#!/usr/bin/env python3 # # Usage: # # maint/update-md-links CHANGELOG.md # # Updates a markdown file, merging/inserting links from `gen_md_links` # # New links are added to the next to the first place in the file # where links are defined. # # When we don't know what a link target should be, we emit four Xs. # ALGORITHM # # Look for annotations in the file telling us how to wirk. # # Split the file up into sections (divided by the specified heading level). # # In each section, find existing link def lines # (ie, lines giving the target `t` for a link anchor text `a`). # This gives us # - for each section # - for each anchor, locations of relevant defs in this section # - targets for some anchors # # Feed each section separately to gen_md_links # This gives us # - for each section, needed anchor set # - targets, for some anchors # # Reconcile definitions, to obtain precisely one target for each anchor. # # for each section. # For each needed anchor # If there are def(s) in this section, before the def collection, OK # Otherwise write a a definition line to the new collection # Collect defs from existing final link def collection (if any) # Sort the collection # Replace the relevant part of the file with the normalised collection # (possibly *adding* the collection) import argparse import collections import filecmp import os import re import subprocess import sys import tempfile from typing import Any, Optional, Tuple, Union #---------- "constant" definitions ---------- # regexps link_def_re = re.compile(r'\[([^][]+)\]\:\s?(.*)\n?') heading_re = re.compile(r'(\#+)\s') instruction_re = re.compile(r'\<\!\-\-\@\@\s+update-md-links\s*(.*\S)\s*\-\-\>\s*$') instruction_val_re = re.compile(r'\s*([-0-9a-z]+)\s+(.*\S)\s*') #---------- set up globals: parse command line and read the input file ---------- # "instructions" we understand in comments like this: # instructions = { 'split-heading-level': 0, 'section-blank-lines': 1, } parser = argparse.ArgumentParser( prog="update-md-links", description="update links in a markdown document", ) parser.add_argument('filename') parser.add_argument('--check', action='store_true', help='Check that everything is up to date; make no changes') args = parser.parse_args() md = list(open(args.filename, 'r')) #---------- types used in our data structures ---------- Anchor = str Target = str Source = str # One definition, of `t`, with human-readable source `source`, at md line `md_i` # # The link definition might be from maint/gen_md_links, in which case `md_i` is `None`. Def = collections.namedtuple('Def', ['t', 'source', 'md_i']) Defs = dict[Anchor, list[Def]]; Resolved = dict[Anchor, Target] # One section (if we have `split-heading-level` of other than 0). # # Comprises lines [`start`, `end`). # `defs_t2i` maps link target to a list of line numbers it's defined on # `a_needed` will be set to True when we find it actually contains any link Section = collections.namedtuple('Section', ['start', 'end', 'defs_t2i', 'a_needed']) #---------- utility functions ---------- troubles = 0 def trouble(m: str) -> None: ''' Record a "trouble" - a nonfatal problem. Prints the message `m` to stderr, and increments `troubles`, so we exit nonzero, later. ''' global troubles print("trouble: "+m, file=sys.stderr) troubles += 1 CheckableSubprocess = Union[subprocess.CompletedProcess[Any], subprocess.Popen] def check_returncode(process: CheckableSubprocess) -> None: ''' Check `process.returncode`; if it's not zero, print an error message and exit nonzero. ''' r = process.returncode if r != 0: print("subprocess failed with nonzero returncode %s" % r, file=sys.stderr) sys.exit(12) def is_link_def(l: str) -> Optional[Tuple[str, str]]: ''' Is the line `l` in the syntax of a link definition? If so returns `(a, t)` where `a` is the anchor and `t` the target. Otherwise returns None. ''' g = link_def_re.fullmatch(l) if g is None: return None # mypy can't see that our regexp has precisely these captures r: Tuple[str, str] = g.groups() # type: ignore return r #---------- search for instructions ---------- def process_instructions() -> None: ''' Looks for instructions and updates the global `instructions`. ''' for i, l in enumerate(md): source = '%s:%d' % (args.filename, i + 1) g = instruction_re.fullmatch(l) if not g: continue g = instruction_val_re.fullmatch(g.group(1)) if g: kv: Tuple[str, str] = g.groups() # type: ignore k, v = kv if k not in instructions: trouble('%s: unknown value instruction %s' % (source, k)) instructions[k] = int(v) continue trouble('%s: unknown instruction' % source) #---------- break input into sections ---------- def split_input() -> list[Section]: ''' Parse `md` into sections. ''' sections = [] section_start = 0 # namedtuple has defaults= but it gives every fresh tuple an aliased copy of the # same value! So we provide this constructor. def new_section(start: int, end: int) -> None: sections.append(Section(start, end, {}, {})) for i, l in enumerate(md): lno = i + 1 g = heading_re.match(l) if g and len(g.group(1)) == int(instructions['split-heading-level']): if i != section_start: new_section(section_start, i) section_start = i new_section(section_start, len(md)) return sections #---------- scan input sections' contents ---------- def scan_sections(sections: list[Section]) -> Defs: ''' Scans each section in `sections` ''' link_defs: Defs = {} def record_link_def(a: Anchor, t: Target, source: Source, md_i: Optional[int]) -> None: ''' Record that anchor `a` is defined to have target url `t`. `source` and `md_i` are as for `Def`. `t` may be the empty string (and for output from `gen_md_links`, often is). ''' link_defs.setdefault(a, []).append(Def(t, source, md_i)) for s in sections: #---------- for each section, find existing link def lines ---------- for i in range(s.start, s.end): lno = i + 1 l = md[i] at = is_link_def(l) if at: a, t = at record_link_def(a, t, "%s:%d" % (args.filename, lno), i) s.defs_t2i.setdefault(t, []).append(i) #---------- for each section, run gen_md_links ---------- text_file = tempfile.TemporaryFile(mode = 'w+', buffering=True) for i in range(s.start, s.end): print(md[i], file=text_file) text_file.flush() text_file.seek(0, 0) gen_links_output = subprocess.Popen( ['maint/gen_md_links', '--', '-'], stdin=text_file, stdout=subprocess.PIPE, encoding='utf-8', ) assert gen_links_output.stdout for l in gen_links_output.stdout: l = l.strip() if l == "": continue at = is_link_def(l) if at is None: print('gen_md_links produced bad output line %s (for %s:%d..%d)' % (repr(l), args.filename, s.start+1, s.end), file=sys.stderr) sys.exit(12) a, t = at record_link_def(a, t, "gen_md_links", None) s.a_needed[a] = True gen_links_output.wait() check_returncode(gen_links_output) return link_defs #---------- reconcile definitions ---------- def resolve_definitions(link_defs: Defs) -> Resolved: ''' Resolve link definitions. ''' link_def = {} for a, defs in link_defs.items(): candidates: dict[Target, list[Source]] = {} for d in defs: if d.t.strip() != "": candidates.setdefault(d.t, []).append(d.source) ts = list(candidates.keys()) if len(ts) > 1: trouble("conflicting definitions for [%s]" % a) done: dict[Target, bool] = {} for d in defs: t = d.t if done.get(t): continue done[t] = True print(" candidate %s" % t, file=sys.stderr) for d in defs: if d.t != t: continue print(" defined %s" % d.source, file=sys.stderr) if len(ts) == 0: ts.append("XX" + "XX") link_def[a] = ts[0] return link_def #---------- collate outputs ---------- def collate_insert_outputs(sections: list[Section], link_defs: Defs, link_def: Resolved) -> None: ''' Collate link definitions into each section Updates `md` in place. ''' for s in sections: linkcoll_start = s.end while True: if linkcoll_start <= s.start: break prev = linkcoll_start-1 prev_l = md[prev] if prev_l.strip() != "" and not is_link_def(prev_l): break linkcoll_start = prev if linkcoll_start <= s.start: continue # section contains only links, ignore it if not s.a_needed: continue # section contains no link anchors, ignore it # Now linkcoll_start is the start of the link collection for this section. # (Including blank lines either siude of the link collection.) new_collection = [] for a in s.a_needed: found = False for d in link_defs[a]: i = d.md_i if i is None: continue if i < s.start or i >= linkcoll_start: continue found = True break if not found: new_collection.append("[%s]: %s\n" % (a, link_def[a])) # delete old collection for i in range(linkcoll_start, s.end): md[i] = "" o = "" if len(new_collection) != 0: new_collection.sort() o += "\n" + "".join(new_collection) if s.end != len(md): for i in range(0, int(instructions['section-blank-lines'])): o += "\n" md[linkcoll_start-1] += o #---------- write output ---------- def write_output() -> None: ''' Writes the output file Writes to a `.tmp`, and then runs diff, or installs it, as appropriate. ''' new_filename = "%s.tmp" % args.filename output = open(new_filename, 'w', buffering=True) for l in md: print(l, file=output, end='') output.close() if troubles != 0: print('trouble, not installing %s' % new_filename, file=sys.stderr) sys.exit(12) if args.check: r = subprocess.run(['diff', '-u', '--', args.filename, new_filename]) if r.returncode == 1: print('%s links not up to date.' % args.filename, file=sys.stderr) sys.exit(1) check_returncode(r) os.remove(new_filename) else: if filecmp.cmp(args.filename, new_filename): print("%s unchanged" % args.filename) else: print("%s *updated*!" % args.filename) os.rename(new_filename, args.filename) #---------- main program ---------- process_instructions() sections = split_input() link_defs = scan_sections(sections) link_def = resolve_definitions(link_defs) collate_insert_outputs(sections, link_defs, link_def) write_output()