#!/usr/bin/env python3 """bend_core.py generate|check: the compiled Bend core (crates/eukhe-bend). Every `bend/ffi_.bend` is one entry of the core. Its id is the line ` ` of bend/core_entries.txt: ids are explicit, so adding an area never renumbers the others. An area file defines a call, a stepper or both: - call: `def ffi(input: String) -> String`; - stepper: its state `type St`, `def open(input: String) -> St & String` and `def step(s: St, input: String) -> St & String`. `generate` writes the three generated files: - bend/core.bend: imports every area (sorted), `def ffi(+entry: U32, input: String) -> String`, the state sum `type State` (one constructor per stepper area, named after it, plus `Unknown{}`), `def open(+entry: U32, input: String) -> State & String`, `def step(state: State, input: String) -> State & String`, `def close(state: State) -> Unit`, and a debugging CLI `main` (`bend bend/core.bend call `, `bend bend/core.bend open [...]`); - crates/eukhe-bend/src/entries.rs: `pub enum Entry` with the same ids; - crates/eukhe-bend/c/core.c: `bend bend/core.bend -o `. `check` regenerates them in a temporary directory and exits 1 when a committed file differs or when `bend version` is not the BEND_VERSION of scripts/toolchain.env.""" import difflib import re import shutil import subprocess import sys import tempfile from dataclasses import dataclass from pathlib import Path ROOT = Path(__file__).resolve().parent.parent BEND = ROOT / "bend" MANIFEST = BEND / "core_entries.txt" CORE_BEND = "bend/core.bend" ENTRIES_RS = "crates/eukhe-bend/src/entries.rs" CORE_C = "crates/eukhe-bend/c/core.c" TOOLCHAIN = ROOT / "scripts" / "toolchain.env" # The segments crates/eukhe-bend/c/shim.c enters. SEGMENTS = ("FID_ffi", "FID_open", "FID_step", "FID_close") AREA = re.compile(r"^ffi_([a-z][a-z0-9_]*)\.bend$") DEF_FFI = re.compile(r"^def ffi\(", re.M) DEF_OPEN = re.compile(r"^def open\(", re.M) DEF_STEP = re.compile(r"^def step\(", re.M) TYPE_ST = re.compile(r"^type St\b", re.M) UNKNOWN = 'W.err("unknown entry")' USAGE = '"usage: core call | core open [...]"' class Fail(Exception): """A generator error: printed, exit 1.""" @dataclass(frozen=True) class Area: id: int name: str call: bool stepper: bool @property def alias(self) -> str: return f"ffi_{self.name}" @property def camel(self) -> str: return "".join(p.capitalize() for p in self.name.split("_")) def manifest() -> dict[str, int]: """bend/core_entries.txt: ` ` lines; `#` comments.""" if not MANIFEST.exists(): raise Fail(f"{MANIFEST.relative_to(ROOT)} is missing") ids: dict[str, int] = {} seen: dict[int, str] = {} for n, line in enumerate(MANIFEST.read_text().splitlines(), 1): text = line.split("#", 1)[0].strip() if not text: continue parts = text.split() if len(parts) != 2 or not parts[0].isdigit() or not re.fullmatch(r"[a-z][a-z0-9_]*", parts[1]): raise Fail(f"core_entries.txt:{n}: expected ` `, got {line!r}") i, name = int(parts[0]), parts[1] if i > 0xFFFF_FFFF: raise Fail(f"core_entries.txt:{n}: id {i} is not a U32") if name in ids: raise Fail(f"core_entries.txt:{n}: area {name} has two ids") if i in seen: raise Fail(f"core_entries.txt:{n}: id {i} names {seen[i]} and {name}") ids[name] = i seen[i] = name return ids def areas() -> list[Area]: ids = manifest() files = {} for f in sorted(BEND.iterdir()): m = AREA.match(f.name) if m: files[m.group(1)] = f errors = [f"bend/ffi_{a}.bend has no id in core_entries.txt" for a in files if a not in ids] errors += [f"core_entries.txt: id {i} names {a}, but bend/ffi_{a}.bend does not exist" for a, i in ids.items() if a not in files] out = [] for name, f in files.items(): text = f.read_text() call = bool(DEF_FFI.search(text)) parts = [bool(DEF_OPEN.search(text)), bool(DEF_STEP.search(text)), bool(TYPE_ST.search(text))] if any(parts) and not all(parts): errors.append(f"{f.relative_to(ROOT)}: a stepper needs `type St`, `def open(` and `def step(`") if not call and not any(parts): errors.append(f"{f.relative_to(ROOT)}: defines neither `def ffi(` nor a stepper") if name in ids: out.append(Area(ids[name], name, call, all(parts))) if errors: raise Fail("\n".join(errors)) return sorted(out, key=lambda a: a.name) def core_bend(xs: list[Area]) -> str: by_id = sorted(xs, key=lambda a: a.id) calls = [a for a in by_id if a.call] steppers = [a for a in by_id if a.stepper] o = [ "# GENERATED by scripts/bend_core.py from bend/core_entries.txt and", "# bend/ffi_*.bend: do not edit (make bend-core).", "import Base", "import ./wire.bend as W", ] o += [f"import ./{a.alias}.bend as {a.alias}" for a in xs] o += [ "", "# The C shim (crates/eukhe-bend/c/shim.c) enters `ffi`, `open`, `step` and", "# `close` by their segments (FID_ffi, ...). The compiler inlines a small", "# fork-free def into its callers and gives it no segment, so each entry is", "# a parallel let of its body and `seg.pin` (scripts/bend_core.py checks", "# that the emitted C has the four segments).", "def seg.pin() -> Unit:", " Unit{}", "", "def ffi.go(+entry: U32, input: String) -> String:", " match entry:", ] for a in calls: o += [f" case {a.id}:", f" {a.alias}.ffi(input)"] o += [ " case _:", f" {UNKNOWN}", "", "# call: the reply of the entry's `ffi` to one input.", "def ffi(+entry: U32, input: String) -> String:", " r u = ffi.go(entry, input) seg.pin()", " r", "", "# A stepper's state, tagged with its entry; Unknown: an unknown entry.", "type State is Type:", ] o += [f" {a.camel}{{s: {a.alias}.St}}" for a in steppers] o += [" Unknown{}"] for a in steppers: o += [ "", f"def open.{a.name}(r: {a.alias}.St & String) -> State & String:", " match r:", " case (s, reply):", f" ({a.camel}{{s}}, reply)", ] o += ["", "def open.go(+entry: U32, input: String) -> State & String:", " match entry:"] for a in steppers: o += [f" case {a.id}:", f" open.{a.name}({a.alias}.open(input))"] o += [ " case _:", f" (Unknown{{}}, {UNKNOWN})", "", "# open: a new state of the entry's stepper and its reply.", "def open(+entry: U32, input: String) -> State & String:", " r u = open.go(entry, input) seg.pin()", " r", ] for a in steppers: o += [ "", f"def step.{a.name}(r: {a.alias}.St & String) -> State & String:", " match r:", " case (s, reply):", f" ({a.camel}{{s}}, reply)", ] o += ["", "def step.go(state: State, input: String) -> State & String:", " match state:"] for a in steppers: o += [f" case {a.camel}{{s}}:", f" step.{a.name}({a.alias}.step(s, input))"] o += [ " case Unknown{}:", f" (Unknown{{}}, {UNKNOWN})", "", "# step: the state after one input, and the reply.", "def step(state: State, input: String) -> State & String:", " r u = step.go(state, input) seg.pin()", " r", "", "def close.go(state: State) -> Unit:", " match state:", ] for a in steppers: o += [f" case {a.camel}{{s}}:", " Unit{}"] o += [ " case Unknown{}:", " Unit{}", "", "# close: drops a state.", "def close(state: State) -> Unit:", " r u = close.go(state) seg.pin()", " r", ] o += CLI return "\n".join(o) + "\n" # The debugging CLI: `core call ` prints the call's reply; # `core open [...]` prints the open's reply, then each # step's reply. It reaches every entry with run-time data, so each keeps its # segment. CLI = [ "", "# The debugging CLI (bend bend/core.bend call|open ...).", "def cli.done(u: Unit, reply: String) -> List:", " match u:", " case Unit{}:", " [reply]", "", "def cli.steps(events: List, r: State & String) -> List:", " match events r:", " case Nil{} Tuple{state, reply}:", " cli.done(close(state), reply)", " case Con{ev, rest} Tuple{state, reply}:", " reply <> cli.steps(rest, step(state, ev))", "", "def cli.call(events: List, +entry: U32, input: String) -> Maybe<&1, List>:", " match events:", " case Nil{}:", " Some{[ffi(entry, input)]}", " case Con{ev, rest}:", " None{}", "", "def cli.run(", " is_call: Bool, is_open: Bool, id: Maybe<&2, U32>, input: String, events: List", ") -> Maybe<&1, List>:", " match is_call is_open id:", " case True{} False{} Some{+n}:", " cli.call(events, n, input)", " case False{} True{} Some{+n}:", " Some{cli.steps(events, open(n, input))}", " case True{} True{} Some{+n}:", " None{}", " case False{} False{} Some{+n}:", " None{}", " case _ _ None{}:", " None{}", "", "def cli.args(args: List) -> Maybe<&1, List>:", " match args:", " case Con{prog, Con{+mode, Con{id, Con{input, events}}}}:", ' cli.run(String.eq(mode, "call"), String.eq(mode, "open"), U32.read(id), input, events)', " case Nil{}:", " None{}", " case Con{prog, Nil{}}:", " None{}", " case Con{prog, Con{mode, Nil{}}}:", " None{}", " case Con{prog, Con{mode, Con{id, Nil{}}}}:", " None{}", "", "def cli.print(lines: List) -> IO(Unit):", " match lines:", " case Nil{}:", " IO.pure(Unit, Unit{})", " case Con{line, rest}:", " do IO:", " IO.print(line)", " cli.print(rest)", "", "def cli.out(r: Maybe<&1, List>) -> IO(Unit):", " match r:", " case Some{lines}:", " cli.print(lines)", " case None{}:", f" IO.die(Unit, 2, {USAGE})", "", "def main() -> IO(Unit):", " do IO:", " args : List <- IO.args()", " cli.out(cli.args(args))", ] def entries_rs(xs: list[Area]) -> str: by_id = sorted(xs, key=lambda a: a.id) o = [ "// GENERATED by scripts/bend_core.py from bend/core_entries.txt and", "// bend/ffi_*.bend: do not edit (make bend-core).", "", "/// An entry of the compiled Bend core, one per `bend/ffi_.bend`; the", "/// discriminant is the id `bend/core.bend` dispatches on (`bend/core_entries.txt`).", "#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]", "#[repr(u32)]", "pub enum Entry {", ] for a in by_id: kinds = " and ".join(k for k, on in (("a call", a.call), ("a stepper", a.stepper)) if on) o += [f" /// `bend/ffi_{a.name}.bend`: {kinds}.", f" {a.camel} = {a.id},"] o += ["}", "", "impl Entry {", " /// Every entry, in id order."] items = [f"Entry::{a.camel}" for a in by_id] line = f" pub const ALL: &[Entry] = &[{', '.join(items)}];" if len(line) <= 100: o.append(line) else: o += [" pub const ALL: &[Entry] = &["] + [f" {i}," for i in items] + [" ];"] o += [ "", " /// The id `bend/core.bend` dispatches on.", " #[must_use]", " pub const fn id(self) -> u32 {", " self as u32", " }", "}", ] return "\n".join(o) + "\n" def bend_version() -> str: try: out = subprocess.run(["bend", "version"], capture_output=True, text=True, check=True).stdout except (OSError, subprocess.CalledProcessError) as e: raise Fail(f"bend version failed: {e}") from e return out.strip().removeprefix("bend ").strip() def pinned_version() -> str: for line in TOOLCHAIN.read_text().splitlines(): if line.startswith("BEND_VERSION="): return line.split("=", 1)[1].strip() raise Fail("scripts/toolchain.env has no BEND_VERSION") def build() -> dict[str, str]: """The three generated files, built in a copy of bend/.""" have, want = bend_version(), pinned_version() if have != want: raise Fail(f"bend {have} is installed; scripts/toolchain.env pins {want}") xs = areas() out = {CORE_BEND: core_bend(xs), ENTRIES_RS: entries_rs(xs)} with tempfile.TemporaryDirectory(prefix="bend-core-") as tmp: work = Path(tmp) / "bend" shutil.copytree(BEND, work, ignore=shutil.ignore_patterns("core.bend")) (work / "core.bend").write_text(out[CORE_BEND]) c = Path(tmp) / "core.c" r = subprocess.run(["bend", "core.bend", "-o", str(c)], cwd=work, capture_output=True, text=True) if r.returncode != 0 or not c.exists(): raise Fail(f"bend bend/core.bend -o core.c failed:\n{r.stdout}{r.stderr}") out[CORE_C] = c.read_text() missing = [f for f in SEGMENTS if not re.search(rf"^#define {f} \d+$", out[CORE_C], re.M)] if missing: raise Fail(f"the emitted C has no {', '.join(missing)}: the shim cannot enter the core") return out def generate() -> int: for rel, text in build().items(): path = ROOT / rel path.parent.mkdir(parents=True, exist_ok=True) if not path.exists() or path.read_text() != text: path.write_text(text) print(f"wrote {rel}") return 0 def check() -> int: stale = [] for rel, text in build().items(): path = ROOT / rel old = path.read_text() if path.exists() else "" if old == text: continue diff = list(difflib.unified_diff( old.splitlines(), text.splitlines(), f"{rel} (committed)", f"{rel} (generated)", lineterm="", n=1)) stale.append(rel) changed = sum(1 for d in diff[2:] if d[:1] in "+-") print(f"{rel}: differs from the generator ({changed} changed lines)") for d in diff[:40]: print(f" {d}") if len(diff) > 40: print(f" ... {len(diff) - 40} more diff lines") if stale: print("run `make bend-core` and commit the result") return 1 print("bend-core: the generated files match") return 0 def main(argv: list[str]) -> int: if len(argv) != 2 or argv[1] not in ("generate", "check"): print(__doc__, file=sys.stderr) return 2 try: return generate() if argv[1] == "generate" else check() except Fail as e: print(f"bend_core.py: {e}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main(sys.argv))