#!/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())