Files
BlackboxBook/scripts/validate_book_format.py
2026-05-20 20:55:03 +03:00

244 lines
8.5 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
import re
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_BOOK_ROOT = REPO_ROOT / "book"
BOOK_FILENAME_PATTERN = re.compile(r"^\d{2}_.+\.md$")
NUMBERED_SECTION_PATTERN = re.compile(r"^##\s+(\d+)\.(\d+)\.")
TABLE_SEPARATOR_CELL_PATTERN = re.compile(r":?-{3,}:?")
LATEX_BOOK_MACRO_PATTERN = re.compile(r"\\Book[A-Za-z]+")
REDUNDANT_TABLE_SYMBOL_PATTERN = re.compile(r"^[ \t]*[✓✗⚠↻★][ \t]+\S")
FORBIDDEN_TEXT_SNIPPETS = {
"": "Use `✓` instead of `✅`.",
"": "Use `✗` instead of `❌`.",
"⚠️": "Use `⚠` instead of `⚠️`.",
"": "Use `⚠` or plain text instead of `❗`.",
"🔄": "Use `↻` instead of `🔄`.",
"": "Use `↻` instead of `↺`.",
"": "Use `★` instead of `⭐`.",
"": "Use a normal space instead of a thin space (U+2009).",
"": "Use `_n` instead of subscript `ₙ`.",
}
REQUIRED_END_BLOCKS = (
"## Источники",
"**Навигация:**",
)
RECOMMENDED_END_BLOCKS = (
"## Практический вывод",
)
END_BLOCK_PATTERNS = {
"## Практический вывод": re.compile(r"^##\s+(?:\d+(?:\.\d+)*\.\s+)?Практический вывод\s*$"),
}
@dataclass(frozen=True)
class Issue:
severity: str
path: Path
line: int
code: str
message: str
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Validate BlackboxBook Markdown formatting rules.",
)
parser.add_argument(
"paths",
nargs="*",
default=[str(DEFAULT_BOOK_ROOT)],
help="Markdown files or directories to validate. Defaults to `book/`.",
)
return parser.parse_args()
def discover_markdown_files(paths: list[str]) -> list[Path]:
files: list[Path] = []
seen: set[Path] = set()
for raw_path in paths:
path = Path(raw_path).expanduser().resolve()
if path.is_file():
if path.suffix.lower() == ".md" and path not in seen:
files.append(path)
seen.add(path)
continue
if path.is_dir():
for candidate in sorted(path.rglob("*.md")):
if candidate not in seen and ".git" not in candidate.parts:
files.append(candidate)
seen.add(candidate)
return files
def relative_path(path: Path) -> Path:
try:
return path.relative_to(REPO_ROOT)
except ValueError:
return path
def is_pipe_table_separator(line: str) -> bool:
stripped = line.strip()
if not stripped.startswith("|") or not stripped.endswith("|"):
return False
cells = [cell.strip() for cell in stripped[1:-1].split("|")]
return bool(cells) and all(TABLE_SEPARATOR_CELL_PATTERN.fullmatch(cell.replace(" ", "")) for cell in cells)
def find_block_position(lines: list[str], block: str) -> int | None:
pattern = END_BLOCK_PATTERNS.get(block)
if pattern is None:
return next((index + 1 for index, line in enumerate(lines) if line.strip() == block), None)
return next((index + 1 for index, line in enumerate(lines) if pattern.fullmatch(line.strip())), None)
def validate_file(path: Path) -> list[Issue]:
issues: list[Issue] = []
text = path.read_text(encoding="utf-8")
lines = text.splitlines()
rel_path = relative_path(path)
if path.parent == DEFAULT_BOOK_ROOT and not BOOK_FILENAME_PATTERN.fullmatch(path.name):
issues.append(
Issue("error", rel_path, 1, "filename", "Chapter file name must use `NN_snake_case.md` format."),
)
first_nonempty_line = next(((index + 1, line) for index, line in enumerate(lines) if line.strip()), None)
if not first_nonempty_line:
issues.append(Issue("error", rel_path, 1, "empty-file", "Markdown file is empty."))
return issues
first_line_no, first_line = first_nonempty_line
if not first_line.startswith("# "):
issues.append(
Issue("error", rel_path, first_line_no, "top-heading", "First non-empty line must be a level-1 heading (`# ...`)."),
)
found_positions: list[int] = []
for block in REQUIRED_END_BLOCKS:
position = find_block_position(lines, block)
if position is None:
issues.append(Issue("error", rel_path, 1, "required-block", f"Missing required block `{block}`."))
continue
found_positions.append(position)
if len(found_positions) == len(REQUIRED_END_BLOCKS) and found_positions != sorted(found_positions):
issues.append(
Issue("error", rel_path, found_positions[0], "block-order", "`Источники` must appear before `Навигация`."),
)
recommended_positions: list[int] = []
for block in RECOMMENDED_END_BLOCKS:
position = find_block_position(lines, block)
if position is None:
issues.append(
Issue(
"warning",
rel_path,
1,
"recommended-block",
f"Recommended block `{block}` is missing.",
)
)
continue
recommended_positions.append(position)
order_positions = [*recommended_positions, *found_positions]
if len(order_positions) == len(RECOMMENDED_END_BLOCKS) + len(REQUIRED_END_BLOCKS) and order_positions != sorted(order_positions):
issues.append(
Issue("error", rel_path, order_positions[0], "block-order", "`Практический вывод`, `Источники`, and `Навигация` must stay in this order."),
)
chapter_prefix = None
if path.parent == DEFAULT_BOOK_ROOT:
chapter_prefix = int(path.name[:2])
for index, line in enumerate(lines, start=1):
for forbidden, message in FORBIDDEN_TEXT_SNIPPETS.items():
if forbidden in line:
issues.append(Issue("error", rel_path, index, "forbidden-symbol", message))
if LATEX_BOOK_MACRO_PATTERN.search(line):
issues.append(
Issue("error", rel_path, index, "latex-macro", "Do not use internal `\\Book...` TeX macros in manuscript Markdown."),
)
match = NUMBERED_SECTION_PATTERN.match(line)
if match and chapter_prefix is not None and int(match.group(1)) != chapter_prefix:
issues.append(
Issue(
"error",
rel_path,
index,
"section-number",
f"Section numbering should start with `{chapter_prefix}.x` in this chapter.",
)
)
if line.strip().startswith("|") and line.strip().endswith("|") and not is_pipe_table_separator(line):
cells = [cell.strip() for cell in line.strip()[1:-1].split("|")]
for cell in cells:
if REDUNDANT_TABLE_SYMBOL_PATTERN.match(cell):
issues.append(
Issue(
"error",
rel_path,
index,
"table-symbol",
"Remove redundant leading status symbols from table cells when the text already conveys the meaning.",
)
)
break
return issues
def print_issues(issues: list[Issue]) -> None:
for issue in issues:
print(f"{issue.severity.upper():7} {issue.path}:{issue.line} [{issue.code}] {issue.message}")
def main() -> int:
args = parse_args()
files = discover_markdown_files(args.paths)
if not files:
print("No Markdown files found.", file=sys.stderr)
return 1
all_issues: list[Issue] = []
for path in files:
all_issues.extend(validate_file(path))
if all_issues:
print_issues(sorted(all_issues, key=lambda item: (str(item.path), item.line, item.code)))
error_count = sum(issue.severity == "error" for issue in all_issues)
warning_count = len(all_issues) - error_count
if error_count:
print(f"\nValidation failed: {error_count} error(s), {warning_count} warning(s) across {len(files)} file(s).")
else:
print(f"\nValidation completed with warnings: {warning_count} warning(s) across {len(files)} file(s).")
return 1 if error_count else 0
print(f"Validation passed: {len(files)} file(s) checked, no formatting issues found.")
return 0
if __name__ == "__main__":
raise SystemExit(main())