From fd7fa78720012e360814bea9472606106d45356e Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Mon, 14 Sep 2026 10:47:47 +0700 Subject: [PATCH] The two sweeps have no lane left to convert, and had stopped telling a real hit from a false one --- NEXT.md | 10 +- tools/colon-to-dot.py | 253 -------------------- tools/unit-return.py | 543 ------------------------------------------ 3 files changed, 7 insertions(+), 799 deletions(-) delete mode 100755 tools/colon-to-dot.py delete mode 100755 tools/unit-return.py diff --git a/NEXT.md b/NEXT.md index 9fe3bc6..2fdff00 100644 --- a/NEXT.md +++ b/NEXT.md @@ -1028,7 +1028,11 @@ Both lanes committed their main work and died on trailing polish; both are merge - ~~**`pause` marking from Emacs was not built.**~~ **Built**, both halves. See `docs/BUILT.md`, "A breakpoint is a function call, and the editor only says where". - ~~**Ghost text** for the watch window — values shown inline at the code they belong to — is noted and not designed.~~ **Built.** See `docs/BUILT.md`, "Ghost text finds its anchor in the buffer, not in the table". -- `tools/unit-return.py` is re-runnable; run it over any `.flan` file a lane wrote before the conversion landed. +- `tools/unit-return.py` was re-runnable over any `.flan` file a lane wrote before the conversion landed. + **Deleted 2026-09-14**, along with `tools/colon-to-dot.py`, once every branch that predated the conversion had + merged. Both had stopped being able to tell a real hit from a false one — `colon-to-dot --check` reported + twenty, all of them `{:where (copyable? $t)}`, a generics constraint naming no field, and running it would have + corrupted six test programs. A destructive script that looks maintained is a worse trap than a red test. # Where this is @@ -1765,7 +1769,7 @@ slot could not produce, and it needed its own arm. And dropping `prelude_types` `Macro.reduce` may only drop `defn`s: the memoised set that a bootstrap build could have poisoned no longer exists, so what is left is the plain one, that the surviving functions still mention those types. -**The sweep is `tools/unit-return.py`**, kept rather than thrown away, because the lanes that branched before this +**The sweep was `tools/unit-return.py`** (deleted 2026-09-14; see above), kept at the time rather than thrown away, because the lanes that branched before this wrote Flan in the old spelling and their files want the same pass at merge: ``` @@ -1809,7 +1813,7 @@ run one lane at a time; item 4 is disjoint and runs alongside any of them. rename. See docs/BUILT.md, "Locals of a stopped frame". 2. ~~**The colon-to-dot change.**~~ **Done**, and `Map` is unblocked: `{:key value}` is free. The sweep is - `tools/colon-to-dot.py`, kept rather than thrown away, because the lanes that branched before it wrote Flan in the + `tools/colon-to-dot.py` (deleted 2026-09-14; see above), kept at the time rather than thrown away, because the lanes that branched before it wrote Flan in the old spelling and their files want the same pass at merge — `python3 tools/colon-to-dot.py .` over the tree, and `--in-strings` for a `test/*.ml` that embeds Flan. diff --git a/tools/colon-to-dot.py b/tools/colon-to-dot.py deleted file mode 100755 index d680f0b..0000000 --- a/tools/colon-to-dot.py +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env python3 -"""Rewrite struct field labels from the colon spelling to the dot spelling. - -`{:x 1.0 :y 2.0}` becomes `{.x 1.0 .y 2.0}`, and the destructuring pair -`{inner :field}` becomes `{inner .field}`. `:keys` is left alone: it names no -field, it is an instruction to the compiler, so the dot keeps exactly one -meaning -- "this names a field". - -Only keywords that sit in a *field-label* position inside a brace form are -touched. Enum members, map keys and every keyword inside a string literal or a -comment are left as they are. The lexing rules here mirror lib/reader.ml. - -Re-runnable: converting an already-converted file is a no-op, so this can be -run again over files a parallel branch wrote in the old spelling. - - tools/colon-to-dot.py ... # rewrite .flan in place - tools/colon-to-dot.py --check ... # report, change nothing - tools/colon-to-dot.py --in-strings ... # Flan inside "..." literals - tools/colon-to-dot.py --raw-ml ... # Flan in a {flan|...|flan} block - -A directory is walked for `.flan` files only. An `.ml` file is converted when it -is named on the command line, which is how the Flan source embedded in -`lib/prelude.ml` and in the tests gets swept; it is deliberately not automatic, -because an OCaml record written `{ v: value }` would look like a field label to -the scan. Read the diff when sweeping `.ml`. -""" - -import sys, os - -DELIM = set('()[]{}";`~ \t\n\r,') -OPENERS = {'(': ')', '[': ']', '{': '}'} -CLOSERS = {')', ']', '}'} - - -class Atom: - def __init__(self, start, end, text): - self.start, self.end, self.text = start, end, text - self.tok = start # never moves; `start` may slide onto a quote sigil - - def label(self): - """The keyword name if this atom is a `:kw`, else None.""" - t = self.text - return t[1:] if len(t) > 1 and t[0] == ':' else None - - def converted(self): - """True if this atom is already a `.field` label.""" - t = self.text - return len(t) > 1 and t[0] == '.' and not t[1].isdigit() - - -class Seq: - def __init__(self, open_char, start): - self.open_char, self.start = open_char, start - self.end = start - self.items = [] - - def label(self): - return None - - def converted(self): - return False - - -def lex_forms(src, i, end, stop=None): - """Read forms from src[i:end] until `stop` (a closing char) or exhaustion. - - Returns (items, next_index). A quote/quasiquote/unquote prefix is folded - into the form it applies to, so a quoted value stays one element. - """ - items = [] - n = end - pending_prefix = None # start offset of a sigil awaiting its form - - def push(node): - nonlocal pending_prefix - if pending_prefix is not None: - node.start = pending_prefix - pending_prefix = None - items.append(node) - - while i < n: - c = src[i] - if c in ' \t\n\r,': - i += 1 - elif c == ';': # line comment - while i < n and src[i] != '\n': - i += 1 - elif c == '"': # string literal - j = i + 1 - while j < n and src[j] != '"': - j += 2 if src[j] == '\\' else 1 - j = min(j + 1, n) - push(Atom(i, j, src[i:j])) - i = j - elif c == '\\': # character literal - j = i + 1 - if j < n: - j += 1 # always one char - while j < n and src[j] not in DELIM: - j += 1 - push(Atom(i, j, src[i:j])) - i = j - elif c in "'`~": # quote sugar - if pending_prefix is None: - pending_prefix = i - i += 2 if (c == '~' and i + 1 < n and src[i + 1] == '@') else 1 - elif c in OPENERS: - node = Seq(c, i) - node.items, i = lex_forms(src, i + 1, n, OPENERS[c]) - node.end = i - push(node) - elif c in CLOSERS: - return items, i + 1 - else: # symbol or keyword - j = i - while j < n and src[j] not in DELIM: - j += 1 - if j == i: - j = i + 1 - push(Atom(i, j, src[i:j])) - i = j - return items, n - - -def collect(node, out): - """Walk the form tree, recording the offsets of every colon to rewrite.""" - if isinstance(node, Seq): - if node.open_char == '{': - items = node.items - k = 0 - while k < len(items): - a = items[k] - b = items[k + 1] if k + 1 < len(items) else None - a_label = a.label() - b_label = b.label() if b is not None else None - if a_label == 'keys': - pass # a directive, not a field - elif a_label is not None: - out.append(a.tok) # {:field value} - elif a.converted(): - pass - # Already `{.field value}`. Without this the pair would fall - # to the rule below and an enum member in value position -- - # `{.k :hi}` -- would be read as a destructuring label and - # converted on a second run. Re-running must be a no-op. - elif b_label is not None and b_label != 'keys': - out.append(b.tok) # {pattern :field} - k += 2 - for it in node.items: - collect(it, out) - - -def convert(src): - items, _ = lex_forms(src, 0, len(src)) - out = [] - for it in items: - collect(it, out) - if not out: - return src, 0 - chars = list(src) - for off in out: - assert chars[off] == ':', "expected ':' at offset %d" % off - chars[off] = '.' - return ''.join(chars), len(out) - - -def convert_in_ocaml_strings(src): - """Convert Flan source that sits inside ordinary OCaml `"..."` literals. - - The tests hold their Flan snippets that way, so the plain scan skips right - over them. Each literal's raw text is scanned on its own, with `\\"` masked - to a same-length filler first so an escaped quote cannot be mistaken for the - start of a Flan string. Masking preserves length, so offsets map back 1:1. - """ - chars = list(src) - total = 0 - i, n = 0, len(src) - while i < n: - c = src[i] - if c == '"': - j = i + 1 - while j < n and src[j] != '"': - j += 2 if src[j] == '\\' else 1 - body = src[i + 1:j] - masked = body.replace('\\"', '\\x') - _, offs = _offsets(masked) - for off in offs: - pos = i + 1 + off - if chars[pos] == ':': - chars[pos] = '.' - total += 1 - i = j + 1 - elif c == '(' and i + 1 < n and src[i + 1] == '*': # OCaml comment - i += 2 - else: - i += 1 - return ''.join(chars), total - - -def _offsets(src): - items, _ = lex_forms(src, 0, len(src)) - out = [] - for it in items: - collect(it, out) - return src, out - - -def walk(paths): - for p in paths: - if os.path.isdir(p): - for root, dirs, files in os.walk(p): - # vendor/ is not third-party: edn, raylib and agent are this - # repo's own packages, written in Flan, and they convert too. - dirs[:] = [d for d in dirs - if d not in ('_build', '.git', 'node_modules')] - for f in sorted(files): - if f.endswith('.flan'): - yield os.path.join(root, f) - else: - yield p - - -def main(argv): - check = '--check' in argv - in_strings = '--in-strings' in argv - allow_raw_ml = '--raw-ml' in argv - paths = [a for a in argv[1:] if not a.startswith('--')] or ['.'] - total_files = total_sites = 0 - for path in walk(paths): - if path.endswith('.ml') and not in_strings and not allow_raw_ml: - sys.stderr.write( - "%s: an .ml file needs --in-strings (its Flan is inside OCaml " - "string literals), or --raw-ml if the Flan is in a {flan|...|flan} " - "block. A plain scan would read OCaml's `::` as a field label.\n" - % path) - return 2 - with open(path, encoding='utf-8') as fh: - src = fh.read() - new, n = convert_in_ocaml_strings(src) if in_strings else convert(src) - if n: - total_files += 1 - total_sites += n - print("%s: %d" % (path, n)) - if not check: - with open(path, 'w', encoding='utf-8') as fh: - fh.write(new) - verb = "would convert" if check else "converted" - print("%s %d field labels across %d files" % (verb, total_sites, total_files)) - return 1 if (check and total_sites) else 0 - - -if __name__ == '__main__': - sys.exit(main(sys.argv)) diff --git a/tools/unit-return.py b/tools/unit-return.py deleted file mode 100755 index 2865c39..0000000 --- a/tools/unit-return.py +++ /dev/null @@ -1,543 +0,0 @@ -#!/usr/bin/env python3 -"""Give every `defn` an explicit return type, and rewrite `Unit` as `()`. - -`(defn f [x i32] body)` becomes `(defn f [x i32] () body)`, and a return type -already written as `Unit` -- or a `Unit` anywhere else a type is spelled, as in -`(Fn [i32] Unit)` -- becomes `()`. - -Two rules, one pass, because both are the same change: the slot after the -parameters is now unconditionally a type, so a function that returns nothing -has to say so, and the thing it says is `()`. - -Deciding whether a `defn` already has a return type is the whole difficulty, -and this script does it the way `lib/parse.ml` did before the slot became -mandatory: a form in that position is the return type when it is a *type form* -and it is not the entire body. `is_type_form` below is a transcription of the -one in parse.ml, deliberately faithful rather than improved -- being identical -to the parser it is replacing is what makes the sweep meaning-preserving. The -type names it needs come from the file's own declarations, from the prelude's -(read out of lib/prelude.ml), and from the builtin list. - -Re-runnable: a `defn` whose slot is already filled is left alone, and `()` is -itself a type form, so converting a converted file is a no-op. Parallel -branches that wrote Flan in the old spelling want this pass at merge. - - tools/unit-return.py ... # rewrite .flan in place - tools/unit-return.py --check ... # report, change nothing - tools/unit-return.py --in-strings ... # Flan inside "..." literals - tools/unit-return.py --raw-ml lib/prelude.ml # Flan in a {flan|...|flan} block - tools/unit-return.py --in-html web/index.html # Flan in
 blocks
