244 lines
8.5 KiB
Python
244 lines
8.5 KiB
Python
#!/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())
|