Skip to content
zit Artifact [11a2ee649b]
Login

Artifact [11a2ee649b]

Artifact 11a2ee649bcc3c92d7e512e0cf5a232549c4b7b044ce78aa955351975ecd9459:


#!/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())