""" 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; display 70, LEGO 150) 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.markup import escape as _rich_escape 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", "prettify": _SCRIPTS_DIR / "utilities" / "prettify_pipe_tables.py", } class FormatCommand: """Auto-format QMD content.""" TARGETS = ["blanks", "python", "lists", "divs", "percent-tables", "mitpress-terms", "tables", "prettify", "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 ("help", "-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, "percent-tables": self._run_percent_tables, "mitpress-terms": self._run_mitpress_terms, "tables": self._run_tables, "prettify": self._run_prettify, } 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 via Black (display 70, LEGO cells 150)") 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("percent-tables", "Rewrite 'percent' → % inside pipe tables (native)") table.add_row("mitpress-terms", "Apply §10.7 canonical spellings (trade-off, Wi-Fi, …)") table.add_row("tables", "Prettify grid tables (align columns, bold headers)") table.add_row("prettify", "Prettify pipe tables (align columns)") table.add_row("all", "Run all formatters") console.print(Panel( table, title=_rich_escape("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", "percent-tables", "mitpress-terms", "tables", "prettify"): dispatch = { "blanks": self._run_blanks, "python": self._run_python, "lists": self._run_lists, "divs": self._run_divs, "percent-tables": self._run_percent_tables, "mitpress-terms": self._run_mitpress_terms, "tables": self._run_tables, "prettify": self._run_prettify, } 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_mitpress_terms(self, file_args: List[str], check_only: bool) -> bool: """Apply the MIT Press canonical spelling dictionary (§10.7). Rewrites unambiguous spelling/hyphenation errors and proper-noun miscapitalizations (tradeoff → trade-off, datacenter → data center, WiFi → Wi-Fi, …). Shares its detection/rewrite with the ``spelling-dict`` validator scope via ``cli.checks.mitpress_terms`` so the fixer fixes exactly what the check flags; skips glossary files. """ from cli.checks.mitpress_terms import fix_text, should_skip_file qmd_files = self._resolve_files(file_args) modified = [] for path in qmd_files: if should_skip_file(path): continue content = path.read_text(encoding="utf-8") new_content, n = fix_text(content) if n: 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]mitpress-terms: {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 console.print("[green]mitpress-terms: All files clean[/green]") return True def _run_percent_tables(self, file_args: List[str], check_only: bool) -> bool: """Rewrite the word 'percent' to the % symbol inside pipe tables. Tables invert the prose convention: dense cells use the % symbol ('86.4%') where body prose spells out 'percent'. Shares its detection and rewrite with the ``percent-in-tables`` validator scope via ``cli.checks.percent_tables`` so the fixer fixes exactly what the check flags. Must run BEFORE ``prettify`` so columns realign after the substitution shortens cells. """ from cli.checks.percent_tables import fix_text qmd_files = self._resolve_files(file_args) modified = [] for path in qmd_files: content = path.read_text(encoding="utf-8") new_content, n = fix_text(content) if n: 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]percent-tables: {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 console.print("[green]percent-tables: All files clean[/green]") return True 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 and HTML comments. Fence tracking must ignore HTML comments: commented-out figures often contain partial code blocks whose unbalanced ``` lines would otherwise desync the toggle, making the formatter treat real code cells as prose for the rest of the file. """ lines = content.split("\n") result = [] in_code_block = False in_html_comment = False blank_count = 0 for line in lines: if in_html_comment: # Leave commented-out regions verbatim; their fences # (often unbalanced) must not toggle code-block state. result.append(line) if "-->" in line: in_html_comment = False continue if not in_code_block and "" not in line: if blank_count > 0: result.append("") blank_count = 0 result.append(line) in_html_comment = True continue 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 fix_bullet_spacing.py) # ------------------------------------------------------------------ _LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]\s+\S|\d+[.)]\s+\S)") _BOLD_MARKDOWN_RE = re.compile(r"(? bool: """Fix list spacing: blank line before lists, tight consecutive items.""" qmd_files = self._resolve_files(file_args) modified = [] for path in qmd_files: try: content = path.read_text(encoding="utf-8") except Exception: continue new_content = self._fix_list_spacing(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]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 @classmethod def _is_list_item(cls, line: str) -> bool: """Check if a line is a bullet or numbered list item.""" return bool(cls._LIST_ITEM_RE.match(line)) @classmethod def _has_bold_markdown(cls, line: str) -> bool: """Check if a line contains Markdown bold text.""" return bool(cls._BOLD_MARKDOWN_RE.search(line)) @classmethod def _fix_list_spacing(cls, content: str) -> str: """Fix list spacing in content. 1. Add blank line before lists when preceded by paragraph text ending with ':' or a bold lead-in paragraph 2. Remove blank lines between consecutive list items (tight lists) Skips code blocks. """ lines = content.split("\n") result: List[str] = [] in_code = False i = 0 while i < len(lines): line = lines[i] stripped = line.strip() # Track code blocks if stripped.startswith("```"): in_code = not in_code result.append(line) i += 1 continue if in_code: result.append(line) i += 1 continue # Fix 1: Add blank line before list when missing if i < len(lines) - 1: next_line = lines[i + 1] if ( (stripped.endswith(":") or cls._has_bold_markdown(stripped)) and not stripped.startswith("```") and not stripped.startswith("#|") and "://" not in stripped and not stripped.startswith("def ") and not stripped.startswith("class ") and not cls._is_list_item(line) and cls._is_list_item(next_line) ): result.append(line) result.append("") i += 1 continue # Fix 2: Remove blank lines between consecutive list items if cls._is_list_item(line): result.append(line) j = i + 1 blank_count = 0 while j < len(lines) and lines[j].strip() == "": blank_count += 1 j += 1 if blank_count > 0 and j < len(lines) and cls._is_list_item(lines[j]): # Skip the blank lines — go directly to next list item i = j continue i += 1 continue result.append(line) i += 1 return "\n".join(result) # ------------------------------------------------------------------ # 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 def _run_prettify(self, file_args: List[str], check_only: bool) -> bool: """Prettify pipe tables (align columns).""" script = _SCRIPT_PATHS["prettify"] if not script.exists(): console.print(f"[red]prettify: Script not found: {script}[/red]") return False cmd = [sys.executable, str(script)] if check_only: cmd.append("--check") if file_args: cmd.extend(file_args) else: content_dir = self.config_manager.book_dir / "contents" cmd.append(str(content_dir)) result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0: return True else: if result.stdout: for line in result.stdout.strip().splitlines()[:10]: console.print(f" [yellow]{line}[/yellow]") console.print(f"[yellow]prettify: Tables reformatted (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)