-
-A directory is walked for `.flan` files only. Anything else is named on the
-command line with the mode that says how its Flan is embedded, because a blind
-scan of an OCaml or HTML file would read its punctuation as Flan.
-
-`-v` logs every `defn` seen and what was decided about it, which is the only
-practical way to review a sweep this size.
-
-**Read the diff of every non-`.flan` file.** A snippet split across OCaml
-string concatenation -- `decls ^ "(defn f [s [u8]] Cursor ...)"` -- is scanned
-one literal at a time, and the names the other literal declared would be
-invisible. The embedded modes work around it by pooling every fragment's type
-declarations across the whole file, and counting a pooled name only as a bare
-symbol, exactly as the prelude's types count: as a list head it would eat
-`(Some 1)` and `(Rune {.code 65})` as return types, which is the misparse this
-change exists to remove. That is sound because no user type takes arguments --
-only the builtin constructors do, and they are known already -- but it is a
-pool and not the real scope, so read the diff.
-
-And one thing it cannot know at all: a file that spells the *refused* forms on
-purpose, to test that they are refused. `test/test_flan.ml` holds six such
-sites -- `(defn f [] (g))`, `(defn f [] Unit (g))`, `(defn f [] Nope (bar))`,
-`(defn f [] f65 0.0)` -- and this script encodes the old rule, so it wants to
-convert every one of them and must not. A clean run over this repo reports
-exactly those six; anything else is a real conversion.
-"""
-
-import sys, os, re
-
-DELIM = set('()[]{}";`~ \t\n\r,')
-OPENERS = {'(': ')', '[': ']', '{': '}'}
-CLOSERS = {')', ']', '}'}
-
-# lib/parse.ml, [primitives] and [builtin_types]. Present in [types] under
-# their plain names, so they count as type forms in every position.
-BUILTINS = {
-    "i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64",
-    "f32", "f64", "bool", "string", "Unit", "Never",
-    "Ptr", "Option", "Result", "Vec", "Map", "Handle", "Fn",
-}
-
-NUMERIC = re.compile(r'^[-+]?[0-9]')
-
-
-class Atom:
-    def __init__(self, start, end, text):
-        self.start, self.end, self.text = start, end, text
-        self.tok = start        # never moves; `start` may slide onto a quote sigil
-        self.items = []
-
-    @property
-    def open_char(self):
-        return None
-
-    def sym(self):
-        """The symbol name, or None if this atom is not one.
-
-        A string, a keyword, a character literal and a number are all atoms and
-        none of them is a symbol -- parse.ml's [is_type_form] answers false for
-        every one of them, through its catch-all arm.
-        """
-        t = self.text
-        if not t or t[0] in '":\\' or NUMERIC.match(t):
-            return None
-        return t
-
-
-class Seq:
-    def __init__(self, open_char, start):
-        self.open_char, self.start = open_char, start
-        self.end = start
-        self.items = []
-        self.closed = False
-
-    def sym(self):
-        return None
-
-
-def lex_forms(src, i, end, stop=None):
-    """Read forms from src[i:end]. Returns (items, next_index, closed).
-
-    `closed` is False when the text ran out before the enclosing delimiter did,
-    which is how a fragment that holds only part of a form is recognised --
-    `"(defn step [] i64\\n"`, one line of a snippet built by concatenation.
-
-    The lexing rules mirror lib/reader.ml. A quote/quasiquote/unquote prefix is
-    folded into the form it applies to, so a quoted value stays one element.
-    """
-    items = []
-    n = end
-    pending_prefix = None
-
-    def push(node):
-        nonlocal pending_prefix
-        if pending_prefix is not None:
-            node.start = pending_prefix
-            pending_prefix = None
-        items.append(node)
-
-    while i < n:
-        c = src[i]
-        if c in ' \t\n\r,':
-            i += 1
-        elif c == ';':                                   # line comment
-            while i < n and src[i] != '\n':
-                i += 1
-        elif c == '"':                                   # string literal
-            j = i + 1
-            while j < n and src[j] != '"':
-                j += 2 if src[j] == '\\' else 1
-            j = min(j + 1, n)
-            push(Atom(i, j, src[i:j]))
-            i = j
-        elif c == '\\':                                  # character literal
-            j = i + 1
-            if j < n:
-                j += 1
-                while j < n and src[j] not in DELIM:
-                    j += 1
-            push(Atom(i, j, src[i:j]))
-            i = j
-        elif c in "'`~":                                 # quote sugar
-            if pending_prefix is None:
-                pending_prefix = i
-            i += 2 if (c == '~' and i + 1 < n and src[i + 1] == '@') else 1
-        elif c in OPENERS:
-            node = Seq(c, i)
-            node.items, i, node.closed = lex_forms(src, i + 1, n, OPENERS[c])
-            node.end = i
-            push(node)
-        elif c in CLOSERS:
-            return items, i + 1, True
-        else:                                            # symbol or keyword
-            j = i
-            while j < n and src[j] not in DELIM:
-                j += 1
-            if j == i:
-                j = i + 1
-            push(Atom(i, j, src[i:j]))
-            i = j
-    return items, n, False
-
-
-def head(node):
-    """The head symbol of a `(...)` form, or None."""
-    if getattr(node, 'open_char', None) != '(' or not node.items:
-        return None
-    return node.items[0].sym()
-
-
-# ── the type sets, as lib/parse.ml collects them ──────────────────────
-
-class Types:
-    """What `is_type_form` consults.
-
-    Three sets, kept apart exactly as parse.ml keeps them, because which
-    positions a name counts in depends on where it came from:
-
-    `names` -- builtins and the file's own defstruct/defunion/defalias. Count
-    as a bare symbol *and* as a list head, since `(Option f64)` is a type.
-
-    `enums`, `prelude` -- enum names and the prelude's types. Count only as a
-    bare symbol. As a list head they would eat `(Key 1)` and `(Rune {.code 65})`
-    -- a conversion and a constructor -- as return types, which is the silent
-    misparse this whole change exists to remove.
-
-    `aliases` -- import aliases, for `rl/Vector2`.
-    """
-    def __init__(self, prelude_names, prelude_enums):
-        self.names = set(BUILTINS)
-        self.enums = set(prelude_enums)
-        self.prelude = set(prelude_names)
-        self.aliases = set()
-
-    def scan(self, forms):
-        """Add what a program's top-level declarations introduce."""
-        for f in forms:
-            h = head(f)
-            if h in ('defstruct', 'defunion', 'defalias') and len(f.items) == 3:
-                n = f.items[1].sym()
-                if n:
-                    self.names.add(n)
-            elif h == 'defenum' and len(f.items) == 3:
-                n = f.items[1].sym()
-                if n:
-                    self.enums.add(n)
-            elif h == 'import' and len(f.items) == 3:
-                a = f.items[1].sym()
-                if a:
-                    self.aliases.add(a)
-
-    def copy(self):
-        t = Types(self.prelude, self.enums)
-        t.names = set(self.names)
-        t.aliases = set(self.aliases)
-        return t
-
-    def qualified(self, s):
-        i = s.find('/')
-        if i < 0:
-            return False
-        alias, name = s[:i], s[i + 1:]
-        return alias in self.aliases and name[:1].isupper()
-
-    def is_type_form(self, f):
-        oc = getattr(f, 'open_char', None)
-        if oc == '(' and not f.items:
-            return True                      # () is unit
-        if oc in ('[', '{'):
-            return True                      # [T], [n T] and {K V} are only types
-        if oc == '(':
-            h = head(f)
-            return bool(h) and (h in self.names or self.qualified(h))
-        s = f.sym()
-        if s is None:
-            return False
-        return s in self.names or s in self.enums or s in self.prelude \
-            or self.qualified(s)
-
-
-def prelude_type_names(root):
-    """The prelude's type names, from its {flan|...|flan} block.
-
-    parse.ml's [prelude_types] does the same walk over the same text; reading
-    the file keeps the two from drifting apart by hand.
-    """
-    path = os.path.join(root, 'lib', 'prelude.ml')
-    try:
-        with open(path, encoding='utf-8') as fh:
-            src = fh.read()
-    except OSError:
-        return set(), set()
-    body = flan_block(src)
-    if body is None:
-        return set(), set()
-    forms, _, _ = lex_forms(body, 0, len(body))
-    names, enums = set(), set()
-    for f in forms:
-        h = head(f)
-        if len(f.items) != 3:
-            continue
-        n = f.items[1].sym()
-        if not n:
-            continue
-        if h in ('defstruct', 'defunion', 'defalias'):
-            names.add(n)
-        elif h == 'defenum':
-            enums.add(n)
-    return names, enums
-
-
-def flan_block(src):
-    i = src.find('{flan|')
-    j = src.rfind('|flan}')
-    if i < 0 or j < i:
-        return None
-    return src[i + len('{flan|'):j]
-
-
-# ── the rewrite ───────────────────────────────────────────────────────
-
-def plan(src, types, label, log, base_line=1):
-    """Edits for one program's worth of Flan. Returns [(start, end, text)]."""
-    forms, _, _ = lex_forms(src, 0, len(src))
-    types = types.copy()
-    types.scan(forms)
-    edits = []
-
-    def line_of(off):
-        return base_line + src.count('\n', 0, off)
-
-    def walk(node, nested):
-        if getattr(node, 'open_char', None) is None:
-            # Only inside a form. Every type position is -- (Fn [i32] Unit),
-            # [Unit], the slot after a defn's parameters -- and a bare top-level
-            # `Unit` is not Flan at all. The guard is what keeps this pass off
-            # an OCaml literal that happens to spell the word, as the checker's
-            # own pattern `Tname "Unit"` does.
-            if nested and node.sym() == 'Unit':
-                edits.append((node.tok, node.end, '()'))
-            return
-        if head(node) == 'defn':
-            decide(node)
-        for it in node.items:
-            walk(it, True)
-
-    def decide(node):
-        items = node.items
-        # (defn name [params] ...). Anything else -- a metadata sigil, a
-        # malformed form -- is left alone and reported, because guessing at a
-        # shape the parser does not accept is how a sweep corrupts a file.
-        if not node.closed:
-            log.append("%s:%d: skipped, the form is cut off here -- a fragment "
-                       "of a snippet built by concatenation"
-                       % (label, line_of(node.start)))
-            return
-        if len(items) < 3 or getattr(items[2], 'open_char', None) != '[':
-            log.append("%s:%d: skipped, not (defn name [params] ...)"
-                       % (label, line_of(node.start)))
-            return
-        name = items[1].sym() or '?'
-        rest = items[3:]
-        # parse.ml's guard: a single remaining form is the body, not the
-        # return type -- [(defn f [] i32)] was a function returning Unit whose
-        # body is the name [i32]. The exception is a lone [()], which is what
-        # this script itself writes for a function with no body, and which was
-        # never a legal body form. Without it a second run would fill the slot
-        # again, and a re-runnable sweep is the point.
-        lone_unit = len(rest) == 1 and getattr(rest[0], 'open_char', None) == '(' \
-            and not rest[0].items
-        if rest and (len(rest) >= 2 or lone_unit) and types.is_type_form(rest[0]):
-            log.append("%s:%d: %s kept %s"
-                       % (label, line_of(node.start), name,
-                          src[rest[0].start:rest[0].end].replace('\n', ' ')))
-            return
-        edits.append((items[2].end, items[2].end, ' ()'))
-        log.append("%s:%d: %s filled ()" % (label, line_of(node.start), name))
-
-    for f in forms:
-        walk(f, False)
-    return edits
-
-
-def apply(src, edits):
-    if not edits:
-        return src, 0
-    out = []
-    last = 0
-    for start, end, text in sorted(edits):
-        out.append(src[last:start])
-        out.append(text)
-        last = end
-    out.append(src[last:])
-    return ''.join(out), len(edits)
-
-
-def mask_ocaml_escapes(body):
-    """Blank out OCaml escapes so the Flan lexer cannot trip on them.
-
-    Length-preserving, so offsets into the masked text index the original. A
-    `\\"` must not end a Flan string, and the `\\n\\` line continuations the
-    tests wrap their snippets with must not read as Flan character literals.
-
-    An escaped newline becomes a real one rather than a blank: a `;` comment
-    runs to end of line, so flattening `\\n` to spaces would let one comment
-    swallow the rest of the snippet.
-    """
-    chars = list(body)
-    i = 0
-    while i < len(chars) - 1:
-        if chars[i] == '\\':
-            c = chars[i + 1]
-            chars[i] = ' '
-            chars[i + 1] = c if c in '\n\t' else ('\n' if c == 'n' else ' ')
-            i += 2
-        else:
-            i += 1
-    return ''.join(chars)
-
-
-def pool(types, fragments):
-    """Fold every fragment's declarations into the bare-symbol-only sets.
-
-    One file's Flan is written in pieces -- concatenated OCaml literals, one
-    
 per section -- and a piece does not see the piece that declared its
-    types. Pooling gives it back. See the module docstring for why pooled names
-    count only in bare-symbol position.
-    """
-    for body in fragments:
-        forms, _, _ = lex_forms(body, 0, len(body))
-        seen = types.copy()
-        seen.names = set()
-        seen.enums = set()
-        seen.scan(forms)
-        types.prelude |= seen.names
-        types.enums |= seen.enums
-        types.aliases |= seen.aliases
-    return types
-
-
-def convert_flan(src, types, label, log):
-    return apply(src, plan(src, types, label, log))
-
-
-def ocaml_literals(src):
-    """(offset, masked body) for every OCaml string literal in `src`."""
-    out = []
-    i, n = 0, len(src)
-    while i < n:
-        c = src[i]
-        if c == '"':
-            j = i + 1
-            while j < n and src[j] != '"':
-                j += 2 if src[j] == '\\' else 1
-            out.append((i + 1, mask_ocaml_escapes(src[i + 1:j])))
-            i = j + 1
-        elif c == '(' and i + 1 < n and src[i + 1] == '*':   # OCaml comment
-            i += 2
-        else:
-            i += 1
-    return out
-
-
-def convert_in_strings(src, types, label, log):
-    """Flan inside ordinary OCaml `"..."` literals, as the tests write it."""
-    lits = ocaml_literals(src)
-    pool(types, [body for _, body in lits])
-    edits = []
-    for off, body in lits:
-        base = src.count('\n', 0, off) + 1
-        for s, e, t in plan(body, types, label, log, base):
-            edits.append((off + s, off + e, t))
-    return apply(src, edits)
-
-
-def convert_raw_ml(src, types, label, log):
-    """A whole {flan|...|flan} block, as lib/prelude.ml writes it."""
-    i = src.find('{flan|')
-    j = src.rfind('|flan}')
-    if i < 0 or j < i:
-        return src, 0
-    off = i + len('{flan|')
-    body = src[off:j]
-    base = src.count('\n', 0, off) + 1
-    edits = [(off + s, off + e, t) for s, e, t in plan(body, types, label, log, base)]
-    return apply(src, edits)
-
-
-CODE = re.compile(r'
(.*?)
', re.S) - - -def convert_in_html(src, types, label, log): - """Flan in
 blocks. HTML entities are left escaped: `<` lexes
-    as an ordinary atom and nothing this pass writes needs escaping."""
-    edits = []
-    pool(types, [m.group(1) for m in CODE.finditer(src)])
-    for m in CODE.finditer(src):
-        off = m.start(1)
-        base = src.count('\n', 0, off) + 1
-        for s, e, t in plan(m.group(1), types, label, log, base):
-            edits.append((off + s, off + e, t))
-    return apply(src, edits)
-
-
-MODES = {
-    '--in-strings': convert_in_strings,
-    '--raw-ml': convert_raw_ml,
-    '--in-html': convert_in_html,
-}
-
-
-def walk_paths(paths):
-    for p in paths:
-        if os.path.isdir(p):
-            for root, dirs, files in os.walk(p):
-                # vendor/ is not third-party: edn, raylib and agent are this
-                # repo's own packages, written in Flan, and they convert too.
-                dirs[:] = [d for d in dirs
-                           if d not in ('_build', '.git', 'node_modules')]
-                for f in sorted(files):
-                    if f.endswith('.flan'):
-                        yield os.path.join(root, f)
-        else:
-            yield p
-
-
-def main(argv):
-    check = '--check' in argv
-    verbose = '-v' in argv or '--verbose' in argv
-    modes = [m for m in MODES if m in argv]
-    if len(modes) > 1:
-        sys.stderr.write("pick one of %s\n" % ', '.join(MODES))
-        return 2
-    mode = modes[0] if modes else None
-    paths = [a for a in argv[1:] if not a.startswith('-')] or ['.']
-
-    root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-    pnames, penums = prelude_type_names(root)
-    if not pnames:
-        sys.stderr.write("warning: no prelude types found under %s -- a "
-                         "prelude type in return position may be mis-read\n" % root)
-
-    total_files = total_edits = 0
-    log = []
-    for path in walk_paths(paths):
-        if not path.endswith('.flan') and mode is None:
-            sys.stderr.write(
-                "%s: say how the Flan is embedded -- --in-strings for OCaml "
-                "string literals, --raw-ml for a {flan|...|flan} block, "
-                "--in-html for 
 blocks. A blind scan would read the "
-                "host language's punctuation as Flan.\n" % path)
-            return 2
-        with open(path, encoding='utf-8') as fh:
-            src = fh.read()
-        types = Types(pnames, penums)
-        fn = MODES[mode] if mode else convert_flan
-        new, n = fn(src, types, path, log)
-        if n:
-            total_files += 1
-            total_edits += n
-            print("%s: %d" % (path, n))
-            if not check:
-                with open(path, 'w', encoding='utf-8') as fh:
-                    fh.write(new)
-    if verbose:
-        for line in log:
-            print("  " + line)
-    verb = "would make" if check else "made"
-    print("%s %d edits across %d files" % (verb, total_edits, total_files))
-    return 1 if (check and total_edits) else 0
-
-
-if __name__ == '__main__':
-    sys.exit(main(sys.argv))