diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a28840778..a3b959dd3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -121,7 +121,7 @@ repos: - id: book-verify-section-ids name: "Book: Verify all sections have IDs" # NOTE: Currently only checking Vol1 - Vol2 is still in early development - entry: ./book/binder validate headers --vol1 + entry: ./book/binder check headers --vol1 language: system pass_filenames: false files: ^book/quarto/contents/vol1/.*\.qmd$ @@ -158,14 +158,14 @@ repos: - id: book-validate-footnotes name: "Book: Validate footnote references" - entry: ./book/binder validate footnote-refs + entry: ./book/binder check footnotes --scope integrity language: system pass_filenames: false files: ^book/quarto/contents/.*\.qmd$ - id: book-check-forbidden-footnotes name: "Book: Check for footnotes in tables/captions" - entry: ./book/binder validate footnote-placement + entry: ./book/binder check footnotes --scope placement language: system pass_filenames: false files: ^book/quarto/contents/.*\.qmd$ @@ -195,14 +195,14 @@ repos: - id: book-check-figure-completeness name: "Book: Check figures have captions and alt-text" - entry: ./book/binder validate figures + entry: ./book/binder check figures --scope captions language: system pass_filenames: false files: ^book/quarto/contents/.*\.qmd$ - id: book-check-figure-placement name: "Book: Check figure/table placement (near first reference)" - entry: ./book/binder validate float-flow + entry: ./book/binder check figures --scope flow language: system pass_filenames: false files: ^book/quarto/contents/.*\.qmd$ @@ -231,7 +231,7 @@ repos: - id: book-check-render-patterns name: "Book: Check for rendering issues (LaTeX+Python)" - entry: ./book/binder validate rendering + entry: ./book/binder check rendering --scope patterns language: system pass_filenames: false files: ^book/quarto/contents/.*\.qmd$ @@ -239,7 +239,7 @@ repos: - id: book-validate-dropcap name: "Book: Validate drop cap compatibility" - entry: ./book/binder validate dropcaps + entry: ./book/binder check rendering --scope dropcaps language: system pass_filenames: false files: ^book/quarto/contents/.*\.qmd$ @@ -261,14 +261,14 @@ repos: - id: book-check-index-placement name: "Book: Check index placement (not inline with headings/callouts)" - entry: ./book/binder validate indexes + entry: ./book/binder check rendering --scope indexes language: system pass_filenames: false files: ^book/quarto/contents/.*\.qmd$ - id: book-validate-part-keys name: "Book: Validate part keys" - entry: ./book/binder validate parts + entry: ./book/binder check rendering --scope parts language: system pass_filenames: false files: ^book/.*\.qmd$ diff --git a/book/cli/commands/formatting.py b/book/cli/commands/formatting.py new file mode 100644 index 000000000..0c9247411 --- /dev/null +++ b/book/cli/commands/formatting.py @@ -0,0 +1,394 @@ +""" +Format commands for MLSysBook CLI. + +Auto-formatters for QMD content: blank lines, Python code blocks, +list spacing, div spacing, and table formatting. + +Usage: + binder format blanks — Collapse extra blank lines + binder format python — Format Python code blocks (Black, 70 chars) + binder format lists — Fix bullet list spacing + binder format divs — Fix div/callout spacing + binder format tables — Prettify grid tables + binder format all — Run all formatters +""" + +import re +import sys +import subprocess +from pathlib import Path +from typing import List, Optional + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +console = Console() + +# Paths to legacy scripts (used for complex formatters not yet natively ported) +_SCRIPTS_DIR = Path(__file__).resolve().parent.parent.parent / "tools" / "scripts" +_SCRIPT_PATHS = { + "python": _SCRIPTS_DIR / "content" / "format_python_in_qmd.py", + "tables": _SCRIPTS_DIR / "content" / "format_tables.py", + "divs": _SCRIPTS_DIR / "content" / "format_div_spacing.py", +} + + +class FormatCommand: + """Auto-format QMD content.""" + + TARGETS = ["blanks", "python", "lists", "divs", "tables", "all"] + + def __init__(self, config_manager, chapter_discovery): + self.config_manager = config_manager + self.chapter_discovery = chapter_discovery + + def run(self, args: List[str]) -> bool: + """Entry point — parse args and dispatch.""" + if not args or args[0] in ("-h", "--help"): + self._print_help() + return True + + target = args[0] + if target not in self.TARGETS: + console.print(f"[red]Unknown format target: {target}[/red]") + self._print_help() + return False + + # Remaining args are file paths or flags + rest = args[1:] + files, check_only = self._parse_rest(rest) + + if target == "all": + return self._run_all(files, check_only) + + dispatch = { + "blanks": self._run_blanks, + "python": self._run_python, + "lists": self._run_lists, + "divs": self._run_divs, + "tables": self._run_tables, + } + return dispatch[target](files, check_only) + + # ------------------------------------------------------------------ + # Argument helpers + # ------------------------------------------------------------------ + + def _parse_rest(self, rest: List[str]) -> tuple: + """Parse remaining args into (file_list, check_only).""" + check_only = False + files: List[str] = [] + for arg in rest: + if arg in ("--check", "-c"): + check_only = True + elif arg.startswith("-"): + pass # ignore unknown flags gracefully + else: + files.append(arg) + return files, check_only + + def _resolve_files(self, file_args: List[str]) -> List[Path]: + """Resolve file arguments to a list of QMD paths.""" + if file_args: + result = [] + for f in file_args: + p = Path(f) + if not p.is_absolute(): + p = (Path.cwd() / p).resolve() + if p.is_dir(): + result.extend(sorted(p.rglob("*.qmd"))) + elif p.suffix == ".qmd" and p.exists(): + result.append(p) + return result + # Default: all content files + base = self.config_manager.book_dir / "contents" + return sorted(base.rglob("*.qmd")) + + # ------------------------------------------------------------------ + # Help + # ------------------------------------------------------------------ + + def _print_help(self) -> None: + table = Table(show_header=True, header_style="bold cyan", box=None) + table.add_column("Target", style="cyan", width=12) + table.add_column("Description", style="white", width=45) + + table.add_row("blanks", "Collapse extra blank lines (native)") + table.add_row("python", "Format Python code blocks via Black (70 chars)") + table.add_row("lists", "Fix bullet list spacing (blank line before lists)") + table.add_row("divs", "Fix div/callout spacing (paragraph ↔ list gaps)") + table.add_row("tables", "Prettify grid tables (align columns, bold headers)") + table.add_row("all", "Run all formatters") + + console.print(Panel(table, title="binder format [files...] [--check]", border_style="cyan")) + console.print("[dim]Examples:[/dim]") + console.print(" [cyan]./binder format blanks[/cyan] [dim]# fix all files[/dim]") + console.print(" [cyan]./binder format tables --check[/cyan] [dim]# check only, no writes[/dim]") + console.print(" [cyan]./binder format python path/to/ch.qmd[/cyan] [dim]# single file[/dim]") + console.print() + + # ------------------------------------------------------------------ + # Run all + # ------------------------------------------------------------------ + + def _run_all(self, files: List[str], check_only: bool) -> bool: + results = [] + for target in ("blanks", "lists", "divs", "python", "tables"): + dispatch = { + "blanks": self._run_blanks, + "python": self._run_python, + "lists": self._run_lists, + "divs": self._run_divs, + "tables": self._run_tables, + } + ok = dispatch[target](files, check_only) + results.append((target, ok)) + + table = Table(show_header=True, header_style="bold cyan", box=None) + table.add_column("Formatter", style="cyan") + table.add_column("Status", style="white") + for name, ok in results: + status = "[green]PASS[/green]" if ok else "[red]MODIFIED[/red]" + table.add_row(name, status) + console.print(Panel(table, title="Binder Format Summary", border_style="cyan")) + + return all(ok for _, ok in results) + + # ------------------------------------------------------------------ + # Blanks (native — ported from format_blank_lines.py) + # ------------------------------------------------------------------ + + def _run_blanks(self, file_args: List[str], check_only: bool) -> bool: + """Collapse multiple consecutive blank lines into single blank lines.""" + qmd_files = self._resolve_files(file_args) + modified = [] + + for path in qmd_files: + content = path.read_text(encoding="utf-8") + new_content = self._collapse_blank_lines(content) + if new_content != content: + if not check_only: + path.write_text(new_content, encoding="utf-8") + modified.append(path) + + if modified: + label = "Would modify" if check_only else "Modified" + console.print(f"[yellow]blanks: {label} {len(modified)} file(s)[/yellow]") + for p in modified[:10]: + console.print(f" {self._rel(p)}") + if len(modified) > 10: + console.print(f" [dim]... {len(modified) - 10} more[/dim]") + return False # pre-commit convention: modified = exit 1 + else: + console.print("[green]blanks: All files clean[/green]") + return True + + @staticmethod + def _collapse_blank_lines(content: str) -> str: + """Replace multiple consecutive blank lines with a single blank line. + + Preserves content inside code blocks. + """ + lines = content.split("\n") + result = [] + in_code_block = False + blank_count = 0 + + for line in lines: + if line.strip().startswith("```"): + in_code_block = not in_code_block + if blank_count > 0: + result.append("") + blank_count = 0 + result.append(line) + continue + + if in_code_block: + result.append(line) + continue + + if line.strip() == "": + blank_count += 1 + else: + if blank_count > 0: + result.append("") + blank_count = 0 + result.append(line) + + if blank_count > 0: + result.append("") + + return "\n".join(result) + + # ------------------------------------------------------------------ + # Lists (native — ported from check_list_formatting.py) + # ------------------------------------------------------------------ + + def _run_lists(self, file_args: List[str], check_only: bool) -> bool: + """Ensure blank line before bullet lists after colon-ending lines.""" + qmd_files = self._resolve_files(file_args) + modified = [] + + for path in qmd_files: + try: + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + except Exception: + continue + + new_lines, changed = self._fix_list_spacing(lines) + if changed: + if not check_only: + path.write_text("".join(new_lines), encoding="utf-8") + modified.append(path) + + if modified: + label = "Would modify" if check_only else "Modified" + console.print(f"[yellow]lists: {label} {len(modified)} file(s)[/yellow]") + for p in modified[:10]: + console.print(f" {self._rel(p)}") + if len(modified) > 10: + console.print(f" [dim]... {len(modified) - 10} more[/dim]") + return False + else: + console.print("[green]lists: All files clean[/green]") + return True + + @staticmethod + def _fix_list_spacing(lines: List[str]) -> tuple: + """Insert blank line between colon-ending paragraph and bullet list. + + Returns (new_lines, changed). + """ + new_lines = [] + changed = False + in_code_block = False + + for i, line in enumerate(lines): + stripped = line.rstrip() + if stripped.startswith("```"): + in_code_block = not in_code_block + + new_lines.append(line) + + if in_code_block or i + 1 >= len(lines): + continue + + next_line = lines[i + 1].rstrip() + if ( + stripped + and stripped.endswith(":") + and not stripped.startswith("```") + and not stripped.startswith(":::") + and not stripped.startswith("#") + and not stripped.startswith("|") + and next_line.startswith("- ") + ): + new_lines.append("\n") + changed = True + + return new_lines, changed + + # ------------------------------------------------------------------ + # Python (delegates to format_python_in_qmd.py) + # ------------------------------------------------------------------ + + def _run_python(self, file_args: List[str], check_only: bool) -> bool: + """Format Python code blocks using Black.""" + script = _SCRIPT_PATHS["python"] + if not script.exists(): + console.print(f"[red]python: Script not found: {script}[/red]") + return False + + qmd_files = self._resolve_files(file_args) + if not qmd_files: + console.print("[green]python: No files to process[/green]") + return True + + cmd = [sys.executable, str(script)] + [str(f) for f in qmd_files] + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode == 0: + console.print("[green]python: All files clean[/green]") + return True + else: + if result.stdout: + for line in result.stdout.strip().splitlines()[:10]: + console.print(f" [yellow]{line}[/yellow]") + console.print(f"[yellow]python: {len(qmd_files)} file(s) processed[/yellow]") + return False + + # ------------------------------------------------------------------ + # Divs (delegates to format_div_spacing.py) + # ------------------------------------------------------------------ + + def _run_divs(self, file_args: List[str], check_only: bool) -> bool: + """Fix div/callout spacing.""" + script = _SCRIPT_PATHS["divs"] + if not script.exists(): + console.print(f"[red]divs: Script not found: {script}[/red]") + return False + + qmd_files = self._resolve_files(file_args) + if not qmd_files: + console.print("[green]divs: No files to process[/green]") + return True + + modified_count = 0 + for path in qmd_files: + mode = "--check" if check_only else "-f" + cmd = [sys.executable, str(script), mode, str(path)] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + modified_count += 1 + + if modified_count: + label = "Would modify" if check_only else "Modified" + console.print(f"[yellow]divs: {label} {modified_count} file(s)[/yellow]") + return False + else: + console.print("[green]divs: All files clean[/green]") + return True + + # ------------------------------------------------------------------ + # Tables (delegates to format_tables.py) + # ------------------------------------------------------------------ + + def _run_tables(self, file_args: List[str], check_only: bool) -> bool: + """Prettify grid tables.""" + script = _SCRIPT_PATHS["tables"] + if not script.exists(): + console.print(f"[red]tables: Script not found: {script}[/red]") + return False + + mode = "--check" if check_only else "--fix" + if file_args: + cmd = [sys.executable, str(script), mode] + for f in file_args: + cmd.extend(["-f", f]) + else: + content_dir = self.config_manager.book_dir / "contents" + cmd = [sys.executable, str(script), mode, "-d", str(content_dir)] + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode == 0: + console.print("[green]tables: All files clean[/green]") + return True + else: + if result.stdout: + for line in result.stdout.strip().splitlines()[:10]: + console.print(f" [yellow]{line}[/yellow]") + console.print(f"[yellow]tables: Issues found (exit {result.returncode})[/yellow]") + return False + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _rel(self, path: Path) -> str: + """Return path relative to book dir for display.""" + try: + return str(path.relative_to(self.config_manager.book_dir)) + except ValueError: + return str(path) diff --git a/book/cli/commands/validate.py b/book/cli/commands/validate.py index bbe053ef0..c9f9a4e57 100644 --- a/book/cli/commands/validate.py +++ b/book/cli/commands/validate.py @@ -92,56 +92,83 @@ EXCLUDED_CITATION_PREFIXES = ("fig-", "tbl-", "sec-", "eq-", "lst-", "ch-") class ValidateCommand: - """Native `binder validate` command group.""" + """Native `binder check` command group (also available as `binder validate`). + + Groups: + refs — inline-python, cross-refs, citations, inline patterns + labels — duplicate labels, orphaned/unreferenced labels + headers — section header IDs + footnotes — placement rules, reference integrity + figures — captions/alt-text, float flow, image files + rendering — render patterns, indexes, dropcaps, parts + all — run every check + """ + + # Maps group name → list of (scope_name, runner_method_name) pairs. + # This is the single source of truth for the hierarchy. + GROUPS: Dict[str, List[tuple]] = { + "refs": [ + ("inline-python", "_run_inline_python"), + ("cross-refs", "_run_refs"), + ("citations", "_run_citations"), + ("inline", "_run_inline_refs"), + ], + "labels": [ + ("duplicates", "_run_duplicate_labels"), + ("orphans", "_run_unreferenced_labels"), + ], + "headers": [ + ("ids", "_run_headers"), + ], + "footnotes": [ + ("placement", "_run_footnote_placement"), + ("integrity", "_run_footnote_refs"), + ], + "figures": [ + ("captions", "_run_figures"), + ("flow", "_run_float_flow"), + ("files", "_run_images"), + ], + "rendering": [ + ("patterns", "_run_rendering"), + ("indexes", "_run_indexes"), + ("dropcaps", "_run_dropcaps"), + ("parts", "_run_parts"), + ], + } def __init__(self, config_manager, chapter_discovery): self.config_manager = config_manager self.chapter_discovery = chapter_discovery def run(self, args: List[str]) -> bool: + all_group_names = list(self.GROUPS.keys()) + ["all"] parser = argparse.ArgumentParser( - prog="binder validate", - description="Run Binder-native validation checks", + prog="binder check", + description="Run quality checks on book content", add_help=True, ) parser.add_argument( "subcommand", nargs="?", - choices=[ - "inline-python", - "refs", - "citations", - "duplicate-labels", - "unreferenced-labels", - "inline-refs", - "headers", - "footnote-placement", - "footnote-refs", - "figures", - "float-flow", - "indexes", - "rendering", - "dropcaps", - "parts", - "images", - "all", - ], - help="Validation command to run", + choices=all_group_names, + help="Check group to run (refs, labels, headers, footnotes, figures, rendering, all)", ) - parser.add_argument("--path", default=None, help="File or directory path to validate") - parser.add_argument("--vol1", action="store_true", help="Scope validation to Volume I") - parser.add_argument("--vol2", action="store_true", help="Scope validation to Volume II") + parser.add_argument("--scope", default=None, help="Narrow to a specific check within a group") + parser.add_argument("--path", default=None, help="File or directory path to check") + parser.add_argument("--vol1", action="store_true", help="Scope to Volume I") + parser.add_argument("--vol2", action="store_true", help="Scope to Volume II") parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON output") parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") parser.add_argument("--citations-in-code", action="store_true", help="refs: check citations in code fences") parser.add_argument("--citations-in-raw", action="store_true", help="refs: check citations in raw blocks") - parser.add_argument("--check-patterns", action="store_true", help="inline-refs: include pattern hazard checks") - parser.add_argument("--figures", action="store_true", help="duplicate/unreferenced labels: figures") - parser.add_argument("--tables", action="store_true", help="duplicate/unreferenced labels: tables") - parser.add_argument("--sections", action="store_true", help="duplicate/unreferenced labels: sections") - parser.add_argument("--equations", action="store_true", help="duplicate/unreferenced labels: equations") - parser.add_argument("--listings", action="store_true", help="duplicate/unreferenced labels: listings") - parser.add_argument("--all-types", action="store_true", help="duplicate/unreferenced labels: all types") + parser.add_argument("--check-patterns", action="store_true", help="refs --scope inline: include pattern hazard checks") + parser.add_argument("--figures", action="store_true", help="labels: filter to figures") + parser.add_argument("--tables", action="store_true", help="labels: filter to tables") + parser.add_argument("--sections", action="store_true", help="labels: filter to sections") + parser.add_argument("--equations", action="store_true", help="labels: filter to equations") + parser.add_argument("--listings", action="store_true", help="labels: filter to listings") + parser.add_argument("--all-types", action="store_true", help="labels: all label types") try: ns = parser.parse_args(args) @@ -150,7 +177,7 @@ class ValidateCommand: return ("-h" in args) or ("--help" in args) if not ns.subcommand: - parser.print_help() + self._print_check_help() return False root_path = self._resolve_path(ns.path, ns.vol1, ns.vol2) @@ -159,58 +186,19 @@ class ValidateCommand: return False runs: List[ValidationRunResult] = [] + if ns.subcommand == "all": - runs.append(self._run_inline_python(root_path)) - runs.append(self._run_refs(root_path, citations_in_code=True, citations_in_raw=True)) - runs.append(self._run_citations(root_path)) - label_types = self._selected_label_types(ns) - runs.append(self._run_duplicate_labels(root_path, label_types)) - runs.append(self._run_unreferenced_labels(root_path, label_types)) - runs.append(self._run_inline_refs(root_path, check_patterns=ns.check_patterns)) - runs.append(self._run_headers(root_path)) - runs.append(self._run_footnote_placement(root_path)) - runs.append(self._run_footnote_refs(root_path)) - runs.append(self._run_figures(root_path)) - runs.append(self._run_float_flow(root_path)) - runs.append(self._run_indexes(root_path)) - runs.append(self._run_rendering(root_path)) - runs.append(self._run_dropcaps(root_path)) - runs.append(self._run_parts(root_path)) - runs.append(self._run_images(root_path)) - elif ns.subcommand == "inline-python": - runs.append(self._run_inline_python(root_path)) - elif ns.subcommand == "refs": - checks_code = ns.citations_in_code or (not ns.citations_in_code and not ns.citations_in_raw) - checks_raw = ns.citations_in_raw or (not ns.citations_in_code and not ns.citations_in_raw) - runs.append(self._run_refs(root_path, citations_in_code=checks_code, citations_in_raw=checks_raw)) - elif ns.subcommand == "citations": - runs.append(self._run_citations(root_path)) - elif ns.subcommand == "duplicate-labels": - runs.append(self._run_duplicate_labels(root_path, self._selected_label_types(ns))) - elif ns.subcommand == "unreferenced-labels": - runs.append(self._run_unreferenced_labels(root_path, self._selected_label_types(ns))) - elif ns.subcommand == "inline-refs": - runs.append(self._run_inline_refs(root_path, check_patterns=ns.check_patterns)) - elif ns.subcommand == "headers": - runs.append(self._run_headers(root_path)) - elif ns.subcommand == "footnote-placement": - runs.append(self._run_footnote_placement(root_path)) - elif ns.subcommand == "footnote-refs": - runs.append(self._run_footnote_refs(root_path)) - elif ns.subcommand == "figures": - runs.append(self._run_figures(root_path)) - elif ns.subcommand == "float-flow": - runs.append(self._run_float_flow(root_path)) - elif ns.subcommand == "indexes": - runs.append(self._run_indexes(root_path)) - elif ns.subcommand == "rendering": - runs.append(self._run_rendering(root_path)) - elif ns.subcommand == "dropcaps": - runs.append(self._run_dropcaps(root_path)) - elif ns.subcommand == "parts": - runs.append(self._run_parts(root_path)) - elif ns.subcommand == "images": - runs.append(self._run_images(root_path)) + for group_name in self.GROUPS: + runs.extend(self._run_group(group_name, None, root_path, ns)) + else: + group_name = ns.subcommand + scope = ns.scope + if scope and not any(s == scope for s, _ in self.GROUPS.get(group_name, [])): + valid = [s for s, _ in self.GROUPS[group_name]] + console.print(f"[red]Unknown scope '{scope}' for group '{group_name}'.[/red]") + console.print(f"[yellow]Valid scopes: {', '.join(valid)}[/yellow]") + return False + runs.extend(self._run_group(group_name, scope, root_path, ns)) any_failed = any(not run.passed for run in runs) summary = { @@ -228,6 +216,67 @@ class ValidateCommand: return not any_failed + # ------------------------------------------------------------------ + # Group dispatch + # ------------------------------------------------------------------ + + def _run_group( + self, + group: str, + scope: Optional[str], + root: Path, + ns: argparse.Namespace, + ) -> List[ValidationRunResult]: + """Run all checks in *group*, or just the one matching *scope*.""" + results: List[ValidationRunResult] = [] + for scope_name, method_name in self.GROUPS[group]: + if scope and scope != scope_name: + continue + method = getattr(self, method_name) + # Some runners need extra kwargs + if method_name == "_run_refs": + checks_code = ns.citations_in_code or (not ns.citations_in_code and not ns.citations_in_raw) + checks_raw = ns.citations_in_raw or (not ns.citations_in_code and not ns.citations_in_raw) + results.append(method(root, citations_in_code=checks_code, citations_in_raw=checks_raw)) + elif method_name == "_run_inline_refs": + results.append(method(root, check_patterns=ns.check_patterns)) + elif method_name in ("_run_duplicate_labels", "_run_unreferenced_labels"): + results.append(method(root, self._selected_label_types(ns))) + else: + results.append(method(root)) + return results + + def _print_check_help(self) -> None: + """Print a nicely formatted help for the check command.""" + table = Table(show_header=True, header_style="bold cyan", box=None) + table.add_column("Group", style="cyan", width=14) + table.add_column("Scopes", style="yellow", width=38) + table.add_column("Description", style="white", width=32) + + descriptions = { + "refs": "References, citations, inline Python", + "labels": "Duplicate and orphaned labels", + "headers": "Section header IDs ({#sec-...})", + "footnotes": "Footnote placement and integrity", + "figures": "Captions, float flow, image files", + "rendering": "Render patterns, indexes, dropcaps, parts", + } + for group_name, checks in self.GROUPS.items(): + scopes = ", ".join(s for s, _ in checks) + desc = descriptions.get(group_name, "") + table.add_row(group_name, scopes, desc) + table.add_row("all", "(everything)", "Run all checks") + + console.print(Panel(table, title="binder check [--scope ]", border_style="cyan")) + console.print("[dim]Examples:[/dim]") + console.print(" [cyan]./binder check refs[/cyan] [dim]# all reference checks[/dim]") + console.print(" [cyan]./binder check refs --scope citations[/cyan] [dim]# only citation check[/dim]") + console.print(" [cyan]./binder check figures --vol1[/cyan] [dim]# all figure checks, Vol I[/dim]") + console.print(" [cyan]./binder check all[/cyan] [dim]# everything[/dim]") + console.print() + + # ------------------------------------------------------------------ + def _resolve_path(self, path_arg: Optional[str], vol1: bool, vol2: bool) -> Path: if path_arg: path = Path(path_arg) @@ -747,7 +796,7 @@ class ValidateCommand: return ValidationRunResult( name="headers", - description="Verify all section headers have {#sec-...} IDs", + description="Verify section headers have {#sec-...} IDs", files_checked=len(files), issues=issues, elapsed_ms=int((time.time() - start) * 1000), @@ -1645,7 +1694,7 @@ class ValidateCommand: f'{run["elapsed_ms"]}ms', "PASS" if run["passed"] else "FAIL", ) - console.print(Panel(table, title="Binder Validate Summary", border_style="cyan")) + console.print(Panel(table, title="Binder Check Summary", border_style="cyan")) if total == 0: console.print("[green]✅ All validation checks passed.[/green]") diff --git a/book/cli/main.py b/book/cli/main.py index cc0ffde06..41b1bc312 100644 --- a/book/cli/main.py +++ b/book/cli/main.py @@ -25,6 +25,7 @@ try: from cli.commands.maintenance import MaintenanceCommand from cli.commands.debug import DebugCommand from cli.commands.validate import ValidateCommand + from cli.commands.formatting import FormatCommand except ImportError: # When run as local script from core.config import ConfigManager @@ -36,6 +37,7 @@ except ImportError: from commands.maintenance import MaintenanceCommand from commands.debug import DebugCommand from commands.validate import ValidateCommand + from commands.formatting import FormatCommand console = Console() @@ -66,6 +68,7 @@ class MLSysBookCLI: self.maintenance_command = MaintenanceCommand(self.config_manager, self.chapter_discovery) self.debug_command = DebugCommand(self.config_manager, self.chapter_discovery, verbose=verbose) self.validate_command = ValidateCommand(self.config_manager, self.chapter_discovery) + self.format_command = FormatCommand(self.config_manager, self.chapter_discovery) def show_banner(self): """Display the CLI banner.""" @@ -117,31 +120,38 @@ class MLSysBookCLI: full_table.add_row("build pdf --all", "Build full book (both volumes)", "./binder build pdf --all") full_table.add_row("build epub --all", "Build full book (both volumes)", "./binder build epub --all") + # Quality Commands + quality_table = Table(show_header=True, header_style="bold yellow", box=None) + quality_table.add_column("Command", style="yellow", width=38) + quality_table.add_column("Description", style="white", width=30) + quality_table.add_column("Example", style="dim", width=28) + + quality_table.add_row("check [--scope ...]", "Run validation checks", "./binder check refs") + quality_table.add_row("check all", "Run all validation checks", "./binder check all --vol1") + quality_table.add_row("fix ", "Fix/manage content", "./binder fix headers add") + quality_table.add_row("format ", "Auto-format content", "./binder format tables") + # Management Commands mgmt_table = Table(show_header=True, header_style="bold blue", box=None) - mgmt_table.add_column("Command", style="green", width=35) + mgmt_table.add_column("Command", style="green", width=38) mgmt_table.add_column("Description", style="white", width=30) - mgmt_table.add_column("Example", style="dim", width=30) + mgmt_table.add_column("Example", style="dim", width=28) mgmt_table.add_row("debug --vol1|--vol2", "Find failing chapter + section", "./binder debug pdf --vol1") - mgmt_table.add_row("debug --chapter ", "Section-level debug (skip scan)", "./binder debug pdf --vol1 --chapter intro") - mgmt_table.add_row("maintain ...", "Run maintenance namespace commands", "./binder maintain repo-health") - mgmt_table.add_row("validate ", "Run native validation checks", "./binder validate inline-refs") mgmt_table.add_row("clean", "Clean build artifacts", "./binder clean") mgmt_table.add_row("switch ", "Switch active config", "./binder switch pdf") mgmt_table.add_row("list", "List available chapters", "./binder list") mgmt_table.add_row("status", "Show current config status", "./binder status") mgmt_table.add_row("doctor", "Run comprehensive health check", "./binder doctor") mgmt_table.add_row("setup", "Setup development environment", "./binder setup") - mgmt_table.add_row("hello", "Show welcome message", "./binder hello") - mgmt_table.add_row("about", "Show project information", "./binder about") mgmt_table.add_row("help", "Show this help", "./binder help") # Display tables console.print(Panel(fast_table, title="⚡ Fast Chapter Commands", border_style="green")) console.print(Panel(vol_table, title="📖 Volume Commands", border_style="magenta")) console.print(Panel(full_table, title="📚 Full Book Commands", border_style="blue")) - console.print(Panel(mgmt_table, title="🔧 Management", border_style="yellow")) + console.print(Panel(quality_table, title="✅ Quality & Formatting", border_style="yellow")) + console.print(Panel(mgmt_table, title="🔧 Management", border_style="cyan")) # Pro Tips examples = Text() @@ -331,10 +341,18 @@ class MLSysBookCLI: """Handle about command.""" return self.maintenance_command.show_about() - def handle_maintain_command(self, args): - """Handle maintenance namespace command.""" + def handle_check_command(self, args): + """Handle check (validation) command group.""" + return self.validate_command.run(args) + + def handle_fix_command(self, args): + """Handle fix (maintenance) namespace command.""" return self.maintenance_command.run_namespace(args) + def handle_format_command(self, args): + """Handle format command group.""" + return self.format_command.run(args) + def handle_debug_command(self, args): """Handle debug command. @@ -390,10 +408,6 @@ class MLSysBookCLI: self.chapter_discovery.show_chapters(volume=volume) return True - def handle_validate_command(self, args): - """Handle validate command group.""" - return self.validate_command.run(args) - def handle_status_command(self, args): """Handle status command.""" console.print("[bold blue]📊 MLSysBook CLI Status[/bold blue]") @@ -428,11 +442,15 @@ class MLSysBookCLI: "list": self.handle_list_command, "status": self.handle_status_command, "doctor": self.handle_doctor_command, - "maintain": self.handle_maintain_command, - "validate": self.handle_validate_command, + "check": self.handle_check_command, + "fix": self.handle_fix_command, + "format": self.handle_format_command, "setup": self.handle_setup_command, "hello": self.handle_hello_command, "about": self.handle_about_command, + # Aliases (backward compat) + "validate": self.handle_check_command, + "maintain": self.handle_fix_command, "help": lambda args: self.show_help() or True, } diff --git a/book/vscode-ext/src/commands/publishCommands.ts b/book/vscode-ext/src/commands/publishCommands.ts index 982b56c53..93fe88ed2 100644 --- a/book/vscode-ext/src/commands/publishCommands.ts +++ b/book/vscode-ext/src/commands/publishCommands.ts @@ -28,17 +28,17 @@ export function registerPublishCommands(context: vscode.ExtensionContext): void }); }), vscode.commands.registerCommand('mlsysbook.buildGlossary', () => { - void runBookCommand('./book/binder maintain glossary build', root, { + void runBookCommand('./book/binder fix glossary build', root, { label: 'Build global glossary', }); }), vscode.commands.registerCommand('mlsysbook.compressImages', () => { - void runBookCommand('./book/binder maintain images compress --all --smart-compression', root, { + void runBookCommand('./book/binder fix images compress --all --smart-compression', root, { label: 'Compress images', }); }), vscode.commands.registerCommand('mlsysbook.repoHealth', () => { - void runBookCommand('./book/binder maintain repo-health', root, { + void runBookCommand('./book/binder fix repo-health', root, { label: 'Repo health check', }); }), diff --git a/book/vscode-ext/src/constants.ts b/book/vscode-ext/src/constants.ts index 025537ef2..6739672eb 100644 --- a/book/vscode-ext/src/constants.ts +++ b/book/vscode-ext/src/constants.ts @@ -61,17 +61,17 @@ export const PUBLISH_ACTIONS: ActionDef[] = [ export const MAINTENANCE_ACTIONS: ActionDef[] = [ { id: 'clean', label: 'Clean Build Artifacts', command: './book/binder clean', icon: 'trash' }, { id: 'doctor', label: 'Doctor (Health Check)', command: './book/binder doctor', icon: 'heart' }, - { id: 'glossary', label: 'Build Global Glossary', command: './book/binder maintain glossary build', icon: 'book' }, - { id: 'compress-images', label: 'Compress Images (Dry Run, All)', command: './book/binder maintain images compress --all --smart-compression', icon: 'file-media' }, - { id: 'repo-health', label: 'Repo Health Check', command: './book/binder maintain repo-health', icon: 'pulse' }, + { id: 'glossary', label: 'Build Global Glossary', command: './book/binder fix glossary build', icon: 'book' }, + { id: 'compress-images', label: 'Compress Images (Dry Run, All)', command: './book/binder fix images compress --all --smart-compression', icon: 'file-media' }, + { id: 'repo-health', label: 'Repo Health Check', command: './book/binder fix repo-health', icon: 'pulse' }, ]; -export const VALIDATE_ACTIONS: ActionDef[] = [ - { id: 'validate-all', label: 'Validate: All (Binder Native)', command: './book/binder validate all', icon: 'shield' }, - { id: 'validate-inline-python', label: 'Validate: Inline Python', command: './book/binder validate inline-python', icon: 'code' }, - { id: 'validate-refs', label: 'Validate: References in Raw/Code', command: './book/binder validate refs', icon: 'link' }, - { id: 'validate-citations', label: 'Validate: Citation Keys', command: './book/binder validate citations', icon: 'book' }, - { id: 'validate-duplicate-labels', label: 'Validate: Duplicate Labels', command: './book/binder validate duplicate-labels', icon: 'warning' }, - { id: 'validate-unreferenced-labels', label: 'Validate: Unreferenced Labels', command: './book/binder validate unreferenced-labels', icon: 'search' }, - { id: 'validate-inline-refs', label: 'Validate: Inline Refs', command: './book/binder validate inline-refs --check-patterns', icon: 'symbol-variable' }, +export const CHECK_ACTIONS: ActionDef[] = [ + { id: 'check-all', label: 'Check: All', command: './book/binder check all', icon: 'shield' }, + { id: 'check-refs', label: 'Check: References', command: './book/binder check refs', icon: 'link' }, + { id: 'check-labels', label: 'Check: Labels', command: './book/binder check labels', icon: 'search' }, + { id: 'check-headers', label: 'Check: Headers', command: './book/binder check headers', icon: 'symbol-structure' }, + { id: 'check-footnotes', label: 'Check: Footnotes', command: './book/binder check footnotes', icon: 'note' }, + { id: 'check-figures', label: 'Check: Figures', command: './book/binder check figures', icon: 'file-media' }, + { id: 'check-rendering', label: 'Check: Rendering', command: './book/binder check rendering', icon: 'warning' }, ]; diff --git a/book/vscode-ext/src/extension.ts b/book/vscode-ext/src/extension.ts index 0463c42fc..d3dac7b13 100644 --- a/book/vscode-ext/src/extension.ts +++ b/book/vscode-ext/src/extension.ts @@ -259,7 +259,7 @@ export function activate(context: vscode.ExtensionContext): void { ? `--path ${editor.document.uri.fsPath}` : '--vol1'; runInVisibleTerminal( - `./book/binder maintain headers add ${target} --force`, + `./book/binder fix headers add ${target} --force`, root, 'Add Section IDs', ); @@ -267,7 +267,7 @@ export function activate(context: vscode.ExtensionContext): void { vscode.commands.registerCommand('mlsysbook.verifySectionIds', () => { if (!root) { return; } runInVisibleTerminal( - './book/binder validate headers --vol1', + './book/binder check headers --vol1', root, 'Verify Section IDs', ); @@ -275,7 +275,7 @@ export function activate(context: vscode.ExtensionContext): void { vscode.commands.registerCommand('mlsysbook.validateCrossReferences', () => { if (!root) { return; } runInVisibleTerminal( - './book/binder validate unreferenced-labels --vol1 --all-types', + './book/binder check labels --scope orphans --vol1 --all-types', root, 'Validate Cross-References', ); diff --git a/book/vscode-ext/src/providers/precommitTreeProvider.ts b/book/vscode-ext/src/providers/precommitTreeProvider.ts index 31ddd4a7e..aadf7b645 100644 --- a/book/vscode-ext/src/providers/precommitTreeProvider.ts +++ b/book/vscode-ext/src/providers/precommitTreeProvider.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { PRECOMMIT_CHECK_HOOKS, PRECOMMIT_FIXER_HOOKS, VALIDATE_ACTIONS } from '../constants'; +import { PRECOMMIT_CHECK_HOOKS, PRECOMMIT_FIXER_HOOKS, CHECK_ACTIONS } from '../constants'; import { ActionTreeItem } from '../models/treeItems'; type TreeNode = ActionTreeItem | SeparatorItem; @@ -49,7 +49,7 @@ export class PrecommitTreeProvider implements vscode.TreeDataProvider 'table', ); - const validateItems = VALIDATE_ACTIONS.map(action => + const binderCheckItems = CHECK_ACTIONS.map(action => new ActionTreeItem(action.label, 'mlsysbook.validateRunAction', [action.command], action.icon) ); @@ -61,8 +61,8 @@ export class PrecommitTreeProvider implements vscode.TreeDataProvider currentFileFixers, currentFileTableFixer, ...fixerItems, - new SeparatorItem('--- Binder Validate (Fast, Focused) ---'), - ...validateItems, + new SeparatorItem('--- Binder Check (Fast, Focused) ---'), + ...binderCheckItems, ]; } }