MessageWeave/scripts/docs_check/reanchor.py

124 lines
4.5 KiB
Python

#!/usr/bin/env python3
"""Re-anchor docs line references after a code edit.
`git diff -U0 HEAD -- src/<file>` gives the exact add/del blocks, so old -> new
line numbers are cumulative and need no text matching. Every `foo.rs:N` /
`foo.rs:N-M` reference in docs/ is translated in place; unchanged references are
left alone.
Usage: reanchor.py [--check]
(default) apply fixes to docs/*.md and print what changed
--check report only; exit 1 if any anchor would move
"""
import re
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
ANCHOR = re.compile(r"(?<![\w.:])([A-Za-z_][\w-]*\.rs):(\d+)(?:-(\d+))?")
HUNK = re.compile(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
DOCS = sorted((REPO / "docs").glob("*.md"))
CHECK_ONLY = "--check" in sys.argv
def hunk_map(path: str) -> list[tuple[int, int, int, int]]:
raw = subprocess.run(
["git", "diff", "-U0", "HEAD", "--", f"src/{path}"],
cwd=REPO,
capture_output=True,
text=True,
)
return [
(int(m.group(1)), int(m.group(2) or 1), int(m.group(3)), int(m.group(4) or 1))
for m in HUNK.finditer(raw.stdout)
]
def old_to_new(hunks: list[tuple[int, int, int, int]], old_line: int) -> int:
"""Translate one old line number through the hunks above it."""
for o_start, o_len, n_start, n_len in hunks:
if old_line < o_start:
break
if old_line <= o_start + o_len - 1:
# Inside a replaced block: map by position; a pure deletion has no
# successor, so fall back to the hunk's new start.
return n_start + max(n_len - 1, 0) if n_len == 0 else n_start + (old_line - o_start)
delta = sum(
n_len - o_len for o_start, o_len, _n_start, n_len in hunks if o_start <= old_line
)
return old_line + delta
def head_text(doc: Path) -> list[str]:
"""The pre-edit version of a doc, so --check compares like with like."""
raw = subprocess.run(
["git", "show", f"HEAD:{doc.relative_to(REPO)}"],
cwd=REPO,
capture_output=True,
text=True,
)
return raw.stdout.splitlines() if raw.returncode == 0 else doc.read_text().splitlines()
def main() -> int:
caches: dict[str, list[tuple[int, int, int, int]]] = {}
changed_files = 0
changed_refs = 0
for doc in DOCS:
current = doc.read_text().splitlines()
baseline = head_text(doc) if CHECK_ONLY else current
lines = list(current)
dirty = False
for lineno, line in enumerate(lines):
if CHECK_ONLY:
if lineno < len(baseline):
line = baseline[lineno]
else:
continue
out: list[str] = []
pos = 0
for m in ANCHOR.finditer(line):
fname = m.group(1)
if not sorted((REPO / "src").rglob(fname)):
continue
hunks = caches.setdefault(fname, hunk_map(fname))
if not hunks:
continue
old_start = int(m.group(2))
old_end = int(m.group(3)) if m.group(3) else old_start
new_start, new_end = old_to_new(hunks, old_start), old_to_new(hunks, old_end)
if (new_start, new_end) == (old_start, old_end):
continue
replacement = f"{fname}:{new_start}" if new_start == new_end else f"{fname}:{new_start}-{new_end}"
if CHECK_ONLY:
# A doc may have been restructured since HEAD, so compare by
# value across the whole file instead of by line index.
if m.group(0) in "\n".join(current):
changed_refs += 1
print(f" STALE {doc.name} {m.group(0)} still present; want {replacement}")
continue
out.append(line[pos : m.start()])
out.append(replacement)
pos = m.end()
changed_refs += 1
print(f" {doc.name}:{lineno + 1} {m.group(0)} -> {replacement}")
dirty = True
if out:
out.append(line[pos:])
lines[lineno] = "".join(out)
dirty = True
if dirty:
doc.write_text("\n".join(lines) + "\n")
changed_files += 1
print(f"refs={changed_refs} files={changed_files}" + (" (check only)" if CHECK_ONLY else ""))
return 1 if changed_refs and CHECK_ONLY else 0
if __name__ == "__main__":
sys.exit(main())