#!/usr/bin/env python3 """Static source checks for superfig figures. The package owns layout and colors at render time; this linter catches the source-level escapes TeX cannot see: undeclared roles, too many active hue families, hand-rolled TikZ drawing, a formula placed before the last row, a callout that is not an aside on a band, and a group that wraps one node. """ from __future__ import annotations import argparse import re import sys from pathlib import Path ROLE_DECL_RE = re.compile(r"\\sfsetrole\{([^{}]+)\}\{([^{}]+)\}") ROLE_KEY_RE = re.compile(r"role\s*=\s*([A-Za-z0-9_-]+)") RAW_TIKZ_RE = re.compile(r"\\(?:draw|fill|path)\b") ROW_NAME_RE = re.compile(r"\\sfrow\{([^{}]+)\}") CALLOUT_RE = re.compile(r"\\sfcallout\{([^{}]+)\}\{[^{}]+\}\{([^{}]+)\}") ROW_RE = re.compile(r"\\sfrow\{[^{}]+\}\{[^{}]+\}(.*?)\\sfrowend", re.DOTALL) GROUP_RE = re.compile( r"\\sfgroup(?:\[[^\]]*\])?\{([^{}]+)\}\{([^{}]*)\}\{([^{}]*)\}" ) def strip_comments(source: str) -> str: return re.sub(r"(? int: return source.count("\n", 0, offset) + 1 def directives(source: str) -> set[str]: found: set[str] = set() for match in re.finditer(r"^\s*%\s*superfig-lint:\s*(.+)$", source, re.MULTILINE): found.update(item.strip() for item in match.group(1).split(",")) return found def lint(path: Path) -> list[str]: raw = path.read_text(encoding="utf-8") allowed = directives(raw) source = strip_comments(raw) errors: list[str] = [] declared: dict[str, str] = {"neutral": "sfGray"} for match in ROLE_DECL_RE.finditer(source): name, color = (part.strip() for part in match.groups()) previous = declared.get(name) if previous is not None and previous != color and name != "neutral": errors.append( f"line {line_of(source, match.start())}: role {name!r} changes " f"from {previous!r} to {color!r}" ) else: declared[name] = color for match in ROLE_KEY_RE.finditer(source): role = match.group(1) color = declared.get(role) if color is None: errors.append( f"line {line_of(source, match.start())}: role {role!r} is used " f"before \\sfsetrole" ) for row_number, row in enumerate(ROW_RE.finditer(source), start=1): row_roles = set(ROLE_KEY_RE.findall(row.group(1))) colors = {declared.get(role, "") for role in row_roles} active = colors - {"sfGray"} if len(active) > 4: errors.append( f"line {line_of(source, row.start())}: row {row_number} uses " f"{len(active)} active hue families " f"({', '.join(sorted(active))}); maximum is 4 plus gray" ) if "allow-raw-tikz" not in allowed: for match in RAW_TIKZ_RE.finditer(source): errors.append( f"line {line_of(source, match.start())}: hand-rolled TikZ drawing " f"bypasses \\sfnode/\\sfarrow; add allow-raw-tikz only for a " f"genuinely non-linear detail" ) formula_positions = [m.start() for m in re.finditer(r"\\sftopformula\b", source)] row_ends = [m.start() for m in re.finditer(r"\\sfrowend\b", source)] if formula_positions and row_ends and formula_positions[-1] < row_ends[-1]: errors.append( f"line {line_of(source, formula_positions[-1])}: \\sftopformula " f"must follow the last \\sfrowend" ) band_names = set(ROW_NAME_RE.findall(source)) callout_anchors: dict[str, str] = {} first_callout: str | None = None for match in CALLOUT_RE.finditer(source): name, anchor = (part.strip() for part in match.groups()) line = line_of(source, match.start()) if anchor not in band_names: errors.append( f"line {line}: callout {name!r} is anchored to {anchor!r}, which " f"is not a \\sfrow band; a card hanging off a single object " f"reads as a step" ) elif anchor in callout_anchors: errors.append( f"line {line}: callout {name!r} is the second card on band " f"{anchor!r} (after {callout_anchors[anchor]!r}); one aside " f"per band" ) elif first_callout is not None and "allow-multiple-callouts" not in allowed: errors.append( f"line {line}: callout {name!r} is the second card in the " f"figure (after {first_callout!r}); the budget is one callout " f"per figure" ) callout_anchors.setdefault(anchor, name) if first_callout is None: first_callout = name if "allow-single-group" not in allowed: for match in GROUP_RE.finditer(source): name, fit, _caption = match.groups() members = re.findall(r"\(([^()]+)\)", fit) if len(members) < 2: errors.append( f"line {line_of(source, match.start())}: group {name!r} " f"wraps {len(members)} member(s); bind at least two" ) return errors def main() -> int: parser = argparse.ArgumentParser(description="lint a superfig .tex source") parser.add_argument("sources", nargs="+", type=Path) args = parser.parse_args() failed = False for path in args.sources: if not path.is_file(): print(f"superfig-lint: no such file: {path}", file=sys.stderr) failed = True continue errors = lint(path) if errors: failed = True print(f"!! {path}", file=sys.stderr) for error in errors: print(f" {error}", file=sys.stderr) else: print(f" lint {path}") return 1 if failed else 0 if __name__ == "__main__": raise SystemExit(main())