Adopt per-row ≤4 active hue families, matching supertensor's documented behavior. Also changed tests/lint-invalid/hue-budget.tex to use \sfrow so the per-row rule actually triggers.
162 lines
5.9 KiB
Python
Executable File
162 lines
5.9 KiB
Python
Executable File
#!/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"(?<!\\)%.*$", "", source, flags=re.MULTILINE)
|
|
|
|
|
|
def line_of(source: str, offset: int) -> 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, "<undeclared>") 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())
|