#!/usr/bin/env python3
"""Move every `§X.Y Lnnn` citation under src/ from an old render of a draft to a new one.
rekey-citations.py --draft draft-fossil-repo-state-00.txt --old OLD.txt [--root .]
OLD.txt is the text the citations were written against (`fossil cat
docs/rfcs/NAME -r CHECKIN`); the new text is the file in docs/rfcs. Each cited
line range is carried across a line diff of the two: lines the diff matches
move with their match, and lines inside a replaced or deleted stretch move to
that stretch's replacement, so a citation into an edited paragraph lands on
the edited paragraph. The diff runs one section at a time, and page headers,
footers, form feeds and blank lines never match each other, so neither a
line repeated in another section nor pagination can drag a citation away.
Every rewritten citation is checked against the new section spans the same
way check-citations.py checks it; one that would land outside its section is
left as it was and reported, since it needs a reader. Run check-citations.py
afterwards; this tool only moves what can be moved mechanically.
"""
import argparse
import difflib
import importlib.util
import pathlib
import re
import sys
sys.dont_write_bytecode = True # the checker is loaded as a module; leave no cache beside it
HERE = pathlib.Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location("check_citations", HERE / "check-citations.py")
cc = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cc)
FURNITURE_RE = re.compile(r"^(\f|\s*$|Internet-Draft\s{2,}.*|.*\[Page \d+\]\s*$)")
def neutralized(lines):
"""Lines for the matcher: furniture and blanks made unique so they never pair."""
return [f"\x00{i}" if FURNITURE_RE.match(l) else l for i, l in enumerate(lines)]
def deepest_section_of(lines, spans):
"""For each 0-based line, the innermost section number holding it, or None."""
owner = [None] * len(lines)
for num, (start, end) in sorted(spans.items(), key=lambda kv: (kv[1][0], -kv[1][1])):
for k in range(start - 1, min(end, len(lines))):
owner[k] = num # later, deeper spans overwrite their parents
return owner
def line_map(old_lines, new_lines, old_spans, new_spans):
"""old 1-based line -> (new_lo, new_hi): the matched line, or the replacement stretch.
Matching runs section by section: a draft repeats many lines verbatim
across sections (card descriptions, "Normative change."), and a whole-text
diff pairs those freely, carrying a citation into a section it never
named. Within its own section a line can only pair with its own section.
"""
old_owner = deepest_section_of(old_lines, old_spans)
new_owner = deepest_section_of(new_lines, new_spans)
out = {}
for sec in set(old_spans) & set(new_spans):
oi = [k for k, o in enumerate(old_owner) if o == sec]
ni = [k for k, o in enumerate(new_owner) if o == sec]
if not oi or not ni:
continue
sm = difflib.SequenceMatcher(None, neutralized([old_lines[k] for k in oi]),
neutralized([new_lines[k] for k in ni]), autojunk=False)
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "equal":
for k in range(i1, i2):
out[oi[k] + 1] = (ni[j1 + (k - i1)] + 1, ni[j1 + (k - i1)] + 1)
elif tag in ("replace", "delete"):
lo = ni[min(j1, len(ni) - 1)] + 1
hi = ni[max(j1, j2 - 1) if j2 > j1 else min(j1, len(ni) - 1)] + 1
hi = max(lo, hi)
for k in range(i1, i2):
out[oi[k] + 1] = (lo, hi)
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--draft", required=True, help="file name under docs/rfcs, e.g. draft-fossil-repo-state-00.txt")
ap.add_argument("--old", required=True, type=pathlib.Path, help="the render the citations were written against")
ap.add_argument("--root", default=".", type=pathlib.Path)
args = ap.parse_args()
rfcs = args.root / "docs" / "rfcs"
new_path = rfcs / args.draft
old_lines = args.old.read_text(encoding="utf-8", errors="replace").split("\n")
new_lines = new_path.read_text(encoding="utf-8", errors="replace").split("\n")
old_spans = cc.section_spans(args.old)
new_spans = cc.section_spans(new_path)
lmap = line_map(old_lines, new_lines, old_spans, new_spans)
all_old_spans = {name: cc.section_spans(rfcs / name) for name in set(cc.DRAFTS.values()) if name != args.draft}
all_old_spans[args.draft] = old_spans
moved = kept = left = 0
for src in sorted((args.root / "src").rglob("*.zig")):
rel = str(src.relative_to(args.root / "src"))
default = cc.BARE_DEFAULT.get(rel)
text = src.read_text(encoding="utf-8", errors="replace")
lines = text.split("\n")
changed = False
for ln, line in enumerate(lines):
secs = [(m.start(), m.group(1), m.group(2)) for m in cc.SEC_RE.finditer(line)]
if not secs:
continue
edits = []
for m in cc.RANGE_RE.finditer(line):
prior = [s for s in secs if s[0] < m.start()]
if not prior:
continue
_, word, sec = prior[-1]
lo = int(m.group(1))
hi = int(m.group(2)) if m.group(2) else lo
named = cc.DRAFTS.get((word or "").lower())
candidates = [named] if named else ([default] if default else list(all_old_spans))
# The draft this citation was written against: the first whose
# OLD section holds the range, as the checker resolved it.
draft = None
for d in candidates:
span = all_old_spans.get(d, {}).get(sec)
if span and span[0] <= lo <= hi <= span[1]:
draft = d
break
if draft != args.draft:
continue
nlo = lmap.get(lo, (None, None))[0]
nhi = lmap.get(hi, (None, None))[1]
span = new_spans.get(sec)
if nlo is None or nhi is None or span is None or not (span[0] <= nlo <= nhi <= span[1]):
left += 1
print(f"left: {rel}:{ln + 1}: §{sec} L{lo}-{hi} -> L{nlo}-{nhi}, section now {span}", file=sys.stderr)
continue
new_text = f"L{nlo}" if nlo == nhi else f"L{nlo}-{nhi}"
if new_text == m.group(0):
kept += 1
else:
moved += 1
edits.append((m.start(), m.end(), new_text))
for start, end, new_text in reversed(edits):
line = line[:start] + new_text + line[end:]
if edits:
lines[ln] = line
changed = True
if changed:
src.write_text("\n".join(lines), encoding="utf-8")
print(f"{args.draft}: moved {moved}, unchanged {kept}, left for a reader {left}")
return 1 if left else 0
if __name__ == "__main__":
sys.exit(main())