From c8e76af8b7cce594632d9ed575cf524a0149d7a5 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Sun, 19 Jul 2026 15:06:33 +0200 Subject: [PATCH 1/4] backend: emit Erlang Abstract Format and compile in-process via compile:forms/2 Replace the textual Core Erlang round trip (core_printer print -> core_scan/core_parse re-parse -> undocumented from_core entry) with a direct lowering of the backend CModule AST to Erlang Abstract Format terms, compiled in-process by compile:forms/2. - backend/eaf.gleam: the new CModule -> abstract-forms translator. EAF nodes are plain tuples, so they are built as Gleam tuple literals coerced through an identity FFI. The two Core-isms Erlang lacks are lowered here: every binder is alpha-renamed to a unique @ name (Core shadowing -> Erlang single-assignment), and letrec lowers to a named fun (F = fun F(...) -> ... end; a multi-def group lowers to one tag-dispatching named fun). Value-list lets become match sequences; primop 'build_stacktrace' vanishes (catch C:R:S already binds the built stacktrace); each top-level function is annotated with a distinct synthetic line. - twocore_codegen_ffi.erl: compile_core (scan/parse/from_core) is replaced by compile_forms (compile:forms/2 + the same flat ': ' diagnostic normalization), plus forms_to_erl (erl_pp dump) and the id/1 coercion helper. The indent-stripping workaround for the 85 MB .core text transient is gone with the text. - build_beam.gleam: the stage API now takes the CModule (compile_module / compile_and_load / link_beam) or raw forms (compile_forms); the BuildError contract is unchanged. - core_erlang/core_printer docs: the printer is demoted to an inspection-only surface (to-core/emit dumps); its output is never re-parsed. --- src/twocore/backend/build_beam.gleam | 145 +++-- src/twocore/backend/core_erlang.gleam | 14 +- src/twocore/backend/core_printer.gleam | 14 +- src/twocore/backend/eaf.gleam | 761 +++++++++++++++++++++++++ src/twocore_codegen_ffi.erl | 127 ++--- 5 files changed, 928 insertions(+), 133 deletions(-) create mode 100644 src/twocore/backend/eaf.gleam diff --git a/src/twocore/backend/build_beam.gleam b/src/twocore/backend/build_beam.gleam index 8d80baf5..0e1f4f1e 100644 --- a/src/twocore/backend/build_beam.gleam +++ b/src/twocore/backend/build_beam.gleam @@ -1,34 +1,39 @@ //// The `build_beam` driver (Unit 04) — the backend's last seam. //// //// Wraps the hand-written Erlang shim `twocore_codegen_ffi` (the «FFI-SHIM») -//// behind one stable Gleam `Result` contract. It takes Core Erlang source -//// *text*, compiles it to an in-memory `.beam` binary, and loads that binary +//// behind one stable Gleam `Result` contract. It takes the backend's +//// Core-shaped AST (`CModule`), lowers it to Erlang Abstract Format forms +//// (`twocore/backend/eaf`), compiles them **in-process** with +//// `compile:forms/2` to an in-memory `.beam` binary, and loads that binary //// into the CURRENT VM (decision D10), so generated modules can be `apply`-ed //// and proven to be real, preemptible BEAM code (high-level §9.2). //// -//// The in-memory path is the default. A file fallback (`compile:file`) is -//// possible behind the *same* `Result` contract should a future OTP quirk -//// demand it, but is intentionally not the default — see the unit doc. -//// -//// PINNED TO OTP 29: the underlying shim uses the compiler-internal -//// `core_scan`/`core_parse` modules and the undocumented textual `from_core` -//// format. See `src/twocore_codegen_ffi.erl`. +//// There is deliberately NO text in this path any more: the old backend +//// printed `.core` text and re-parsed it with the compiler-internal +//// `core_scan`/`core_parse` modules plus the undocumented textual `from_core` +//// entry. Abstract forms are the documented `erts/absform` contract, so the +//// build is no longer pinned to compiler internals (only `compile:forms/2` +//// itself, a stable public API). `core_printer` remains solely as the human +//// inspection surface (`to-core` dumps); it is never re-parsed. import gleam/erlang/atom.{type Atom} import gleam/result import twocore/backend/beam_link +import twocore/backend/core_erlang.{type CModule} +import twocore/backend/eaf import twocore/backend/link_manifest /// This stage's own error type (D4 — there is no shared `StageError`; each /// stage owns its errors and the top-level driver composes them). pub type BuildError { - /// Core Erlang scan, parse, or compile reported one or more diagnostics. + /// The AST → abstract-forms lowering or `compile:forms/2` reported one or + /// more diagnostics. /// /// Each string is a normalized `": "` line where `` is a /// line number or the literal `"module"` (for module-level errors with no - /// line). Messages are rendered via the failing module's `format_error/1` — - /// never a raw Erlang term. The list is always non-empty when this variant - /// is produced. + /// line); an `eaf` lowering error is rendered via `eaf.describe`. Messages + /// are never a raw Erlang term. The list is always non-empty when this + /// variant is produced. CompileFailed(errors: List(String)) /// `code:load_binary/3` rejected the binary (e.g. `"sticky_directory"`, /// `"badfile"`, `"not_purged"`). `reason` is the VM's error atom rendered as @@ -40,8 +45,10 @@ pub type BuildError { /// Returns the shim's `{ok,{Mod,Beam}} | {error,[Bin]}` mapped onto a Gleam /// `Result(#(Atom, BitArray), List(String))`. Gleam does NOT type-check this /// boundary — shapes are validated in the tests (trust boundary). -@external(erlang, "twocore_codegen_ffi", "compile_core") -fn ffi_compile_core(core: BitArray) -> Result(#(Atom, BitArray), List(String)) +@external(erlang, "twocore_codegen_ffi", "compile_forms") +fn ffi_compile_forms( + forms: List(eaf.Form), +) -> Result(#(Atom, BitArray), List(String)) /// FFI into the «FFI-SHIM». Wraps `code:load_binary/3`. Returns the shim's /// `{ok,Mod} | {error,Bin}` mapped onto `Result(Atom, String)`. @@ -52,35 +59,70 @@ fn ffi_load_module( beam: BitArray, ) -> Result(Atom, String) -/// Compile Core Erlang source TEXT to an in-memory `.beam` binary. -/// -/// - `core_text`: UTF-8 `.core` source as a byte-aligned `BitArray` (it is read -/// on the Erlang side as a binary; do not pass a non-byte-aligned bitstring). +/// FFI into the «FFI-SHIM»: pretty-print abstract forms as Erlang source text +/// (`erl_pp`) — the `to-erl` inspection dump. +@external(erlang, "twocore_codegen_ffi", "forms_to_erl") +fn ffi_forms_to_erl(forms: List(eaf.Form)) -> String + +/// Compile already-lowered abstract FORMS to an in-memory `.beam` binary — +/// the forms-level seam (`compile_module` is this plus the AST lowering). /// -/// Returns `Ok(#(module_name, beam_binary))` on success. The `module_name` atom -/// is taken from the `.core` `module` header, NOT from any filename. Returns -/// `Error(CompileFailed(lines))` if `core_scan`/`core_parse`/`compile` reports -/// any diagnostic, where `lines` is a non-empty list of human-readable -/// `": "` strings. +/// - `forms`: the abstract-format form list (from `eaf.module_forms`). /// -/// Failure modes: never panics on malformed (syntactically or semantically -/// broken) input — broken input yields `Error(CompileFailed(_))`. This -/// fail-closed behavior is a tested property (D8). The only inputs that could -/// still crash the shim guard are non-byte-aligned bitstrings, which the -/// `BitArray` contract for text excludes. -pub fn compile_core( - core_text: BitArray, +/// Returns `Ok(#(module_name, beam_binary))` on success; the `module_name` +/// atom is taken from the `-module` attribute. Returns +/// `Error(CompileFailed(lines))` on any compiler diagnostic. Never panics on a +/// malformed form list — the shim catches a compiler crash and renders it as a +/// diagnostic (fail-closed, D8). +pub fn compile_forms( + forms: List(eaf.Form), ) -> Result(#(Atom, BitArray), BuildError) { - ffi_compile_core(core_text) + ffi_compile_forms(forms) |> result.map_error(CompileFailed) } +/// Compile a backend `CModule` to an in-memory `.beam` binary: lower it to +/// abstract forms (`eaf.module_forms`) and run them through `compile:forms/2` +/// in-process. +/// +/// - `cmod`: the Core-shaped module `emit_core` produced. +/// +/// Returns `Ok(#(module_name, beam_binary))` on success (the atom comes from +/// `cmod.name`). Returns `Error(CompileFailed(lines))` if the lowering or the +/// compiler rejects the module, where `lines` is a non-empty list of +/// human-readable strings. +/// +/// Failure modes: never panics on a malformed module — a broken AST yields +/// `Error(CompileFailed(_))` (fail-closed, D8), whether the `eaf` lowering or +/// `erl_lint` inside `compile:forms` catches it. +pub fn compile_module(cmod: CModule) -> Result(#(Atom, BitArray), BuildError) { + case eaf.module_forms(cmod) { + Error(e) -> Error(CompileFailed([eaf.describe(e)])) + Ok(forms) -> compile_forms(forms) + } +} + +/// Pretty-print a backend `CModule` as Erlang SOURCE text — the `.erl`-flavour +/// inspection dump (`to-erl`): the module is lowered to the SAME abstract +/// forms `compile_module` compiles and rendered with `erl_pp`, so the dump is +/// exactly what the compiler consumes (unlike the `.core` pretty-print, which +/// is a display-only rendering of the pre-lowering AST). +/// +/// Returns `Ok(text)` or `Error(CompileFailed([line]))` if the AST cannot be +/// lowered. Total — never panics. +pub fn module_to_erl(cmod: CModule) -> Result(String, BuildError) { + case eaf.module_forms(cmod) { + Error(e) -> Error(CompileFailed([eaf.describe(e)])) + Ok(forms) -> Ok(ffi_forms_to_erl(forms)) + } +} + /// Load a `.beam` binary into the CURRENT VM (D10) and return its module name. /// /// - `module`: the module atom; MUST match the name baked into `beam`. /// - `filename`: metadata only (surfaced by `code:which`); it does not affect /// the loaded module's identity. -/// - `beam`: the compiled `.beam` binary (as returned by `compile_core`). +/// - `beam`: the compiled `.beam` binary (as returned by `compile_module`). /// /// Returns `Ok(module)` once the module is resident, or `Error(reason)` where /// `reason` is the VM's rejection atom rendered as text. This is a deliberately @@ -98,39 +140,40 @@ pub fn load_module( ffi_load_module(module, filename, beam) } -/// Merge a generated module's Core Erlang with its whole runtime dependency closure into ONE -/// self-contained `.beam` — the `--link` path (the linked analog of `compile_core/1`). +/// Merge a generated module with its whole runtime dependency closure into ONE +/// self-contained `.beam` — the `--link` path (the linked analog of `compile_module`). /// /// This is the single composition point that binds "which OTP modules stay remote" (the frozen /// manifest's `ambient_allowlist/0`, R7) to a whole-program link — the CLI never re-spells the /// allowlist. It is a one-way dependency onto `beam_link` (P11-03) + `link_manifest` (P11-02); /// neither imports `build_beam`, so there is no cycle. /// -/// - `generated_core`: the emitted `.core` TEXT for the generated module (UTF-8, byte-aligned), -/// exactly as `pipeline.ir_to_core` produced it — its `module` header names the merged module. -/// - `module_name`: the generated module's name (`ir.Module.name`); passed through so the linker -/// can locate the generated module within the acquired closure and name the merged output. It -/// MUST equal the `.core` `module` header (a mismatch is `Error(MalformedCore(_))`). +/// - `cmod`: the emitted Core-shaped module, exactly as `pipeline.ir_to_cmod` produced it — +/// its `name` names the merged module. It is lowered to abstract forms here and handed to the +/// linker, which recovers the cerl module via the compiler's own `to_core` pass in-process. /// - Returns `Ok(#(merged_module_atom, merged_beam))` — one self-contained binary that loads on a /// bare OTP node, whose declared module atom equals `merged_module_atom` — or the linker's typed /// `beam_link.LinkError` (off-allowlist remote, unmergeable construct, Core-acquisition failure, /// D3a authority, …). Fail-closed: never emits a partial or authority-bearing artifact, and /// never panics on malformed input (it becomes a typed `Error`). pub fn link_beam( - generated_core: BitArray, - module_name: String, + cmod: CModule, ) -> Result(#(Atom, BitArray), beam_link.LinkError) { - beam_link.link_program( - generated_core, - module_name, - link_manifest.ambient_allowlist(), - ) + case eaf.module_forms(cmod) { + Error(e) -> Error(beam_link.MalformedCore(eaf.describe(e))) + Ok(forms) -> + beam_link.link_program( + forms, + cmod.name, + link_manifest.ambient_allowlist(), + ) + } } -/// Convenience: compile `core_text` then load the resulting binary, folding a +/// Convenience: compile `cmod` then load the resulting binary, folding a /// load failure into `BuildError` so the whole stage speaks one error type (D4). /// -/// - `core_text`: UTF-8 `.core` source (see `compile_core`). +/// - `cmod`: the Core-shaped module (see `compile_module`). /// /// Returns `Ok(module)` — a resident module ready to `apply` — or the first /// failing stage's error: `Error(CompileFailed(_))` if compilation fails, or @@ -138,8 +181,8 @@ pub fn link_beam( /// /// The load `filename` is fixed to `"twocore_generated"` (metadata only); use /// `load_module` directly if a specific `code:which` filename is needed. -pub fn compile_and_load(core_text: BitArray) -> Result(Atom, BuildError) { - use #(mod, beam) <- result.try(compile_core(core_text)) +pub fn compile_and_load(cmod: CModule) -> Result(Atom, BuildError) { + use #(mod, beam) <- result.try(compile_module(cmod)) load_module(mod, "twocore_generated", beam) |> result.map_error(LoadFailed) } diff --git a/src/twocore/backend/core_erlang.gleam b/src/twocore/backend/core_erlang.gleam index 52a4a219..4be2d1c2 100644 --- a/src/twocore/backend/core_erlang.gleam +++ b/src/twocore/backend/core_erlang.gleam @@ -2,12 +2,14 @@ //// representation). //// //// Per the high-level spec §5 we model the subset of Core Erlang we emit as -//// ordinary Gleam custom types and then *print* them to `.core` text (see -//// `twocore/backend/core_printer`), rather than driving the Erlang `cerl` -//// record API over FFI (awkward over FFI, loses Gleam's type safety). These -//// types are the `«CORE-AST»` milestone: unit 08 (`emit_core`) builds them from -//// the IR (the binding chokepoint, D3b) and the printer turns them into text -//// that the OTP-29 compiler accepts via `compile:from_core`. +//// ordinary Gleam custom types, rather than driving the Erlang `cerl` record +//// API over FFI (awkward over FFI, loses Gleam's type safety). These types are +//// the `«CORE-AST»` milestone: unit 08 (`emit_core`) builds them from the IR +//// (the binding chokepoint, D3b). The COMPILE path lowers them to Erlang +//// Abstract Format terms (`twocore/backend/eaf`) consumed in-process by +//// `compile:forms/2`; `twocore/backend/core_printer` renders the same AST as +//// `.core` text purely for INSPECTION (the CLI's `to-core`/`emit` dumps and +//// text-shape tests) — the printed text is never re-parsed. //// //// Phase 1 deliberately OMITS Core Erlang annotations (`-| [...]`) entirely — a //// zero-annotation module compiles fine on OTP 29 — and omits the diff --git a/src/twocore/backend/core_printer.gleam b/src/twocore/backend/core_printer.gleam index 0a6deba6..59b5934e 100644 --- a/src/twocore/backend/core_printer.gleam +++ b/src/twocore/backend/core_printer.gleam @@ -1,10 +1,12 @@ -//// The Core Erlang pretty-printer (Unit 03). +//// The Core Erlang pretty-printer (Unit 03) — now an INSPECTION-ONLY surface. //// -//// Turns a `twocore/backend/core_erlang` AST into `.core` source text that the -//// OTP-29 compiler accepts via `compile:from_core` (the path -//// `core_scan` → `core_parse` → `core_lint` → `compile`). It is the last -//// Gleam-side seam before `twocore/backend/build_beam` hands text to the Erlang -//// compiler. +//// Turns a `twocore/backend/core_erlang` AST into `.core` source text for the +//// CLI's `to-core`/`emit` dumps and the text-shape tests. The COMPILE path no +//// longer goes through this text: `twocore/backend/eaf` lowers the same AST to +//// Erlang Abstract Format terms and `build_beam` compiles them in-process with +//// `compile:forms/2`. The printed text is never re-parsed (the historical +//// `core_scan` → `core_parse` → `from_core` round trip is gone); the lexical +//// rules below are kept verbatim so the dump remains faithful `.core`. //// //// The printer is small and has no algorithmic cleverness — it is a recursive //// walk emitting a `gleam/string_tree`. Its hard part is purely *lexical*, and diff --git a/src/twocore/backend/eaf.gleam b/src/twocore/backend/eaf.gleam new file mode 100644 index 00000000..3cb1d5d4 --- /dev/null +++ b/src/twocore/backend/eaf.gleam @@ -0,0 +1,761 @@ +//// Erlang Abstract Format (EAF) backend — the Core-AST → abstract-forms +//// translator that replaced the textual `.core` print → `core_scan`/`core_parse` +//// re-parse round trip. +//// +//// `emit_core` still produces the Gleam-native Core-shaped AST (`CModule`, the +//// binding chokepoint), but instead of printing it to `.core` TEXT and driving +//// the compiler-internal `core_scan`/`core_parse` modules plus the undocumented +//// textual `from_core` entry, this module lowers the AST directly to the +//// **documented** Erlang Abstract Format (`erts/absform`) as in-memory Erlang +//// terms, which `compile:forms/2` consumes natively (see +//// `twocore_codegen_ffi:compile_forms/1`). From Gleam an EAF node is just a +//// plain tuple (`{integer, Anno, 42}`), so the forms are built with ordinary +//// tuple literals coerced into the opaque `Form` type — no cerl records, no +//// text, no re-parse. +//// +//// The two Core-Erlang-isms Erlang source semantics does not have are lowered +//// here: +//// +//// 1. **Scoping/shadowing.** Core Erlang binders shadow freely and `emit_core` +//// reuses raw names (`%p0`, `$loop0`) across sibling scopes, while Erlang +//// variables are function-scoped, single-assignment, and a bound variable in +//// a pattern means an equality CHECK, not a fresh binding. Every binder is +//// therefore alpha-renamed to a globally-fresh name +//// (`@`, the Elixir-style counter-suffix scheme — +//// `@` is legal in an Erlang variable), so every EAF pattern variable is +//// provably unbound at its binding site and Core's shadowing semantics are +//// preserved exactly. +//// 2. **`letrec`.** Erlang has no expression-level `letrec`; a single-def +//// `letrec 'f'/n = fun … in Body` (the shape `emit_core` produces for loops, +//// join points, and try bodies) lowers to a **named fun** +//// `F = fun F(…) -> … end` — self-recursion via the fun's own name variable +//// (a proper tail call on the BEAM), outer references via the bound `F` +//// (both are the same fun value, so one name serves both). A multi-def +//// (mutually recursive) `letrec` — not currently emitted, but handled for +//// totality — lowers to ONE dispatching named fun +//// `R = fun R(Tag, Args) -> …` whose clauses match `(tag_i, [arg…])`, with +//// every intra-group apply rewritten to `R(tag_j, [args…])` (still a tail +//// call). +//// +//// Value lists (`<…>` / multi-binder `let`) only ever appear as the RHS of a +//// same-arity multi-binder `let` (see `emit_core.value_list`), so they lower to +//// a sequence of matches; `primop 'build_stacktrace'` disappears entirely +//// because an Erlang `catch C:R:S` binds the ALREADY-BUILT stacktrace. +//// +//// Annotations: the IR carries no source positions (WASM offsets are not +//// tracked), so annos are synthetic — every form in the n-th top-level function +//// is annotated with line `n+1`, which makes BEAM stacktrace lines identify the +//// generated function even without names. + +import gleam/dict.{type Dict} +import gleam/int +import gleam/list +import gleam/result +import twocore/backend/core_erlang.{ + type CBitSeg, type CClause, type CExpr, type CModule, type CPat, type FunDef, + CApply, CApplyExpr, CAtom, CBinary, CBitSeg, CCall, CCase, CClause, CCons, + CFloat, CFun, CInt, CLet, CLetrec, CNil, CPrimop, CTry, CTuple, CValues, CVar, + FName, FunDef, PAtom, PCons, PInt, PNil, PTuple, PVar, +} +import twocore/backend/core_printer + +/// An opaque Erlang Abstract Format term — a form, expression, pattern, or +/// annotation node exactly as `compile:forms/2` / `erl_lint` consume it +/// (documented in `erts/absform`). Constructed only by this module (plain +/// tuples coerced via the identity FFI); never inspected from Gleam. +pub type Form + +/// A Gleam-side atom handle used inside EAF nodes (node tags, atom literals, +/// variable names). Opaque here; created with `atom_of`. +type EAtom + +/// Identity coercion: any Gleam value → an opaque `Form`. A Gleam tuple IS an +/// Erlang tuple and a Gleam `List` IS an Erlang list, so building an EAF node +/// is building the corresponding Gleam literal and forgetting its type. +@external(erlang, "twocore_codegen_ffi", "id") +fn raw(term: a) -> Form + +/// `erlang:binary_to_atom/2 (utf8)` — the atom for a node tag / atom literal / +/// variable name. Same total contract as `gleam/erlang/atom.create`, typed to +/// this module's private `EAtom` so raw atoms never leak. +@external(erlang, "erlang", "binary_to_atom") +fn atom_of(name: String) -> EAtom + +/// Why the Core-shaped AST could not be lowered to abstract forms. Every +/// variant is an `emit_core` INVARIANT violation (the shapes `emit_core` +/// guarantees never to produce), surfaced as a typed error rather than a panic +/// so the pipeline stays total (D8 — fail closed, never crash). +pub type EafError { + /// A top-level (or `letrec`) definition whose RHS is not a `CFun` literal — + /// Core requires a `fun` on a def RHS, so this is unreachable from + /// `emit_core`. `name` is the offending def's function name. + NonFunDef(name: String) + /// A `primop` other than `'build_stacktrace'` (the only one emitted). + UnsupportedPrimop(name: String) + /// A multi-binder `let` whose RHS is not a same-arity literal value list — + /// `emit_core.value_list` is the single producer, so this is unreachable. + /// `binders`/`values` are the mismatched arities (`values` is `-1` for a + /// non-value-list RHS). + BadValueBinding(binders: Int, values: Int) + /// A bare value list (`CValues`) in single-value expression position. + BareValueList(arity: Int) + /// A `case` clause with pattern arity ≠ 1 (multi-value scrutinees are never + /// emitted). + BadClauseArity(arity: Int) + /// A `try` whose binder shape is not 1 body var + 3 exception vars (the only + /// shape `emit_try` produces). + BadTryShape(body_vars: Int, evars: Int) + /// A module attribute whose value is not a literal term. `key` names it. + UnsupportedAttribute(key: String) +} + +/// A short human-readable rendering of an `EafError` for CLI/diagnostic text. +/// Total. Programmatic callers should match the variant, not parse this. +pub fn describe(error: EafError) -> String { + case error { + NonFunDef(name) -> "definition of '" <> name <> "' is not a fun literal" + UnsupportedPrimop(name) -> "unsupported primop '" <> name <> "'" + BadValueBinding(b, v) -> + "let binds " + <> int.to_string(b) + <> " names to " + <> int.to_string(v) + <> " values" + BareValueList(n) -> + "value list of arity " <> int.to_string(n) <> " in single-value position" + BadClauseArity(n) -> "case clause with pattern arity " <> int.to_string(n) + BadTryShape(bv, ev) -> + "try with " + <> int.to_string(bv) + <> " body vars / " + <> int.to_string(ev) + <> " exception vars" + UnsupportedAttribute(key) -> + "attribute '" <> key <> "' has a non-literal value" + } +} + +// ─────────────────────────────── translation state ─────────────────────────────── + +/// How an in-scope `letrec`-bound function is called at an `apply` site: +/// `DirectFun` — the common single-def lowering — calls the named-fun variable +/// directly (`F(Args…)`); `DispatchFun` — the multi-def lowering — calls the +/// shared dispatcher with the def's tag and a packed argument list +/// (`R(tag, [Args…])`). +type LocalFun { + DirectFun(var: String) + DispatchFun(var: String, tag: String) +} + +/// The per-function translation environment: `ln` is the function's synthetic +/// anno line (constant for the whole body); `vars` maps a raw Core variable +/// name to its unique renamed Erlang variable; `funs` maps an in-scope +/// `letrec`-bound `'name'/arity` to its lowering. An `FName` absent from +/// `funs` is a top-level module function (a plain local call). +type Env { + Env(ln: Int, vars: Dict(String, String), funs: Dict(#(String, Int), LocalFun)) +} + +/// The fresh-name counter, threaded through the whole translation of one +/// top-level function (each function restarts at 0 — Erlang variables are +/// function-scoped, so cross-function uniqueness is not needed). +type St { + St(counter: Int) +} + +/// Mint a globally-fresh (within the current function) Erlang variable name +/// for the raw Core name `raw_name`: the printer's injective legalization +/// (`V…`) plus an `@` suffix, so the name is simultaneously legal, +/// traceable to its Core origin, and provably unbound at its binding site. +fn fresh(st: St, raw_name: String) -> #(String, St) { + #( + core_printer.legalize_var(raw_name) <> "@" <> int.to_string(st.counter), + St(st.counter + 1), + ) +} + +/// Bind `raw_name` to a fresh Erlang variable: mint the name and extend the +/// environment so later references (and only references made under the +/// returned env — Core scoping) resolve to it. +fn bind_var(env: Env, st: St, raw_name: String) -> #(String, Env, St) { + let #(name, st2) = fresh(st, raw_name) + #(name, Env(..env, vars: dict.insert(env.vars, raw_name, name)), st2) +} + +// ─────────────────────────────── node constructors ─────────────────────────────── +// +// Each helper builds exactly the tuple `erts/absform` documents, with the +// enclosing function's synthetic line as the anno. Patterns share the +// expression shapes (`{var,…}`, `{tuple,…}`, …) exactly as in the format. + +fn e_atom(ln: Int, name: String) -> Form { + raw(#(atom_of("atom"), ln, atom_of(name))) +} + +fn e_int(ln: Int, value: Int) -> Form { + raw(#(atom_of("integer"), ln, value)) +} + +fn e_float(ln: Int, value: Float) -> Form { + raw(#(atom_of("float"), ln, value)) +} + +fn e_var(ln: Int, name: String) -> Form { + raw(#(atom_of("var"), ln, atom_of(name))) +} + +fn e_nil(ln: Int) -> Form { + raw(#(atom_of("nil"), ln)) +} + +fn e_cons(ln: Int, head: Form, tail: Form) -> Form { + raw(#(atom_of("cons"), ln, head, tail)) +} + +fn e_tuple(ln: Int, elements: List(Form)) -> Form { + raw(#(atom_of("tuple"), ln, elements)) +} + +fn e_match(ln: Int, pattern: Form, value: Form) -> Form { + raw(#(atom_of("match"), ln, pattern, value)) +} + +fn e_block(ln: Int, body: List(Form)) -> Form { + raw(#(atom_of("block"), ln, body)) +} + +fn e_case(ln: Int, arg: Form, clauses: List(Form)) -> Form { + raw(#(atom_of("case"), ln, arg, clauses)) +} + +/// `{clause, Anno, Pats, Guards, Body}`. `guards` is `[]` for a guardless +/// clause or `[[G]]` for a single guard test (the only shapes emitted). +fn e_clause( + ln: Int, + pats: List(Form), + guards: List(List(Form)), + body: List(Form), +) -> Form { + raw(#(atom_of("clause"), ln, pats, guards, body)) +} + +fn e_call(ln: Int, target: Form, args: List(Form)) -> Form { + raw(#(atom_of("call"), ln, target, args)) +} + +fn e_remote(ln: Int, module: Form, function: Form) -> Form { + raw(#(atom_of("remote"), ln, module, function)) +} + +fn e_fun(ln: Int, clauses: List(Form)) -> Form { + raw(#(atom_of("fun"), ln, #(atom_of("clauses"), clauses))) +} + +fn e_named_fun(ln: Int, name: String, clauses: List(Form)) -> Form { + raw(#(atom_of("named_fun"), ln, atom_of(name), clauses)) +} + +/// `{'try', Anno, Body, CaseClauses, CatchClauses, After=[]}`. +fn e_try( + ln: Int, + body: List(Form), + case_clauses: List(Form), + catch_clauses: List(Form), +) -> Form { + let after: List(Form) = [] + raw(#(atom_of("try"), ln, body, case_clauses, catch_clauses, after)) +} + +fn e_bin(ln: Int, elements: List(Form)) -> Form { + raw(#(atom_of("bin"), ln, elements)) +} + +/// One `{bin_element, Anno, Value, Size, TSL}` segment; `tsl` is the +/// type-specifier list (`[integer, {unit,1}, unsigned, big]`-style). +fn e_bin_element(ln: Int, value: Form, size: Form, tsl: List(Form)) -> Form { + raw(#(atom_of("bin_element"), ln, value, size, tsl)) +} + +/// A proper-list EAF term `[E1, E2, …]` (a `{cons,…}` chain ending `{nil,…}`) +/// — used for the packed argument list of a multi-def `letrec` dispatch call, +/// and as the matching list PATTERN (same shape) on the receiving clause. +fn e_list(ln: Int, elements: List(Form)) -> Form { + list.fold_right(elements, e_nil(ln), fn(acc, e) { e_cons(ln, e, acc) }) +} + +// ─────────────────────────────── expression translation ─────────────────────────────── + +/// Translate a list of single-value expressions in order, threading the fresh +/// counter. Returns the forms in the input order. +fn tr_exprs( + exprs: List(CExpr), + env: Env, + st: St, +) -> Result(#(List(Form), St), EafError) { + use #(rev, st2) <- result.try( + list.try_fold(exprs, #([], st), fn(acc, e) { + let #(forms, st0) = acc + use #(f, st1) <- result.try(tr_expr(e, env, st0)) + Ok(#([f, ..forms], st1)) + }), + ) + Ok(#(list.reverse(rev), st2)) +} + +/// Translate one single-value Core expression to an EAF expression. +/// +/// Returns the form plus the advanced counter, or the `EafError` describing +/// which `emit_core` invariant the input broke. `let`/`letrec` chains are +/// routed through `tr_body` and wrapped in a `begin … end` block only when the +/// chain has more than one statement. +fn tr_expr(expr: CExpr, env: Env, st: St) -> Result(#(Form, St), EafError) { + let ln = env.ln + case expr { + CVar(name) -> { + // A reference must resolve to an in-scope binder. The fallback (raw + // legalized name) is defensively deterministic for an unbound + // reference — erl_lint then reports it as unbound, fail-closed. + let v = case dict.get(env.vars, name) { + Ok(n) -> n + Error(_) -> core_printer.legalize_var(name) + } + Ok(#(e_var(ln, v), st)) + } + CInt(v) -> Ok(#(e_int(ln, v), st)) + CFloat(v) -> Ok(#(e_float(ln, v), st)) + CAtom(name) -> Ok(#(e_atom(ln, name), st)) + CNil -> Ok(#(e_nil(ln), st)) + CCons(head, tail) -> { + use #(h, st1) <- result.try(tr_expr(head, env, st)) + use #(t, st2) <- result.try(tr_expr(tail, env, st1)) + Ok(#(e_cons(ln, h, t), st2)) + } + CTuple(elements) -> { + use #(es, st1) <- result.try(tr_exprs(elements, env, st)) + Ok(#(e_tuple(ln, es), st1)) + } + CBinary(segments) -> { + use #(rev, st1) <- result.try( + list.try_fold(segments, #([], st), fn(acc, seg) { + let #(fs, st0) = acc + use #(f, stn) <- result.try(tr_bitseg(seg, env, st0)) + Ok(#([f, ..fs], stn)) + }), + ) + Ok(#(e_bin(ln, list.reverse(rev)), st1)) + } + CValues(values) -> Error(BareValueList(list.length(values))) + CFun(vars, body) -> { + let #(params, env2, st1) = bind_params(vars, env, st) + use #(bodyf, st2) <- result.try(tr_body(body, env2, st1)) + Ok(#(e_fun(ln, [e_clause(ln, params, [], bodyf)]), st2)) + } + CLet(_, _, _) | CLetrec(_, _) -> { + use #(stmts, st1) <- result.try(tr_body(expr, env, st)) + case stmts { + [single] -> Ok(#(single, st1)) + _ -> Ok(#(e_block(ln, stmts), st1)) + } + } + CCase(arg, clauses) -> { + use #(argf, st1) <- result.try(tr_expr(arg, env, st)) + use #(rev, st2) <- result.try( + list.try_fold(clauses, #([], st1), fn(acc, cl) { + let #(fs, st0) = acc + use #(f, stn) <- result.try(tr_clause(cl, env, st0)) + Ok(#([f, ..fs], stn)) + }), + ) + Ok(#(e_case(ln, argf, list.reverse(rev)), st2)) + } + CApply(FName(name, arity), args) -> { + use #(argfs, st1) <- result.try(tr_exprs(args, env, st)) + case dict.get(env.funs, #(name, arity)) { + Ok(DirectFun(v)) -> Ok(#(e_call(ln, e_var(ln, v), argfs), st1)) + Ok(DispatchFun(v, tag)) -> + Ok(#( + e_call(ln, e_var(ln, v), [e_atom(ln, tag), e_list(ln, argfs)]), + st1, + )) + Error(_) -> Ok(#(e_call(ln, e_atom(ln, name), argfs), st1)) + } + } + CApplyExpr(op, args) -> { + use #(opf, st1) <- result.try(tr_expr(op, env, st)) + use #(argfs, st2) <- result.try(tr_exprs(args, env, st1)) + Ok(#(e_call(ln, opf, argfs), st2)) + } + CCall(module, function, args) -> { + use #(mf, st1) <- result.try(tr_expr(module, env, st)) + use #(ff, st2) <- result.try(tr_expr(function, env, st1)) + use #(argfs, st3) <- result.try(tr_exprs(args, env, st2)) + Ok(#(e_call(ln, e_remote(ln, mf, ff), argfs), st3)) + } + // `primop 'build_stacktrace'(RawS)` exists in Core because the caught + // stacktrace token is raw there; an Erlang `catch C:R:S` binds the + // already-built stacktrace, so the primop is the identity here. + CPrimop("build_stacktrace", [arg]) -> tr_expr(arg, env, st) + CPrimop(name, _) -> Error(UnsupportedPrimop(name)) + CTry(arg, body_vars, body, evars, handler) -> + case body_vars, evars { + [bv], [ec, er, es] -> { + use #(argf, st1) <- result.try(tr_expr(arg, env, st)) + let #(bvn, benv, st2) = bind_var(env, st1, bv) + use #(bodyf, st3) <- result.try(tr_body(body, benv, st2)) + let #(ecn, henv1, st4) = bind_var(env, st3, ec) + let #(ern, henv2, st5) = bind_var(henv1, st4, er) + let #(esn, henv3, st6) = bind_var(henv2, st5, es) + use #(handlerf, st7) <- result.try(tr_body(handler, henv3, st6)) + Ok(#( + e_try(ln, [argf], [e_clause(ln, [e_var(ln, bvn)], [], bodyf)], [ + e_clause( + ln, + [ + e_tuple(ln, [ + e_var(ln, ecn), + e_var(ln, ern), + e_var(ln, esn), + ]), + ], + [], + handlerf, + ), + ]), + st7, + )) + } + _, _ -> Error(BadTryShape(list.length(body_vars), list.length(evars))) + } + } +} + +/// One binary segment: `#(Size, Unit, Type, Flags)` → +/// `{bin_element, Anno, V, Size, [Type, {unit,Unit}, Flags…]}`. +fn tr_bitseg(seg: CBitSeg, env: Env, st: St) -> Result(#(Form, St), EafError) { + let ln = env.ln + let CBitSeg(value, size, unit, segtype, flags) = seg + use #(vf, st1) <- result.try(tr_expr(value, env, st)) + use #(sf, st2) <- result.try(tr_expr(size, env, st1)) + let tsl = + list.append( + [raw(atom_of(segtype)), raw(#(atom_of("unit"), unit))], + list.map(flags, fn(f) { raw(atom_of(f)) }), + ) + Ok(#(e_bin_element(ln, vf, sf, tsl), st2)) +} + +/// Bind a fun/def parameter list to fresh Erlang variables, in order. +/// Returns the parameter pattern forms, the extended environment, and the +/// advanced counter. +fn bind_params( + params: List(String), + env: Env, + st: St, +) -> #(List(Form), Env, St) { + let #(rev, env2, st2) = + list.fold(params, #([], env, st), fn(acc, p) { + let #(fs, env0, st0) = acc + let #(n, env1, st1) = bind_var(env0, st0, p) + #([e_var(env.ln, n), ..fs], env1, st1) + }) + #(list.reverse(rev), env2, st2) +} + +/// Translate one `case` clause. The pattern list is always singleton (the +/// value-list wrapper the printer used was cosmetic — every emitted scrutinee +/// is single-valued); pattern variables are fresh binders extending the +/// clause-local environment, exactly Core's shadowing semantics. +fn tr_clause(cl: CClause, env: Env, st: St) -> Result(#(Form, St), EafError) { + let ln = env.ln + let CClause(pats, guard, body) = cl + case pats { + [p] -> { + let #(pf, env2, st1) = tr_pat(p, env, st) + use #(guards, st2) <- result.try(case guard { + CAtom("true") -> Ok(#([], st1)) + _ -> { + use #(gf, stg) <- result.try(tr_expr(guard, env2, st1)) + Ok(#([[gf]], stg)) + } + }) + use #(bodyf, st3) <- result.try(tr_body(body, env2, st2)) + Ok(#(e_clause(ln, [pf], guards, bodyf), st3)) + } + _ -> Error(BadClauseArity(list.length(pats))) + } +} + +/// Translate a Core pattern. Every `PVar` is a FRESH binder (alpha-renamed), +/// so in the produced EAF it is guaranteed unbound — i.e. it always binds, +/// never degenerates into Erlang's bound-variable equality check, preserving +/// Core's binding semantics. Total (patterns contain no failing shapes). +fn tr_pat(pat: CPat, env: Env, st: St) -> #(Form, Env, St) { + let ln = env.ln + case pat { + PVar(name) -> { + let #(n, env2, st2) = bind_var(env, st, name) + #(e_var(ln, n), env2, st2) + } + PInt(v) -> #(e_int(ln, v), env, st) + PAtom(name) -> #(e_atom(ln, name), env, st) + PNil -> #(e_nil(ln), env, st) + PCons(head, tail) -> { + let #(hf, env1, st1) = tr_pat(head, env, st) + let #(tf, env2, st2) = tr_pat(tail, env1, st1) + #(e_cons(ln, hf, tf), env2, st2) + } + PTuple(elements) -> { + let #(rev, env2, st2) = + list.fold(elements, #([], env, st), fn(acc, p) { + let #(fs, env0, st0) = acc + let #(f, env1, st1) = tr_pat(p, env0, st0) + #([f, ..fs], env1, st1) + }) + #(e_tuple(ln, list.reverse(rev)), env2, st2) + } + } +} + +// ─────────────────────────────── body (statement-list) translation ─────────────────────────────── + +/// Translate an expression into a NON-EMPTY statement list — the body of a +/// clause/fun. `let` and `letrec` chains flatten into sequential `match` +/// statements instead of nesting `begin … end` blocks hundreds deep (large +/// guests chain thousands of `let`s): +/// +/// - `let X = A in B` → `X@n = A′, B′…` +/// - `let = in C` → `X@n = A′, Y@m = B′, C′…` (RHS values are +/// translated in the OUTER scope first — Core evaluates the whole value +/// list before binding — then bound pairwise; the binders are fresh, so the +/// interleaved match order is observationally identical). +/// - `let <> = <> in B` → `B′…` (the vacuous zero-binder let). +/// - `letrec 'f'/n = fun … in B` → `F@n = fun F@n(…) -> … end, B′…`. +fn tr_body( + expr: CExpr, + env: Env, + st: St, +) -> Result(#(List(Form), St), EafError) { + let ln = env.ln + case expr { + CLet([], CValues([]), body) -> tr_body(body, env, st) + CLet([], arg, body) -> { + // A zero-binder let over a non-empty RHS: evaluate for effect, discard. + use #(argf, st1) <- result.try(tr_expr(arg, env, st)) + use #(rest, st2) <- result.try(tr_body(body, env, st1)) + Ok(#([argf, ..rest], st2)) + } + CLet([x], arg, body) -> { + use #(argf, st1) <- result.try(tr_expr(arg, env, st)) + let #(xn, env2, st2) = bind_var(env, st1, x) + use #(rest, st3) <- result.try(tr_body(body, env2, st2)) + Ok(#([e_match(ln, e_var(ln, xn), argf), ..rest], st3)) + } + CLet(vars, CValues(vals), body) -> { + let nv = list.length(vars) + case nv == list.length(vals) { + False -> Error(BadValueBinding(nv, list.length(vals))) + True -> { + // Translate every RHS value in the OUTER scope first (Core + // evaluates the full value list before any binder is in scope)… + use #(valfs, st1) <- result.try(tr_exprs(vals, env, st)) + // …then bind the names and pair them up as sequential matches. + let #(rev_names, env2, st2) = + list.fold(vars, #([], env, st1), fn(acc, v) { + let #(ns, env0, st0) = acc + let #(n, env1, stn) = bind_var(env0, st0, v) + #([n, ..ns], env1, stn) + }) + let matches = + list.map2(list.reverse(rev_names), valfs, fn(n, vf) { + e_match(ln, e_var(ln, n), vf) + }) + use #(rest, st3) <- result.try(tr_body(body, env2, st2)) + Ok(#(list.append(matches, rest), st3)) + } + } + } + CLet(vars, _, _) -> Error(BadValueBinding(list.length(vars), -1)) + CLetrec(defs, inner) -> tr_letrec(defs, inner, env, st) + _ -> { + use #(f, st1) <- result.try(tr_expr(expr, env, st)) + Ok(#([f], st1)) + } + } +} + +/// Lower a `letrec` (see the module doc): a single def becomes a named fun +/// bound to a variable of the same name (`F = fun F(…) -> … end` — the outer +/// `F` and the fun-name `F` are the same fun value, so one name serves both +/// the letrec body and the recursive calls); multiple defs become one +/// tag-dispatching named fun. +fn tr_letrec( + defs: List(FunDef), + inner: CExpr, + env: Env, + st: St, +) -> Result(#(List(Form), St), EafError) { + let ln = env.ln + case defs { + [FunDef(FName(name, arity), CFun(params, fbody))] -> { + let #(fvar, st1) = fresh(st, name) + let env2 = + Env(..env, funs: dict.insert(env.funs, #(name, arity), DirectFun(fvar))) + let #(pforms, env3, st2) = bind_params(params, env2, st1) + use #(bodyf, st3) <- result.try(tr_body(fbody, env3, st2)) + let named = e_named_fun(ln, fvar, [e_clause(ln, pforms, [], bodyf)]) + use #(rest, st4) <- result.try(tr_body(inner, env2, st3)) + Ok(#([e_match(ln, e_var(ln, fvar), named), ..rest], st4)) + } + [FunDef(FName(name, _), _)] -> Error(NonFunDef(name)) + _ -> { + // Mutually recursive group → one dispatcher. Not currently emitted + // (emit_core always produces singleton letrecs) but lowered for + // totality: R = fun R(tag_i, [P…]) -> body_i end, calls become + // R(tag_j, [args…]) — still tail calls. + let #(rvar, st1) = fresh(st, "letrec") + let env2 = + list.fold(defs, env, fn(acc, def) { + let FunDef(FName(name, arity), _) = def + let tag = name <> "/" <> int.to_string(arity) + Env( + ..acc, + funs: dict.insert(acc.funs, #(name, arity), DispatchFun(rvar, tag)), + ) + }) + use #(clauses_rev2, st2) <- result.try( + list.try_fold(defs, #([], st1), fn(acc, def) { + let #(cls, st0) = acc + case def { + FunDef(FName(name, arity), CFun(params, fbody)) -> { + let tag = name <> "/" <> int.to_string(arity) + let #(pforms, env3, stp) = bind_params(params, env2, st0) + use #(bodyf, stb) <- result.try(tr_body(fbody, env3, stp)) + Ok(#( + [ + e_clause(ln, [e_atom(ln, tag), e_list(ln, pforms)], [], bodyf), + ..cls + ], + stb, + )) + } + FunDef(FName(name, _), _) -> Error(NonFunDef(name)) + } + }), + ) + let named = e_named_fun(ln, rvar, list.reverse(clauses_rev2)) + use #(rest, st4) <- result.try(tr_body(inner, env2, st2)) + Ok(#([e_match(ln, e_var(ln, rvar), named), ..rest], st4)) + } + } +} + +// ─────────────────────────────── module translation ─────────────────────────────── + +/// Convert a literal Core expression into a plain-term EAF ATTRIBUTE value +/// (attribute values are terms, not expression nodes). Only literal shapes are +/// convertible; anything else is `Error(Nil)` (the caller maps it to +/// `UnsupportedAttribute`). +fn lit_term(expr: CExpr) -> Result(Form, Nil) { + case expr { + CInt(v) -> Ok(raw(v)) + CFloat(v) -> Ok(raw(v)) + CAtom(name) -> Ok(raw(atom_of(name))) + CNil -> Ok(raw(empty_term_list())) + CCons(_, _) -> { + // A literal PROPER list (a Gleam `List(Form)` IS the Erlang list term); + // an improper tail is not emitted and falls out as `Error(Nil)`. + use elements <- result.try(lit_list(expr, [])) + Ok(raw(elements)) + } + CTuple(els) -> { + use fs <- result.try(list.try_map(els, lit_term)) + Ok(term_tuple(fs)) + } + _ -> Error(Nil) + } +} + +/// The empty list as a plain term (typed helper so `raw` sees a `List(Form)`). +fn empty_term_list() -> List(Form) { + [] +} + +/// Walk a literal `CCons` chain into a `List(Form)` of element terms; +/// `Error(Nil)` on an improper tail or a non-literal element. +fn lit_list(expr: CExpr, acc: List(Form)) -> Result(List(Form), Nil) { + case expr { + CNil -> Ok(list.reverse(acc)) + CCons(h, t) -> { + use hf <- result.try(lit_term(h)) + lit_list(t, [hf, ..acc]) + } + _ -> Error(Nil) + } +} + +/// `erlang:list_to_tuple/1` — build a plain tuple TERM from element terms +/// (attribute values only; expression nodes never go through here). +@external(erlang, "erlang", "list_to_tuple") +fn term_tuple(elements: List(Form)) -> Form + +/// Translate one top-level definition `'name'/arity = fun (…) -> …` into a +/// `{function, Line, Name, Arity, [Clause]}` form. `idx` (0-based def +/// position) supplies the synthetic line (`idx + 1`) annotating every node in +/// the function, so runtime stacktrace lines identify the generated function. +fn tr_def(def: FunDef, idx: Int) -> Result(Form, EafError) { + let FunDef(FName(name, arity), value) = def + case value { + CFun(params, body) -> { + let ln = idx + 1 + let env = Env(ln: ln, vars: dict.new(), funs: dict.new()) + let st = St(0) + let #(pforms, env2, st1) = bind_params(params, env, st) + use #(bodyf, _st2) <- result.try(tr_body(body, env2, st1)) + Ok( + raw( + #(atom_of("function"), ln, atom_of(name), arity, [ + e_clause(ln, pforms, [], bodyf), + ]), + ), + ) + } + _ -> Error(NonFunDef(name)) + } +} + +/// Lower a whole Core-shaped module to its Erlang Abstract Format form list: +/// `-module` and `-export` attributes, any literal module attributes, then one +/// `{function,…}` form per definition (def order preserved; the n-th function +/// is annotated line `n+1`). +/// +/// The result is exactly what `compile:forms/2` consumes +/// (`twocore_codegen_ffi:compile_forms/1` / the linker's `to_core` entry) — +/// `module_info/0,1` are NOT emitted (the compiler adds them itself on the +/// abstract-forms path, unlike `from_core`). +/// +/// Returns `Ok(forms)` or the first `EafError` (each variant marks an +/// `emit_core` invariant violation — see the type). Total — never panics. +pub fn module_forms(m: CModule) -> Result(List(Form), EafError) { + let mod_attr = + raw(#(atom_of("attribute"), 1, atom_of("module"), atom_of(m.name))) + let exports = + list.map(m.exports, fn(f) { + let FName(name, arity) = f + raw(#(atom_of(name), arity)) + }) + let exp_attr = raw(#(atom_of("attribute"), 1, atom_of("export"), exports)) + use attrs <- result.try( + list.try_map(m.attributes, fn(kv) { + let #(key, value) = kv + case lit_term(value) { + Ok(term) -> Ok(raw(#(atom_of("attribute"), 1, atom_of(key), term))) + Error(Nil) -> Error(UnsupportedAttribute(key)) + } + }), + ) + use defs <- result.try( + list.index_map(m.defs, fn(def, idx) { #(def, idx) }) + |> list.try_map(fn(pair) { tr_def(pair.0, pair.1) }), + ) + Ok(list.flatten([[mod_attr, exp_attr], attrs, defs])) +} diff --git a/src/twocore_codegen_ffi.erl b/src/twocore_codegen_ffi.erl index a456c3e8..994b7473 100644 --- a/src/twocore_codegen_ffi.erl +++ b/src/twocore_codegen_ffi.erl @@ -1,7 +1,8 @@ %%% twocore_codegen_ffi — the «FFI-SHIM» (Unit 04). %%% -%%% Turns Core Erlang *text* into a loaded `.beam` module inside the running -%%% VM. This is the backend's last seam (decision D10): everything the codegen +%%% Turns Erlang Abstract Format *forms* (built in-memory by +%%% `twocore/backend/eaf`) into a loaded `.beam` module inside the running VM. +%%% This is the backend's last seam (decision D10): everything the codegen %%% units (03/08/10/11) produce is run through here to prove it is real, %%% preemptible BEAM code. %%% @@ -10,82 +11,70 @@ %%% `twocore_` so it can NEVER collide with an OTP module (`compile`, `lists`, %%% …); generated modules are prefixed `twocore@…`. %%% -%%% PINNED TO OTP 29. The in-memory text path relies on the compiler-internal -%%% modules `core_scan`/`core_parse` and on the UNDOCUMENTED textual `from_core` -%%% format, both of which may change between OTP releases. Verified on OTP 29. +%%% Why abstract forms (NOT textual Core Erlang): the previous backend printed +%%% `.core` text and re-parsed it with the compiler-internal +%%% `core_scan`/`core_parse` modules plus the UNDOCUMENTED textual `from_core` +%%% entry — an OTP-release-fragile print→re-parse round trip that also cost an +%%% ~85 MB text transient on large guests. Abstract forms are the DOCUMENTED +%%% `erts/absform` contract consumed natively by `compile:forms/2`: no text, no +%%% scanner, no parser, stable across OTP releases. %%% -%%% Why scan→parse→forms (NOT `compile:forms` on text): `compile:forms/2` with -%%% `from_core` expects cerl records (`#c_module{}`), not `.core` text — feeding -%%% it text crashes inside `core_lint`. So the path MUST be -%%% core_scan:string/1 → core_parse:parse/1 → #c_module{} -%%% → compile:forms(CMod, [from_core, binary, return_errors, return_warnings]). -%%% -%%% Error-shape normalization: the three failing stages report differently — -%%% core_scan:string -> {error, ErrInfo, End} -%%% core_parse:parse -> {error, ErrInfo} (bare, one level) -%%% compile:forms -> {error, [{File,[ErrInfo]}], _W} (per-file nested) +%%% Error-shape normalization: `compile:forms` reports +%%% {error, [{File,[ErrInfo]}], _W} (per-file nested) %%% where ErrInfo = {Loc, Mod, Desc}, Desc is a TERM (not a string), and Loc is -%%% {Line,Col} | Line | none. We fold all three into ONE flat `[Binary]` list of +%%% {Line,Col} | Line | none. We fold it into ONE flat `[Binary]` list of %%% ": " lines (message via `Mod:format_error/1`), so the Gleam -%%% `Result(_, [String])` is stable regardless of which stage failed. NOTE: the -%%% scan/parse branches wrap their single rendered line in a list (`[fmt_one(EI)]`) -%%% so the error slot is ALWAYS a flat `[Binary]` — matching the Gleam FFI type -%%% `List(String)`. (The unit-04 doc's verbatim shim returned a bare binary here, -%%% which is inconsistent with the `compile:forms` branch and with the declared -%%% Gleam type; this is the documented-contract shape.) +%%% `Result(_, [String])` is stable. A crash inside the compiler on a malformed +%%% form list is caught and rendered the same way (fail-closed, D8 — the shim +%%% never brings the build VM down on bad input). -module(twocore_codegen_ffi). --export([compile_core/1, load_module/3]). +-export([id/1, compile_forms/1, forms_to_erl/1, load_module/3]). + +%% id(Term) -> Term. +%% +%% The identity coercion the Gleam side uses to forget a node tuple's Gleam +%% type (a Gleam tuple IS the Erlang term, so building an abstract-format node +%% from Gleam is a plain tuple literal passed through here). +id(X) -> X. -%% compile_core(CoreBin) -> {ok, {Module, Beam}} | {error, [Binary]} +%% compile_forms(Forms) -> {ok, {Module, Beam}} | {error, [Binary]} %% -%% CoreBin is `.core` source TEXT as a (byte-aligned) binary. On success the -%% returned Module atom is taken from the `.core` `module` header, not any -%% filename. On failure every diagnostic from whichever stage failed is -%% returned as a flat list of human-readable ": " binaries. +%% Forms is a list of Erlang Abstract Format forms (`-module`/`-export` +%% attributes + `{function,…}` forms — `erts/absform`). On success the returned +%% Module atom is taken from the `-module` attribute. On failure every +%% diagnostic is returned as a flat list of human-readable ": " +%% binaries. %% -%% PERF (compile memory): `core_printer` pretty-prints with an indent that GROWS -%% per nesting level, and a large guest (a TeaVM/Java module) lowers to functions -%% nested hundreds of `let`/`letrec` levels deep — so a ~277 KB wasm becomes ~85 MB -%% of `.core`, of which ~94% is leading whitespace. That is catastrophic HERE -%% (only): `unicode:characters_to_list/1` turns the 85 MB binary into an ~1.4 GB -%% char list, and `core_scan` chews the lot, blowing the compile up to ~12 GiB and -%% OOM-killing a memory-capped node. The whitespace is INSIGNIFICANT to the scanner -%% (tokens stay separated by the newlines we keep), so we strip each line's leading -%% indentation before scanning — dropping the text ~15x and peak compile memory -%% ~11x (measured: 11.74 GiB -> ~1.0 GiB) with a byte-identical `.beam`. The -%% pretty-printed text `core_printer` returns is untouched (tests/debug still read -%% it); only this compile path sees the compact form. -compile_core(CoreBin) when is_binary(CoreBin) -> - Str = unicode:characters_to_list(strip_indent(CoreBin)), - case core_scan:string(Str) of - {ok, Toks, _End} -> - case core_parse:parse(Toks) of - {ok, CMod} -> - case compile:forms(CMod, [from_core, binary, return_errors, return_warnings]) of - {ok, Mod, Beam, _W} -> {ok, {Mod, Beam}}; - {ok, Mod, Beam} -> {ok, {Mod, Beam}}; - {error, Errs, _W} -> {error, fmt_errs(Errs)} - end; - {error, EI} -> {error, [fmt_one(EI)]} - end; - {error, EI, _End} -> {error, [fmt_one(EI)]} +%% Options: `binary` returns the `.beam` in-memory (never touches disk); +%% `return_errors`/`return_warnings` select the tuple (not printed) report +%% shapes; `nowarn_unused_vars` silences the one warning class alpha-renamed +%% codegen output legitimately triggers en masse (fresh pattern binders used +%% as wildcards), keeping the warning list from growing O(module) on large +%% guests. +compile_forms(Forms) when is_list(Forms) -> + try compile:forms(Forms, [binary, return_errors, return_warnings, + nowarn_unused_vars]) of + {ok, Mod, Beam, _W} -> {ok, {Mod, Beam}}; + {ok, Mod, Beam} -> {ok, {Mod, Beam}}; + {error, Errs, _W} -> {error, fmt_errs(Errs)}; + error -> {error, [<<"module: compile:forms failed">>]} + catch + Class:Reason -> + Msg = io_lib:format("compiler crashed: ~0p:~0p", [Class, Reason]), + {error, [unicode:characters_to_binary(Msg)]} end. -%% Strip each line's LEADING indentation (spaces/tabs) while keeping the newlines -%% that separate tokens — semantics-preserving for the Core scanner. Sub-binaries -%% from `binary:split` are cheap references into `CoreBin`; the rejoined result is -%% the ~6 MB of real content (vs ~85 MB of mostly-whitespace input). -strip_indent(CoreBin) -> - Lines = binary:split(CoreBin, <<"\n">>, [global]), - iolist_to_binary(lists:join(<<"\n">>, [trim_leading(L) || L <- Lines])). - -trim_leading(<<$\s, Rest/binary>>) -> trim_leading(Rest); -trim_leading(<<$\t, Rest/binary>>) -> trim_leading(Rest); -trim_leading(Line) -> Line. +%% forms_to_erl(Forms) -> Binary +%% +%% Pretty-print abstract forms as Erlang SOURCE text (`erl_pp`) — the debug / +%% inspection surface (the CLI's `to-erl`). Total for well-formed forms; a +%% malformed node is rendered by erl_pp's own fallback rather than crashing +%% the dump. +forms_to_erl(Forms) when is_list(Forms) -> + unicode:characters_to_binary([[erl_pp:form(F), $\n] || F <- Forms]). %% Flatten the compiler's per-file nested error list into one flat list of -%% rendered binary lines. `fmt_one` already returns a single binary, so we wrap -%% it in a list for `core_parse`/`core_scan` (bare ErrInfo) callers below. +%% rendered binary lines. fmt_errs(Errs) -> lists:flatten([[fmt_one(EI) || EI <- EIs] || {_F, EIs} <- Errs]). %% Render one ErrInfo `{Loc, Mod, Desc}` into a ": " binary. @@ -109,9 +98,7 @@ loc_bin(none) -> <<"module">>. %% i.e. an Erlang STRING (char list) — it raises `function_clause` on a binary. %% A Gleam `String` crosses the FFI as a binary, so we convert it to a char list %% here (`unicode:characters_to_list/1` is idempotent for lists, so a list -%% caller also works). (The unit-04 doc's verbatim shim passed Filename straight -%% through, which only works when called from Erlang with a list literal; from -%% Gleam it must be converted.) +%% caller also works). load_module(Mod, Filename, Beam) -> FnList = unicode:characters_to_list(Filename), case code:load_binary(Mod, FnList, Beam) of From f3870303640de84e32b194c66012c60d97e9beee Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Sun, 19 Jul 2026 15:07:56 +0200 Subject: [PATCH 2/4] linker: acquire the generated module from abstract forms via to_core The whole-program linker consumed the generated module as .core text (core_scan/core_parse). It now takes the same abstract forms the build compiles and recovers the #c_module{} through the compiler's own to_core pass in-process, stripping the auto-added module_info/0,1 defs/exports to restore the merge pipeline's invariant (assemble emits the merged pair itself). The discovered-closure acquisition (resident .beam debug_info core_v1, compile:file to_core fallback), reachability, mangle, DCE, D3a self-check and the deterministic from_core compile of the MERGED cerl are unchanged. link_manifest: the frozen acquisition rule for the generated module is renamed GeneratedCoreText -> GeneratedAbstractForms. --- src/twocore/backend/beam_link.gleam | 30 ++++--- src/twocore/backend/link_manifest.gleam | 11 +-- src/twocore_linker_ffi.erl | 111 ++++++++++++++---------- 3 files changed, 89 insertions(+), 63 deletions(-) diff --git a/src/twocore/backend/beam_link.gleam b/src/twocore/backend/beam_link.gleam index edf6cc23..0a3fa2bc 100644 --- a/src/twocore/backend/beam_link.gleam +++ b/src/twocore/backend/beam_link.gleam @@ -18,6 +18,7 @@ //// deterministic `from_core`, with a built-in fail-closed structural D3a check). import gleam/erlang/atom.{type Atom} +import twocore/backend/eaf /// Why a whole-program link refused to produce a `.beam` (fail-closed). Each /// variant is a distinct, terminal reason surfaced at LINK time — never a @@ -46,9 +47,10 @@ pub type LinkError { /// `'M__F'` local-name scheme would not be collision-free. `a`/`b` carry the /// offending atom and detail. MangleCollision(a: String, b: String) - /// `core_scan`/`core_parse`/`core_lint`/`compile:forms` rejected the input or - /// the merged output (e.g. broken generated `.core`, or a declared-name - /// mismatch). `detail` is a human-readable diagnostic, never a raw term. + /// The `to_core` lowering of the generated forms, `core_lint`, or + /// `compile:forms` rejected the input or the merged output (e.g. broken + /// generated forms, or a declared-name mismatch). `detail` is a + /// human-readable diagnostic, never a raw term. MalformedCore(detail: String) /// An in-closure module was located but its Core could not be acquired (no /// `core_v1` `debug_info` chunk and no compilable source). `module`/`reason` @@ -63,7 +65,7 @@ pub type LinkError { /// this boundary; the shapes are validated by the tests (trust boundary). @external(erlang, "twocore_linker_ffi", "link_program") fn ffi_link_program( - generated_core: BitArray, + generated_forms: List(eaf.Form), module_name: String, ambient: List(String), ) -> Result(#(Atom, BitArray), #(String, String, String)) @@ -72,7 +74,7 @@ fn ffi_link_program( /// merged Core Erlang TEXT before compilation. @external(erlang, "twocore_linker_ffi", "link_to_core") fn ffi_link_to_core( - generated_core: BitArray, + generated_forms: List(eaf.Form), module_name: String, ambient: List(String), ) -> Result(#(Atom, String), #(String, String, String)) @@ -97,10 +99,12 @@ fn to_link_error(err: #(String, String, String)) -> LinkError { /// Merge the generated module + its transitive twocore/gleam closure into ONE /// self-contained `.beam`. /// -/// - `generated_core`: the emitted `.core` TEXT as a byte-aligned `BitArray` -/// (exactly what `build_beam.compile_core` consumes). -/// - `module_name`: the generated module's atom name — it MUST equal the `.core` -/// `module` header (`== ir.Module.name`). It BOTH locates the generated +/// - `generated_forms`: the generated module's Erlang Abstract Format forms +/// (from `eaf.module_forms` — exactly what `build_beam.compile_forms` +/// consumes); the linker recovers the cerl module via the compiler's own +/// `to_core` pass in-process. +/// - `module_name`: the generated module's atom name — it MUST equal the +/// `-module` attribute (`== ir.Module.name`). It BOTH locates the generated /// module and names the merged output. Callers that load multiple linked /// modules concurrently pass a uniquified name (R10/capstone). /// - `ambient`: `link_manifest.ambient_allowlist()` — the modules DCE walks up @@ -112,11 +116,11 @@ fn to_link_error(err: #(String, String, String)) -> LinkError { /// reason. Never panics on bad input — a malformed generated `.core`, a missing /// dependency, or a D3a violation all surface as a typed `Error`. pub fn link_program( - generated_core: BitArray, + generated_forms: List(eaf.Form), module_name: String, ambient: List(String), ) -> Result(#(Atom, BitArray), LinkError) { - case ffi_link_program(generated_core, module_name, ambient) { + case ffi_link_program(generated_forms, module_name, ambient) { Ok(pair) -> Ok(pair) Error(err) -> Error(to_link_error(err)) } @@ -135,11 +139,11 @@ pub fn link_program( /// the identical reachability/mangle/DCE and the identical fail-closed D3a /// self-check. pub fn link_to_core( - generated_core: BitArray, + generated_forms: List(eaf.Form), module_name: String, ambient: List(String), ) -> Result(#(Atom, String), LinkError) { - case ffi_link_to_core(generated_core, module_name, ambient) { + case ffi_link_to_core(generated_forms, module_name, ambient) { Ok(pair) -> Ok(pair) Error(err) -> Error(to_link_error(err)) } diff --git a/src/twocore/backend/link_manifest.gleam b/src/twocore/backend/link_manifest.gleam index 498d8c37..b8407c28 100644 --- a/src/twocore/backend/link_manifest.gleam +++ b/src/twocore/backend/link_manifest.gleam @@ -83,9 +83,10 @@ pub fn dce_only_remotes() -> List(String) { /// NAMES the rule so the choice is single-sourced. Verified acquirable on OTP 29.0.2 for every member /// via the primary path. pub type Acquisition { - /// The GENERATED (wasm-derived) module ONLY: its `.core` source TEXT parsed via the existing - /// `core_scan`/`core_parse` path (the `twocore_codegen_ffi` route). - GeneratedCoreText + /// The GENERATED (wasm-derived) module ONLY: its Erlang Abstract Format forms (from + /// `twocore/backend/eaf`) lowered to `#c_module{}` via the compiler's own `to_core` pass + /// (`compile:forms/2` stopped at the Core stage), with the auto-added `module_info/0,1` stripped. + GeneratedAbstractForms /// The UNIFORM primary for every DISCOVERED in-closure module: `beam_lib:chunks(Beam,[debug_info])` /// then `Backend:debug_info(core_v1, Mod, Data, [])` straight from the module's RESIDENT `.beam` — /// needs no `.erl` on disk (verified on `rt_num`, `gleam@int`, `gleam_stdlib`, `twocore_rt_exn_ffi`). @@ -99,11 +100,11 @@ pub type Acquisition { /// /// - `is_generated`: True for the wasm-derived generated module, False for every discovered /// in-closure runtime/gleam/FFI module. -/// - Returns `GeneratedCoreText` when `is_generated`, otherwise `ResidentBeamCore` (the uniform +/// - Returns `GeneratedAbstractForms` when `is_generated`, otherwise `ResidentBeamCore` (the uniform /// resident-`.beam` `debug_info` path that covers the `.erl` FFI bucket too). pub fn primary_acquisition(is_generated: Bool) -> Acquisition { case is_generated { - True -> GeneratedCoreText + True -> GeneratedAbstractForms False -> ResidentBeamCore } } diff --git a/src/twocore_linker_ffi.erl b/src/twocore_linker_ffi.erl index 4cc8c835..9924138a 100644 --- a/src/twocore_linker_ffi.erl +++ b/src/twocore_linker_ffi.erl @@ -9,15 +9,19 @@ %%% `cerl`/`cerl_trees`/`core_lint`/`core_pp`/`compile` machinery. %%% %%% PINNED TO OTP 29. Relies on the compiler-internal `cerl`/`cerl_trees`/ -%%% `core_scan`/`core_parse`/`core_lint`/`core_pp` modules, the `debug_info` -%%% chunk `core_v1` backend contract, and the undocumented textual `from_core` -%%% format — all of which may change between OTP releases. Verified on OTP 29. +%%% `core_lint`/`core_pp` modules, the `debug_info` chunk `core_v1` backend +%%% contract, and the (undocumented) `from_core` entry for compiling the merged +%%% cerl — all of which may change between OTP releases. Verified on OTP 29. +%%% (The GENERATED module now arrives as documented Erlang Abstract Format +%%% forms and is lowered to cerl via the compiler's own `to_core` pass; the +%%% textual `core_scan`/`core_parse` route is gone.) %%% %%% ── The algorithm (spec §3, decisions R1/R4/R5/R6/R9/R10/R11) ───────────── %%% -%%% 1. ACQUIRE the generated module's Core from its `.core` TEXT -%%% (`core_scan`/`core_parse`, the `twocore_codegen_ffi` route). Its declared -%%% module name must equal `ModuleName`. +%%% 1. ACQUIRE the generated module's Core from its ABSTRACT FORMS via the +%%% compiler's own `to_core` pass (`compile:forms/2` stopped at Core), with +%%% the auto-added `module_info/0,1` stripped. Its declared module name must +%%% equal `ModuleName`. %%% 2. REACHABILITY (R6): a worklist from the ROOTS — the generated module's %%% exports (public exports + the synthesized `instantiate/N`) — following %%% THREE edge kinds (R4): remote `#c_call` to an in-closure module, an @@ -72,29 +76,30 @@ %% ── Public entry points ─────────────────────────────────────────────────── -%% link_program(GeneratedCore, ModuleName, Ambient) +%% link_program(GeneratedForms, ModuleName, Ambient) %% -> {ok, {Module :: atom(), Beam :: binary()}} | {error, ErrTuple} %% -%% Merge + deterministic-compile to a loadable `.beam`. `GeneratedCore` is the -%% emitted `.core` TEXT (binary); `ModuleName` is the merged module's atom name +%% Merge + deterministic-compile to a loadable `.beam`. `GeneratedForms` is the +%% generated module's Erlang Abstract Format form list (what +%% `twocore/backend/eaf` emits); `ModuleName` is the merged module's atom name %% (binary); `Ambient` is a list of allowlist module-name binaries (the DCE %% stop-set). On success the returned Module atom equals the name declared %% inside the returned Beam. -link_program(GeneratedCore, ModuleName, Ambient) -> - case build_merged(GeneratedCore, ModuleName, Ambient) of +link_program(GeneratedForms, ModuleName, Ambient) -> + case build_merged(GeneratedForms, ModuleName, Ambient) of {ok, {Name, CMod}} -> compile_merged(Name, CMod); {error, _} = E -> E end. -%% link_to_core(GeneratedCore, ModuleName, Ambient) +%% link_to_core(GeneratedForms, ModuleName, Ambient) %% -> {ok, {Module :: atom(), CoreText :: binary()}} | {error, ErrTuple} %% %% The SAME merge as `link_program`, returning the merged Core Erlang TEXT %% *before* compilation — the seam P11-06 uses to independently re-run the %% structural D3a assertion and inspect DCE. Single-sourced with `link_program` %% via `build_merged/3` (both run the fail-closed D3a self-check). -link_to_core(GeneratedCore, ModuleName, Ambient) -> - case build_merged(GeneratedCore, ModuleName, Ambient) of +link_to_core(GeneratedForms, ModuleName, Ambient) -> + case build_merged(GeneratedForms, ModuleName, Ambient) of {ok, {Name, CMod}} -> Txt = unicode:characters_to_binary(core_pp:format(CMod)), {ok, {Name, Txt}}; @@ -106,12 +111,12 @@ link_to_core(GeneratedCore, ModuleName, Ambient) -> %% Produce the merged `#c_module{}` (name + cerl), fail-closed. Wrapped in a %% catch so any unexpected internal crash surfaces as a typed `malformed_core` %% error rather than a raw exception across the FFI boundary (D8, fail-closed). -build_merged(GeneratedCore, ModuleNameBin, AmbientBins) -> +build_merged(GeneratedForms, ModuleNameBin, AmbientBins) -> try MergedName = binary_to_atom(ModuleNameBin, utf8), Ambient = sets:from_list([binary_to_atom(B, utf8) || B <- AmbientBins]), - %% (1) acquire the generated module from TEXT. - GenCMod = acquire_generated(GeneratedCore, MergedName), + %% (1) acquire the generated module from its abstract FORMS. + GenCMod = acquire_generated(GeneratedForms, MergedName), %% (2) reachability + lazy acquisition of the discovered closure. St0 = #{merged => MergedName, ambient => Ambient, mods => #{MergedName => index_module(GenCMod)}, @@ -146,30 +151,53 @@ bin(X) -> iolist_to_binary(io_lib:format("~p", [X])). %% ── (1) Core acquisition ────────────────────────────────────────────────── -%% Scan+parse the generated `.core` TEXT into a `#c_module{}` and check that its -%% declared module name matches the caller's `ModuleName` (the merged output -%% name). `malformed_core` on scan/parse failure or a name mismatch. -acquire_generated(CoreBin, MergedName) -> - Str = unicode:characters_to_list(CoreBin), - case core_scan:string(Str) of - {ok, Toks, _End} -> - case core_parse:parse(Toks) of - {ok, CMod} -> - Declared = cerl:atom_val(cerl:module_name(CMod)), - case Declared =:= MergedName of - true -> CMod; - false -> - fail(<<"malformed_core">>, - io_lib:format("generated module declares '~s' " - "but link requested '~s'", - [Declared, MergedName]), - <<>>) - end; - {error, EI} -> fail(<<"malformed_core">>, fmt_ei(EI), <<>>) +%% Lower the generated module's abstract FORMS to a `#c_module{}` via the +%% compiler's own `to_core` pass in-process (`compile:forms/2` — the same +%% documented entry the plain build uses, stopped at the Core stage), strip the +%% auto-added `module_info/0,1` defs/exports (the merge assembles its own pair +%% for the merged atom — and the pre-EAF generated Core never carried them), +%% and check that the declared module name matches the caller's `ModuleName` +%% (the merged output name). `malformed_core` on a compile failure or a name +%% mismatch. +acquire_generated(Forms, MergedName) when is_list(Forms) -> + case compile:forms(Forms, [to_core, binary, return_errors, + nowarn_unused_vars]) of + {ok, _Mod, CMod0} -> + CMod = strip_module_info(CMod0), + Declared = cerl:atom_val(cerl:module_name(CMod)), + case Declared =:= MergedName of + true -> CMod; + false -> + fail(<<"malformed_core">>, + io_lib:format("generated module declares '~s' " + "but link requested '~s'", + [Declared, MergedName]), + <<>>) end; - {error, EI, _End} -> fail(<<"malformed_core">>, fmt_ei(EI), <<>>) + {error, Errs, _W} -> + fail(<<"malformed_core">>, + io_lib:format("to_core failed: ~0p", [Errs]), <<>>); + Other -> + fail(<<"malformed_core">>, + io_lib:format("to_core failed: ~0p", [Other]), <<>>) end. +%% Drop the compiler-added `module_info/0,1` definitions + exports from a +%% freshly `to_core`-compiled generated module, restoring the invariant the +%% merge pipeline was built on (the generated module defines no module_info; +%% `assemble` emits the merged pair itself). +strip_module_info(CMod) -> + Defs = [D || {V, _} = D <- cerl:module_defs(CMod), + not is_module_info(cerl:var_name(V))], + Exports = [E || E <- cerl:module_exports(CMod), + not is_module_info(cerl:var_name(E))], + cerl:update_c_module(CMod, cerl:module_name(CMod), Exports, + cerl:module_attrs(CMod), Defs). + +is_module_info({module_info, 0}) -> true; +is_module_info({module_info, 1}) -> true; +is_module_info(_) -> false. + %% Acquire a DISCOVERED in-closure module's `#c_module{}` from its RESIDENT %% `.beam` via the `debug_info` `core_v1` backend (R1 primary); fall back to %% `compile:file(F,[to_core])` on the `.erl` source when no `core_v1` chunk is @@ -655,13 +683,6 @@ compile_forms(MergedName, CMod) -> %% ── diagnostic rendering ─────────────────────────────────────────────────── -fmt_ei({Loc, Mod, Desc}) -> - iolist_to_binary([loc(Loc), ": ", Mod:format_error(Desc)]). - -loc({L, _C}) -> integer_to_binary(L); -loc(L) when is_integer(L) -> integer_to_binary(L); -loc(_) -> <<"module">>. - fmt_lint(Es) -> iolist_to_binary( lists:join("; ", [io_lib:format("~p", [E]) || E <- Es])). From bbc14cbbfeabdeda836c707cb2aeaf398c608da5 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Sun, 19 Jul 2026 15:07:56 +0200 Subject: [PATCH 3/4] pipeline/cli: compile through the CModule seam; add to-erl; to-beam takes .ir - pipeline: ir_to_lowered_core/ir_to_core+core_to_beam are replaced by ir_to_lowered_cmod/ir_to_cmod + cmod_to_beam - the compile path hands the backend AST straight to build_beam (abstract forms + compile:forms/2), with no printed text in between. ir_to_core remains as the textual inspection surface backing the to-core dump. chunks_to_beams compiles each chunk CModule directly. - cli: to-beam/build now compiles .ir (there is no .core text left to compile; the verb runs the fail-closed Safe binding); a new to-erl verb dumps the generated module as Erlang source via erl_pp over the exact forms compile:forms/2 consumes; to-beam-wasm and the --link path go through the CModule seam. - embed: unchanged behavior (compiles via the updated chunk path); doc references updated. --- src/twocore.gleam | 128 ++++++++++++++++++++++++------------- src/twocore/embed.gleam | 2 +- src/twocore/pipeline.gleam | 102 ++++++++++++++++------------- 3 files changed, 142 insertions(+), 90 deletions(-) diff --git a/src/twocore.gleam b/src/twocore.gleam index a155a4be..202e52ac 100644 --- a/src/twocore.gleam +++ b/src/twocore.gleam @@ -17,7 +17,8 @@ //// | `opt [--unsafe]` | parse `.ir` → optimize_ir(profile) → print `.ir` | //// | `emit [--unsafe]` | parse `.ir` → emit_core(profile) → print `.core` | //// | `to-core [--unsafe]` | parse `.ir` → ir_lower → optimize → emit_core → `.core` | -//// | `to-beam [out.beam]` (= `build`) | parse+build `.core` → write `.beam` (no profile) | +//// | `to-erl [axes]` | parse `.ir` → … → emit → abstract forms → print `.erl` | +//// | `to-beam [out.beam]` (= `build`) | parse `.ir` → … → compile:forms → write `.beam` (Safe) | //// | `run [axes] ` | source → … → ir_lower → optimize → load → invoke → print | //// //// ## Phase-4 axis flags (decision #5 — every posture is a NAMED token; fail-closed default) @@ -41,7 +42,7 @@ //// - `--table-tier paged|ets|atomics` — the funcref-table trust tier. //// - `--cap PAGES` — a bounded linear-memory page cap (required to engage `atomics`/`ceiling`). //// `opt` keeps only `--unsafe` (it drives the optimizer, which reads no tier). `to-beam`/`build` -//// take **no** profile — they compile already-emitted `.core`, which carries no `Binding`. +//// take **no** profile — they compile the `.ir` under the fail-closed Safe binding. //// //// ## Value convention (the run/invoke ABI — `pipeline.gleam`) //// @@ -52,7 +53,6 @@ //// non-zero — a trap is a runtime outcome, surfaced as a CLI failure. import argv -import gleam/bit_array import gleam/int import gleam/io import gleam/list @@ -63,6 +63,7 @@ import simplifile import twocore/backend/beam_link import twocore/backend/bindings import twocore/backend/build_beam +import twocore/backend/core_erlang import twocore/backend/core_printer import twocore/backend/emit_core import twocore/frontend/wasm/decode @@ -125,14 +126,23 @@ pub fn run(args: List(String)) -> Result(String, String) { _ -> Error(usage()) } }) + ["to-erl", ..rest] -> + with_binding(rest, fn(binding, axes, pos) { + use <- reject_link(axes.link, "to-erl") + use <- reject_output_flags(axes, "to-erl") + case pos { + [path] -> cmd_to_erl(path, binding) + _ -> Error(usage()) + } + }) ["to-beam", ..rest] | ["build", ..rest] -> - // R13: the `.core`-input verbs carry no `Binding`/profile, so `--link` cannot be gated - // (tier-N / import-bearing are undecidable here). Reject the token explicitly rather than - // silently no-op it or mistake it for a positional path. + // R13: this profile-less verb runs the fail-closed Safe binding, so `--link` cannot be + // gated here. Reject the token explicitly rather than silently no-op it or mistake it + // for a positional path. case list.contains(rest, "--link") { True -> Error( - "--link is only valid on to-beam-wasm; the .core-input to-beam/build verbs carry no Binding to gate (R13)", + "--link is only valid on to-beam-wasm; the profile-less to-beam/build verb carries no Binding to gate (R13)", ) False -> case rest { @@ -608,17 +618,50 @@ fn cmd_to_core(path: String, binding: Binding) -> Result(String, String) { } } -/// `to-beam`/`build [out.beam]` — compile `.core` to a `.beam` binary (unit 04) -/// and write it to `output`. Prints a confirmation line. +/// `to-beam`/`build [out.beam]` — parse `.ir` → ir_lower → optimize → emit → +/// abstract forms → `compile:forms/2` → write `.beam` (unit 04). Runs the fail-closed Safe +/// profile (the verb still takes no axis flags — use `to-beam-wasm [axes]` for a posture). +/// Prints a confirmation line. (Previously this verb compiled already-printed `.core` text; +/// the textual re-parse path no longer exists — the backend compiles the AST directly.) fn cmd_to_beam(input: String, output: String) -> Result(String, String) { use text <- result.try(read_text(input)) - case build_beam.compile_core(bit_array.from_string(text)) { - Error(e) -> Error("build: " <> string.inspect(e)) - Ok(#(_mod_atom, beam)) -> - case simplifile.write_bits(output, beam) { - Error(fe) -> - Error("write " <> output <> ": " <> simplifile.describe_error(fe)) - Ok(Nil) -> Ok("wrote " <> output) + case pipeline.parse_ir(text) { + Error(e) -> Error("parse .ir: " <> string.inspect(e)) + Ok(m) -> + case pipeline.ir_to_cmod(m, profiles.safe()) { + Error(e) -> Error(pipeline.describe(e)) + Ok(cmod) -> + case build_beam.compile_module(cmod) { + Error(e) -> Error("build: " <> string.inspect(e)) + Ok(#(_mod_atom, beam)) -> + case simplifile.write_bits(output, beam) { + Error(fe) -> + Error( + "write " <> output <> ": " <> simplifile.describe_error(fe), + ) + Ok(Nil) -> Ok("wrote " <> output) + } + } + } + } +} + +/// `to-erl [axes]` — parse `.ir` → ir_lower → optimize → emit → abstract forms → +/// `erl_pp` pretty-print: dump the generated module as Erlang SOURCE text, i.e. exactly the +/// forms `compile:forms/2` consumes (the EAF-backend inspection surface, the `.erl`-flavoured +/// sibling of `to-core`). +fn cmd_to_erl(path: String, binding: Binding) -> Result(String, String) { + use text <- result.try(read_text(path)) + case pipeline.parse_ir(text) { + Error(e) -> Error("parse .ir: " <> string.inspect(e)) + Ok(m) -> + case pipeline.ir_to_cmod(m, binding) { + Error(e) -> Error(pipeline.describe(e)) + Ok(cmod) -> + case build_beam.module_to_erl(cmod) { + Error(e) -> Error("to-erl: " <> string.inspect(e)) + Ok(erl) -> Ok(erl) + } } } } @@ -762,8 +805,8 @@ fn cmd_to_beam_wasm( } } -/// The LEGACY `to-beam-wasm` body (P11-04, unchanged) — `source_to_ir → ir_to_core → core_to_beam → -/// write` (or the `--link` merge). Extracted verbatim so the two-positional path stays +/// The LEGACY `to-beam-wasm` body (P11-04) — `source_to_ir → ir_to_cmod → cmod_to_beam → +/// write` (or the `--link` merge). Extracted so the two-positional path stays /// byte-identical (proven by `cli_link_flag_test.default_off_byte_identical_test`). fn legacy_to_beam_wasm( input: String, @@ -777,10 +820,10 @@ fn legacy_to_beam_wasm( Ok(m) -> case link { False -> - case pipeline.ir_to_core(m, binding) { + case pipeline.ir_to_cmod(m, binding) { Error(e) -> Error(pipeline.describe(e)) - Ok(core) -> - case pipeline.core_to_beam(core, m.name) { + Ok(cmod) -> + case pipeline.cmod_to_beam(cmod) { Error(e) -> Error(pipeline.describe(e)) Ok(beam) -> write_beam(output, beam) } @@ -789,12 +832,10 @@ fn legacy_to_beam_wasm( case link_gate(binding, m) { Error(ge) -> Error(describe_link_gate_error(ge)) Ok(Nil) -> - case pipeline.ir_to_core(m, binding) { + case pipeline.ir_to_cmod(m, binding) { Error(e) -> Error(pipeline.describe(e)) - Ok(core) -> - case - build_beam.link_beam(bit_array.from_string(core), m.name) - { + Ok(cmod) -> + case build_beam.link_beam(cmod) { Error(le) -> Error(describe_beam_link_error(le)) Ok(#(_atom, beam)) -> write_beam(output, beam) } @@ -806,9 +847,9 @@ fn legacy_to_beam_wasm( /// The FOLDER `to-beam-wasm` path (P12-05): compile `` into ``, emitting the `.beam` + /// one typed companion binding per requested language. The R17 lower-ONCE seam is the crux — -/// `pipeline.ir_to_lowered_core` lowers+optimizes ONCE and returns BOTH the module and its `.core`; +/// `pipeline.ir_to_lowered_cmod` lowers+optimizes ONCE and returns BOTH the module and its `CModule`; /// the SAME lowered module is handed to `bindings.emit_bindings` (which runs `iface.describe` over -/// it) while its `.core` becomes the `.beam`. So `describe` and the `.beam` ABI see identical bodies +/// it) while its `CModule` becomes the `.beam`. So `describe` and the `.beam` ABI see identical bodies /// — a mutation-carrying export cannot be misclassified pure (dropping `St'`). Fail-closed: a /// rejected module (Cell / import-bearing / mutable-tier) writes NOTHING (describe runs before any /// IO); a link-gate/link failure surfaces before emit. @@ -830,39 +871,39 @@ fn folder_to_beam_wasm( pipeline.source_to_ir_with(bytes, binding.narrow_carried) |> result.map_error(pipeline.describe), ) - // R17: lower + optimize ONCE; the returned `lowered` module is exactly what the `.core` (hence the - // `.beam`) is generated from, and it is what `emit_bindings` runs `describe` over. - use #(lowered, core) <- result.try( - pipeline.ir_to_lowered_core(m, binding) + // R17: lower + optimize ONCE; the returned `lowered` module is exactly what the `CModule` (hence + // the `.beam`) is generated from, and it is what `emit_bindings` runs `describe` over. + use #(lowered, cmod) <- result.try( + pipeline.ir_to_lowered_cmod(m, binding) |> result.map_error(pipeline.describe), ) - use beam <- result.try(beam_of_lowered(core, lowered, binding, link)) + use beam <- result.try(beam_of_lowered(cmod, lowered, binding, link)) case bindings.emit_bindings(lowered, binding, beam, dir, langs) { Error(be) -> Error(bindings.describe_error(be)) Ok(paths) -> Ok("wrote " <> string.join(paths, ", ")) } } -/// Compile lowered `.core` text into the `.beam` bytes for the FOLDER path, honoring `--link`. With -/// `link == False` it is a plain `core_to_beam`; with `link == True` it runs the fail-closed +/// Compile the lowered `CModule` into the `.beam` bytes for the FOLDER path, honoring `--link`. +/// With `link == False` it is a plain `cmod_to_beam`; with `link == True` it runs the fail-closed /// `link_gate` (tier-N / import-bearing) then merges the runtime closure via `build_beam.link_beam`. /// Returns the bytes or the rendered CLI diagnostic. `mod`'s `.name`/`.imports` drive the gate + the /// baked module atom (it is the SAME lowered module `describe` sees). fn beam_of_lowered( - core: String, + cmod: core_erlang.CModule, mod: ir.Module, binding: Binding, link: Bool, ) -> Result(BitArray, String) { case link { False -> - pipeline.core_to_beam(core, mod.name) + pipeline.cmod_to_beam(cmod) |> result.map_error(pipeline.describe) True -> case link_gate(binding, mod) { Error(ge) -> Error(describe_link_gate_error(ge)) Ok(Nil) -> - case build_beam.link_beam(bit_array.from_string(core), mod.name) { + case build_beam.link_beam(cmod) { Error(le) -> Error(describe_beam_link_error(le)) Ok(#(_atom, beam)) -> Ok(beam) } @@ -968,11 +1009,11 @@ fn format_values(values: List(Int)) -> String { values |> list.map(int.to_string) |> string.join(" ") } -/// Default `.beam` output path for `to-beam`: swap a trailing `.core` for `.beam`, else +/// Default `.beam` output path for `to-beam`: swap a trailing `.ir` for `.beam`, else /// append `.beam`. fn default_beam(input: String) -> String { - case string.ends_with(input, ".core") { - True -> string.drop_end(input, 5) <> ".beam" + case string.ends_with(input, ".ir") { + True -> string.drop_end(input, 3) <> ".beam" False -> input <> ".beam" } } @@ -1005,7 +1046,8 @@ fn usage() -> String { " gleam run -- opt [--unsafe] optimizer stage → .ir (Safe=Baseline, Unsafe=Aggressive)", " gleam run -- emit [axes] emit_core only → .core", " gleam run -- to-core [axes] ir_lower + optimize + emit_core → .core", - " gleam run -- to-beam [out.beam] compile → .beam (alias: build; no profile)", + " gleam run -- to-erl [axes] ir_lower + optimize + emit → abstract forms → .erl dump", + " gleam run -- to-beam [out.beam] compile → .beam (alias: build; Safe profile)", " gleam run -- to-beam-wasm [axes] [--link] .wasm → .beam under a profile (bench)", " gleam run -- to-beam-wasm [axes] --bindings --out + typed host bindings", " gleam run -- run [axes] compile + invoke on the BEAM", @@ -1031,7 +1073,7 @@ fn usage() -> String { " files into (pass only ; the .beam name is derived)", " A non-default posture must be NAMED; Safe + --tier nif and an uncapped atomics/ceiling", " build are rejected fail-closed (exit non-zero), never silently downgraded.", - " opt takes only --unsafe; to-beam/build take no profile (they compile .core — no Binding).", + " opt takes only --unsafe; to-beam/build take no profile (they compile under Safe).", "Values are raw unsigned bit patterns in decimal (i32 -1 is 4294967295).", ], "\n", diff --git a/src/twocore/embed.gleam b/src/twocore/embed.gleam index 496d6ee3..ed8c9da3 100644 --- a/src/twocore/embed.gleam +++ b/src/twocore/embed.gleam @@ -99,7 +99,7 @@ fn no_progress(_percent: Int, _phase: String) -> Nil { /// guests an embedder loads into one node (e.g. `"twocore@wasm@" <> deployment <> "_" <> slug`). /// Passing a colliding name reintroduces the load-overwrite hazard `compile` warns about. /// - Returns `Ok(Compiled)` whose `module.name` is `name`, or `Error(text)` (same failure modes as -/// `compile`; an atom-invalid `name` surfaces as a codegen `Error` from `core_to_beam`). Total. +/// `compile`; an atom-invalid `name` surfaces as a codegen `Error` from `cmod_to_beam`). Total. pub fn compile_named(wasm: BitArray, name: String) -> Result(Compiled, String) { compile_with_name(wasm, Some(name), no_progress) } diff --git a/src/twocore/pipeline.gleam b/src/twocore/pipeline.gleam index a2220a8e..b51756a3 100644 --- a/src/twocore/pipeline.gleam +++ b/src/twocore/pipeline.gleam @@ -37,7 +37,7 @@ //// `mem_tier`/`table_tier` (`Paged`/`Atomics`/`Nif` · …) — are **build-time fields on the //// one `Binding`** that already threads through every stage. The pipeline runs the **same //// five stages in the same order** regardless of them: -//// `source_to_ir → lower_ir → optimize_ir → ir_to_core (emit_core) → core_to_beam`. +//// `source_to_ir → lower_ir → optimize_ir → ir_to_cmod (emit_core) → cmod_to_beam`. //// **No stage branches on a Phase-4 axis.** The axes are consumed at exactly two points, both //// downstream of the stage graph: //// - **codegen** — `emit_core` reads `binding.state_strategy` (the one codegen-shape switch: @@ -60,7 +60,6 @@ //// threading `St'` across invokes as a value). The `Cell` path is byte-identical to Phase 2/3; //// a `Threaded`/`atomics` build runs the SAME driver code end-to-end (G7). -import gleam/bit_array import gleam/dynamic.{type Dynamic} import gleam/erlang/atom.{type Atom} import gleam/erlang/process.{type Pid} @@ -201,7 +200,7 @@ fn parse_payload(s: String) -> List(Int) { /// `instantiate → invoke_instance` process ABI below (E5). Retained for the fuel-measuring /// `ir_lower` tests, which require same-process execution. /// -/// - `beam`: the compiled `.beam` binary (from `core_to_beam`). +/// - `beam`: the compiled `.beam` binary (from `cmod_to_beam`). /// - `mod`: the module's atom NAME (the name baked into the `.core`, i.e. `ir.Module.name`). /// - `export`: the exported function name to apply. /// - `args`: the call arguments as raw unsigned bit-pattern integers (see the ABI above). @@ -248,7 +247,7 @@ pub opaque type InstanceProc { /// cell) vs the `InstanceState` record (`Threaded`, held as the process's loop variable) — so /// this function needs **no** `Binding` parameter and both strategies share one code path. /// -/// - `beam`: the compiled `.beam` binary (from `core_to_beam`). +/// - `beam`: the compiled `.beam` binary (from `cmod_to_beam`). /// - `mod`: the module's atom NAME (must match the name baked into the `.core`). /// - Returns `Ok(InstanceProc)` once the state is seeded and the process is ready for /// `invoke_instance`; `Error("load failed: …")` if the binary will not load; or @@ -339,7 +338,7 @@ pub fn mem_size_instance(proc: InstanceProc) -> Int { /// embedder host-function guest is expected to import functions, not state) — an unsatisfied /// state import fails closed here (`Error`), never a silent default (spec §4.5.4). /// -/// - `beam`: the compiled module (from `core_to_beam` / `embed.compile`). +/// - `beam`: the compiled module (from `cmod_to_beam` / `embed.compile`). /// - `m`: that module's IR — its `imports` order drives the woven function-import vector. /// - `host`: the embedder's dispatcher. Receives `(capability, name, args)` where `args` are the /// call's raw WASM argument bit patterns (D5); returns the raw result bit patterns (`[]` for a @@ -535,7 +534,7 @@ fn ffi_bench_instance( /// /// - `wasm`: untrusted `.wasm` bytes. /// - Return: `Ok(ir.Module)` or the first failing stage's `Error(PipelineError)`. No policy -/// pass or codegen is run here (use `ir_to_core` for those). Total — never panics. +/// pass or codegen is run here (use `ir_to_cmod` for those). Total — never panics. pub fn source_to_ir(wasm: BitArray) -> Result(ir.Module, PipelineError) { source_to_ir_with(wasm, False) } @@ -598,47 +597,62 @@ pub fn optimize_ir(m: ir.Module, binding: Binding) -> ir.Module { ir_opt.optimize(m, binding.opt_level) } -/// IR → `#(lowered_optimized_module, core_text)`: run `lower_ir` (Safe policy pass / metering) -/// then `optimize_ir` (level from `binding.opt_level`) ONCE, then `emit_core` + `core_printer`, -/// returning BOTH the exact `ir.Module` `emit_core` consumed AND its printed `.core` text. +/// IR → `#(lowered_optimized_module, cmod)`: run `lower_ir` (Safe policy pass / metering) +/// then `optimize_ir` (level from `binding.opt_level`) ONCE, then `emit_core`, returning BOTH +/// the exact `ir.Module` `emit_core` consumed AND the Core-shaped `CModule` it produced. /// /// This is the **lower-ONCE seam** (Phase-12 · R17) for any consumer that must run a second /// analysis over the module the `.beam` is generated from — chiefly the bindings driver, which -/// hands the returned module to `iface.describe` while the returned `core_text` becomes the `.beam` -/// (`core_to_beam`). Because both see the IDENTICAL lowered+optimized function bodies, a derived +/// hands the returned module to `iface.describe` while the returned `CModule` becomes the `.beam` +/// (`cmod_to_beam`). Because both see the IDENTICAL lowered+optimized function bodies, a derived /// property (`touches_state`, emitted arity) can never diverge from the `.beam` ABI — the failure /// mode R17 guards against (a mutation-carrying export misclassified pure, silently dropping `St'`). -/// `ir_to_core/2` is exactly this seam with the module discarded. +/// `ir_to_cmod/2` is exactly this seam with the module discarded. /// /// - `m`: the IR module to compile (e.g. from `source_to_ir` or `ir/parser`). /// - `binding`: the build-time runtime binding (chokepoint module names + policy mode + the /// optimizer level). -/// - Return: `Ok(#(lowered_optimized_module, core_text))`, or `Error(IrLowerFailed/EmitFailed)`. +/// - Return: `Ok(#(lowered_optimized_module, cmod))`, or `Error(IrLowerFailed/EmitFailed)`. /// The module's `.name` (the compiled atom, `twocore@wasm@`) is preserved by `lower_ir`/ /// `optimize_ir` — it is the atom the `.beam` loads under and the binding dispatches into. Total. -pub fn ir_to_lowered_core( +pub fn ir_to_lowered_cmod( m: ir.Module, binding: Binding, -) -> Result(#(ir.Module, String), PipelineError) { +) -> Result(#(ir.Module, CModule), PipelineError) { case lower_ir(m, binding) { Error(e) -> Error(e) Ok(lowered) -> { let optimized = optimize_ir(lowered, binding) case emit_core.emit_module(optimized, binding) { Error(e) -> Error(EmitFailed(e)) - Ok(cmod) -> Ok(#(optimized, core_printer.print_module(cmod))) + Ok(cmod) -> Ok(#(optimized, cmod)) } } } } -/// IR → `.core` text: `ir_lower` (Safe policy pass / metering) → `ir_opt` (level from -/// `binding.opt_level`, F1) → `emit_core`, printed by `core_printer`. The canonical -/// "IR → backend" path the CLI's `to-core`/`run` use, now with the optimizer in-chain. +/// IR → the backend `CModule`: `ir_lower` (Safe policy pass / metering) → `ir_opt` (level from +/// `binding.opt_level`, F1) → `emit_core`. The canonical "IR → backend" seam the CLI's +/// `run`/`to-beam-wasm` and the test drivers compile through (`cmod_to_beam` / +/// `build_beam.compile_and_load` consume the result directly — no textual round trip). /// -/// Delegates to `ir_to_lowered_core/2` (the same three stages) and discards the module, so the -/// `.core` text is byte-identical to the standalone chain — proven by the CLI default-off -/// byte-identity test. +/// Delegates to `ir_to_lowered_cmod/2` (the same three stages) and discards the module. +/// +/// - `m`: the IR module to compile. +/// - `binding`: the build-time runtime binding. +/// - Return: `Ok(cmod)`, or `Error(IrLowerFailed/EmitFailed)`. Total — never panics. +pub fn ir_to_cmod( + m: ir.Module, + binding: Binding, +) -> Result(CModule, PipelineError) { + ir_to_lowered_cmod(m, binding) + |> result.map(fn(pair) { pair.1 }) +} + +/// IR → `.core` text: the same three stages as `ir_to_cmod`, pretty-printed by +/// `core_printer`. This is now a pure INSPECTION surface (the CLI's `to-core` dump and the +/// text-shape tests) — the compile path consumes the `CModule` directly (`cmod_to_beam`); +/// the printed text is never re-parsed. /// /// - `m`: the IR module to compile. /// - `binding`: the build-time runtime binding (chokepoint module names + policy mode + the @@ -648,23 +662,19 @@ pub fn ir_to_core( m: ir.Module, binding: Binding, ) -> Result(String, PipelineError) { - ir_to_lowered_core(m, binding) - |> result.map(fn(pair) { pair.1 }) + ir_to_cmod(m, binding) + |> result.map(core_printer.print_module) } -/// Compile `.core` text to an in-memory `.beam` binary (unit 04), WITHOUT loading it. +/// Compile a backend `CModule` to an in-memory `.beam` binary (unit 04), WITHOUT loading it: +/// lower to Erlang Abstract Format (`eaf.module_forms`) and compile in-process via +/// `compile:forms/2` (`build_beam.compile_module`). /// -/// - `core`: the Core Erlang source text. -/// - `mod`: the expected module name (documentation/diagnostic only; the real name is the -/// one baked into the `.core` header — pass `ir.Module.name` for clarity). -/// - Return: `Ok(beam_bytes)` or `Error(BuildFailed(_))` (scan/parse/compile diagnostics). -/// Total — never panics on malformed `.core` (it becomes `Error`). -pub fn core_to_beam( - core: String, - mod: String, -) -> Result(BitArray, PipelineError) { - let _ = mod - case build_beam.compile_core(bit_array.from_string(core)) { +/// - `cmod`: the emitted Core-shaped module (its `.name` is the atom baked into the `.beam`). +/// - Return: `Ok(beam_bytes)` or `Error(BuildFailed(_))` (lowering/compile diagnostics). +/// Total — never panics on a malformed module (it becomes `Error`). +pub fn cmod_to_beam(cmod: CModule) -> Result(BitArray, PipelineError) { + case build_beam.compile_module(cmod) { Error(e) -> Error(BuildFailed(e)) Ok(#(_atom, beam)) -> Ok(beam) } @@ -672,7 +682,7 @@ pub fn core_to_beam( /// Lower + optimize + emit + **split** an IR module into N balanced Core sub-modules (chunk 0 first). /// -/// Same three stages as `ir_to_lowered_core` up to `emit_core.emit_module`, then `chunk.split_module` +/// Same three stages as `ir_to_lowered_cmod` up to `emit_core.emit_module`, then `chunk.split_module` /// partitions the whole-module `CModule` so the downstream `compile:forms` peak is bounded to /// O(largest chunk) instead of O(whole module). `target <= 1` or a module with fewer than /// `min_split_defs` defs returns a SINGLE chunk (the whole module), whose printed `.core` is @@ -710,7 +720,7 @@ pub fn chunks_to_beams( chunks: List(CModule), ) -> Result(List(#(String, BitArray)), PipelineError) { list.try_map(chunks, fn(c) { - case core_to_beam(core_printer.print_module(c), c.name) { + case cmod_to_beam(c) { Error(e) -> Error(e) Ok(beam) -> Ok(#(c.name, beam)) } @@ -730,7 +740,7 @@ pub fn parse_ir(text: String) -> Result(ir.Module, ir_parser.ParseError) { /// End-to-end: `.wasm` bytes → result on the BEAM, through the Phase-2 run-ABI /// `load → instantiate → invoke` with one-instance-one-process isolation (E5). /// -/// Composes `source_to_ir` → `ir_to_core(binding)` → `core_to_beam` → `instantiate` (spawn +/// Composes `source_to_ir` → `ir_to_cmod(binding)` → `cmod_to_beam` → `instantiate` (spawn /// the instance's owned process + run `instantiate/0`) → `invoke_instance` → `stop_instance`. /// This is the CLI `run` subcommand's engine and the shape the acceptance corpus proves /// green. The raw-bit-pattern argument/result ABI (D5) is unchanged. @@ -762,10 +772,10 @@ pub fn run_source( case source_to_ir_with(wasm, binding.narrow_carried) { Error(e) -> Error(e) Ok(m) -> - case ir_to_core(m, binding) { + case ir_to_cmod(m, binding) { Error(e) -> Error(e) - Ok(core) -> - case core_to_beam(core, m.name) { + Ok(cmod) -> + case cmod_to_beam(cmod) { Error(e) -> Error(e) Ok(beam) -> case instantiate(beam, m.name) { @@ -867,7 +877,7 @@ fn no_mem_reader(_addr: Int, _len: Int) -> Result(BitArray, Nil) { /// Compile + run a Porffor-emitted `.wasm` on the BEAM under `profiles.porffor()` and collect /// its console output + decoded completion value (the JS-on-BEAM run path, P7-08 §C/§E). This -/// is the headline seam: `source_to_ir` → `ir_to_core(porffor())` → `core_to_beam` → +/// is the headline seam: `source_to_ir` → `ir_to_cmod(porffor())` → `cmod_to_beam` → /// `instantiate` (owned process) → invoke the entry `main` (multi-value `(f64, i32)` return via /// `ffi_call_instance_pair`) → drain the console buffer (`ffi_porffor_output`) → decode the pair /// (`porffor_abi.porf_decode`). Conformance-neutral: a non-Porffor module never reaches this @@ -894,10 +904,10 @@ pub fn run_porffor( Error(reason) -> Ok(PorfforRun(<<>>, porffor_abi.PUndefined, Some("link: " <> reason))) Ok(provided) -> - case ir_to_core(m, profiles.porffor()) { + case ir_to_cmod(m, profiles.porffor()) { Error(e) -> Error(e) - Ok(core) -> - case core_to_beam(core, m.name) { + Ok(cmod) -> + case cmod_to_beam(cmod) { Error(e) -> Error(e) Ok(beam) -> case start_provided_instance(beam, m.name, provided) { From 2f5b4533eb094e78427496acbb9a93c60c248db6 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Sun, 19 Jul 2026 15:07:56 +0200 Subject: [PATCH 4/4] tests: drive the backend through the CModule/forms seam Update every test that compiled printed .core text to hand the backend CModule (or its abstract forms, for the linker) to the new API: - build_beam_test: the hand-written .core string fixtures become hand-built CModule fixtures exercising the same shapes (guards, hot-replace, error paths - the broken-input cases now fail in erl_lint/compile:forms instead of the scanner, same typed CompileFailed contract). - beam_link_test: the synthetic generated modules (fun-capture, D3a erlang:apply, missing closure, mangle collision, on_load, malformed) are built as CModules and lowered with eaf.module_forms; the malformed-input case is now an unbound variable caught by the linker's to_core acquisition. - everywhere else: mechanical moves from ir_to_core + compile_and_load(from_string(core)) to ir_to_cmod + compile_and_load(cmod) (and cmod_to_beam), keeping the text-shape assertions on the printed .core dump where they existed. - cli_test: to-beam is driven with .ir input; a to-erl test asserts the erl_pp dump. --- test/twocore/acceptance_test.gleam | 14 +- test/twocore/backend/beam_link_test.gleam | 140 +++++++---- .../backend/bindings_compile_call_test.gleam | 11 +- .../backend/bindings_driver_test.gleam | 14 +- test/twocore/backend/boxing_test.gleam | 5 +- test/twocore/backend/build_beam_test.gleam | 233 +++++++++--------- test/twocore/backend/closures_test.gleam | 5 +- test/twocore/backend/core_printer_test.gleam | 16 +- test/twocore/backend/emit_core_e2e_test.gleam | 15 +- .../backend/emit_core_tailcall_test.gleam | 5 +- .../backend/emit_elixir_bindings_test.gleam | 7 +- .../backend/emit_erlang_bindings_test.gleam | 7 +- .../backend/emit_gleam_bindings_test.gleam | 5 +- .../backend/emit_trust_memory_test.gleam | 4 +- .../twocore/backend/emit_unchecked_test.gleam | 4 +- test/twocore/backend/fastpath_test.gleam | 5 +- test/twocore/backend/inline_joins_test.gleam | 5 +- test/twocore/backend/lazy_mask_test.gleam | 5 +- test/twocore/backend/link_manifest_test.gleam | 4 +- .../backend/linked_selfcontained_test.gleam | 56 +++-- test/twocore/backend/maps_test.gleam | 5 +- .../backend/reffunc_import_emit_test.gleam | 8 +- .../twocore/backend/rt_js_boundary_test.gleam | 3 +- test/twocore/backend/term_ops_test.gleam | 5 +- test/twocore/cli_link_flag_test.gleam | 4 +- test/twocore/cli_test.gleam | 18 +- test/twocore/conformance/corpus_test.gleam | 8 +- test/twocore/conformance/driver.gleam | 7 +- test/twocore/frontend/wasm/lower_test.gleam | 4 +- test/twocore/middle/ir_lower_test.gleam | 4 +- test/twocore/optimize/bce_test.gleam | 4 +- test/twocore/optimize/coexistence_test.gleam | 5 +- test/twocore/optimize/cross_cf_test.gleam | 4 +- test/twocore/optimize/licm_test.gleam | 4 +- .../optimize/mem10_keystone_test.gleam | 4 +- test/twocore/optimize/mem_bench_test.gleam | 4 +- test/twocore/optimize/mem_dse_test.gleam | 4 +- test/twocore/optimize/mem_forward_test.gleam | 4 +- .../optimize/memory_differential_test.gleam | 4 +- test/twocore/optimize/metering_test.gleam | 5 +- .../twocore/optimize/phase10_bench_test.gleam | 4 +- .../optimize/phase10_capstone_test.gleam | 4 +- test/twocore/pipeline_opt_test.gleam | 4 +- test/twocore/pipeline_tier_test.gleam | 15 +- .../twocore/runtime/linker_coexist_test.gleam | 4 +- .../tier/constant_space_threaded_test.gleam | 5 +- .../twocore/tier/tailcall_capstone_test.gleam | 5 +- 47 files changed, 342 insertions(+), 367 deletions(-) diff --git a/test/twocore/acceptance_test.gleam b/test/twocore/acceptance_test.gleam index 47745495..d6b57bee 100644 --- a/test/twocore/acceptance_test.gleam +++ b/test/twocore/acceptance_test.gleam @@ -147,13 +147,13 @@ fn instantiate_safe(bytes: BitArray) -> Result(Instance, String) { |> result.map_error(pipeline.describe), ) let m = ir.Module(..m0, name: uniquify(m0.name)) - // `ir_to_core` runs the Safe policy pass (ir_lower) BEFORE emit_core — the proof point. - use core <- result.try( - pipeline.ir_to_core(m, profiles.safe()) + // `ir_to_cmod` runs the Safe policy pass (ir_lower) BEFORE emit_core — the proof point. + use cmod <- result.try( + pipeline.ir_to_cmod(m, profiles.safe()) |> result.map_error(pipeline.describe), ) use mod_atom <- result.try( - build_beam.compile_and_load(bit_array.from_string(core)) + build_beam.compile_and_load(cmod) |> result.map_error(fn(e) { "build: " <> string.inspect(e) }), ) use proc <- result.try( @@ -359,11 +359,11 @@ fn declared_host_module() -> ir.Module { /// Compile a hand-built IR module through the Safe pipeline (ir_lower → emit → build) and /// load it, returning the module atom (or the pipeline error as text). fn load_ir(m: ir.Module) -> Result(Atom, String) { - use core <- result.try( - pipeline.ir_to_core(m, profiles.safe()) + use cmod <- result.try( + pipeline.ir_to_cmod(m, profiles.safe()) |> result.map_error(pipeline.describe), ) - build_beam.compile_and_load(bit_array.from_string(core)) + build_beam.compile_and_load(cmod) |> result.map_error(fn(e) { "build: " <> string.inspect(e) }) } diff --git a/test/twocore/backend/beam_link_test.gleam b/test/twocore/backend/beam_link_test.gleam index cd4c5b46..d119f38a 100644 --- a/test/twocore/backend/beam_link_test.gleam +++ b/test/twocore/backend/beam_link_test.gleam @@ -11,7 +11,6 @@ //// Canonical references: the Core Erlang language spec + the WebAssembly spec //// (i32.clz counts leading zero bits; i32.add is modulo 2^32). -import gleam/bit_array import gleam/erlang/atom.{type Atom} import gleam/list import gleam/option @@ -21,7 +20,10 @@ import twocore/backend/beam_link.{ UnmergeableConstruct, } import twocore/backend/build_beam -import twocore/backend/core_printer +import twocore/backend/core_erlang.{ + CApplyExpr, CAtom, CCall, CFun, CModule, CVar, FName, FunDef, +} +import twocore/backend/eaf import twocore/backend/emit_core import twocore/backend/link_manifest import twocore/ir @@ -46,12 +48,35 @@ fn install_synth(name: String, src: String) -> Atom // ───────────────────────────── plumbing ───────────────────────────── -/// Emit `module` to Core Erlang TEXT (as `build_beam`/the linker consume it). -/// `let assert` here is the test's success contract — a failure to emit is a -/// genuine test failure. -fn gen_core(module: ir.Module) -> BitArray { +/// Emit `module` to the backend `CModule` (as `build_beam`/the linker consume +/// it). `let assert` here is the test's success contract — a failure to emit is +/// a genuine test failure. +fn gen_cmod(module: ir.Module) -> core_erlang.CModule { let assert Ok(cm) = emit_core.emit_module(module, instance.safe_default()) - bit_array.from_string(core_printer.print_module(cm)) + cm +} + +/// Lower a backend `CModule` to the abstract FORMS the linker consumes. +fn forms_of(cm: core_erlang.CModule) -> List(eaf.Form) { + let assert Ok(forms) = eaf.module_forms(cm) + forms +} + +/// A tiny synthetic generated module: one exported function `fname/arity` +/// whose body is `body` over params `params` — the forms-level replacement for +/// the hand-written `.core` text fixtures the pre-EAF linker consumed. +fn synth_forms( + name: String, + fname: String, + params: List(String), + body: core_erlang.CExpr, +) -> List(eaf.Form) { + let arity = list.length(params) + forms_of( + CModule(name: name, exports: [FName(fname, arity)], attributes: [], defs: [ + FunDef(FName(fname, arity), CFun(params, body)), + ]), + ) } /// The frozen OTP-ambient allowlist (the DCE stop-set) the linker consumes. @@ -138,12 +163,13 @@ fn rotl_fn() -> ir.Function { /// hot-replaces it and is called — the two must agree. pub fn smoke_differential_numerics_test() { let name = "twocore@link@smoke_num" - let core = gen_core(num_module(name, [clz_fn(), add_fn()])) + let cmod = gen_cmod(num_module(name, [clz_fn(), add_fn()])) + let core = forms_of(cmod) let clz = atom.create("clz") let add = atom.create("add") // in-process path (the oracle): thin module calling the resident rt_num. - let assert Ok(inproc) = build_beam.compile_and_load(core) + let assert Ok(inproc) = build_beam.compile_and_load(cmod) let r_clz1 = catch_apply(inproc, clz, [1]) let r_clz0 = catch_apply(inproc, clz, [0]) let r_add = catch_apply(inproc, add, [2_147_483_647, 1]) @@ -170,7 +196,7 @@ pub fn smoke_differential_numerics_test() { /// merged module is loadable purely by that atom. pub fn returned_atom_matches_beam_name_test() { let name = "twocore@link@atomname" - let core = gen_core(num_module(name, [add_fn()])) + let core = forms_of(gen_cmod(num_module(name, [add_fn()]))) let assert Ok(#(mod, beam)) = beam_link.link_program(core, name, ambient()) // load under the returned atom; the load succeeds only if the atom matches // the name baked into the beam. @@ -192,12 +218,12 @@ pub fn returned_atom_matches_beam_name_test() { /// metering closure). pub fn safe_metered_fun_capture_target_survives_dce_test() { let name = "twocore@link@metered" - let assert Ok(core_text) = - pipeline.ir_to_core( + let assert Ok(cmod) = + pipeline.ir_to_cmod( num_module(name, [clz_fn(), add_fn()]), instance.safe_default(), ) - let core = bit_array.from_string(core_text) + let core = forms_of(cmod) let assert Ok(#(mod, beam)) = beam_link.link_program(core, name, ambient()) let assert Ok(_) = build_beam.load_module(mod, "metered.beam", beam) @@ -256,7 +282,7 @@ pub fn instantiate_root_state_survives_dce_test() { start: option.None, tags: [], ) - let core = gen_core(module) + let core = forms_of(gen_cmod(module)) let assert Ok(#(mod, beam)) = beam_link.link_program(core, name, ambient()) let assert Ok(_) = build_beam.load_module(mod, "stateful.beam", beam) @@ -281,11 +307,14 @@ pub fn fun_capture_is_first_class_test() { let name = "twocore@link@capture" // a generated module that reaches the capture-bearing rt_simd function. let core = - bit_array.from_string( - "module '" - <> name - <> "' ['use'/2]\n attributes []\n'use'/2 =\n fun (A, B) ->\n" - <> " call 'twocore@runtime@rt_simd':'f32x4_add'(A, B)\nend\n", + synth_forms( + name, + "use", + ["A", "B"], + CCall(CAtom("twocore@runtime@rt_simd"), CAtom("f32x4_add"), [ + CVar("A"), + CVar("B"), + ]), ) // (a) it links + loads (no undef anywhere in the closure). @@ -310,7 +339,7 @@ pub fn fun_capture_is_first_class_test() { /// `rt_num` export (e.g. `f64_sqrt`) must NOT appear as a merged def. pub fn dce_drops_unreachable_test() { let name = "twocore@link@dce" - let core = gen_core(num_module(name, [clz_fn(), rotl_fn()])) + let core = forms_of(gen_cmod(num_module(name, [clz_fn(), rotl_fn()]))) let assert Ok(#(_, text)) = beam_link.link_to_core(core, name, ambient()) // i32_clz + i32_rotl ARE reached (defs present). assert string.contains(text, "'twocore@runtime@rt_num__i32_clz'/1 =") @@ -326,7 +355,7 @@ pub fn dce_drops_unreachable_test() { /// were module-granular those three remotes would leak — they must not. pub fn dce_only_remotes_do_not_survive_test() { let name = "twocore@link@ambient" - let core = gen_core(num_module(name, [clz_fn(), add_fn()])) + let core = forms_of(gen_cmod(num_module(name, [clz_fn(), add_fn()]))) let assert Ok(#(_, text)) = beam_link.link_to_core(core, name, ambient()) assert !string.contains(text, "call 'code':") assert !string.contains(text, "call 'net_kernel':") @@ -341,7 +370,7 @@ pub fn dce_only_remotes_do_not_survive_test() { /// diffable/cacheable. pub fn deterministic_output_test() { let name = "twocore@link@determinism" - let core = gen_core(num_module(name, [clz_fn(), add_fn()])) + let core = forms_of(gen_cmod(num_module(name, [clz_fn(), add_fn()]))) let assert Ok(#(_, beam1)) = beam_link.link_program(core, name, ambient()) let assert Ok(#(_, beam2)) = beam_link.link_program(core, name, ambient()) assert beam1 == beam2 @@ -356,11 +385,15 @@ pub fn deterministic_output_test() { pub fn d3a_rejects_erlang_apply_test() { let name = "twocore@link@apply" let core = - bit_array.from_string( - "module '" - <> name - <> "' ['f'/2]\n attributes []\n'f'/2 =\n" - <> " fun (M, A) -> call 'erlang':'apply'(M, 'g', A)\nend\n", + synth_forms( + name, + "f", + ["M", "A"], + CCall(CAtom("erlang"), CAtom("apply"), [ + CVar("M"), + CAtom("g"), + CVar("A"), + ]), ) let assert Error(AmbientAuthorityFound(_)) = beam_link.link_program(core, name, ambient()) @@ -371,12 +404,7 @@ pub fn d3a_rejects_erlang_apply_test() { /// attacker-chosen MFA. The merge succeeds. pub fn d3a_allows_first_class_apply_test() { let name = "twocore@link@firstclass" - let core = - bit_array.from_string( - "module '" - <> name - <> "' ['f'/1]\n attributes []\n'f'/1 =\n fun (F) -> apply F ('x')\nend\n", - ) + let core = synth_forms(name, "f", ["F"], CApplyExpr(CVar("F"), [CAtom("x")])) let assert Ok(_) = beam_link.link_program(core, name, ambient()) } @@ -388,11 +416,11 @@ pub fn d3a_allows_first_class_apply_test() { pub fn fail_closed_missing_module_test() { let name = "twocore@link@missing" let core = - bit_array.from_string( - "module '" - <> name - <> "' ['f'/1]\n attributes []\n'f'/1 =\n" - <> " fun (X) -> call 'twocore@runtime@does_not_exist':'g'(X)\nend\n", + synth_forms( + name, + "f", + ["X"], + CCall(CAtom("twocore@runtime@does_not_exist"), CAtom("g"), [CVar("X")]), ) let assert Error(MissingClosureModule("twocore@runtime@does_not_exist")) = beam_link.link_program(core, name, ambient()) @@ -406,7 +434,7 @@ pub fn fail_closed_missing_module_test() { /// asserts the merged module actually defines the distinctly-mangled names. pub fn mangle_disambiguates_same_named_functions_test() { let name = "twocore@link@mangle" - let core = gen_core(num_module(name, [clz_fn(), rotl_fn()])) + let core = forms_of(gen_cmod(num_module(name, [clz_fn(), rotl_fn()]))) let assert Ok(#(_, text)) = beam_link.link_to_core(core, name, ambient()) // the runtime `i32_rotl` (a still-seamed op) is mangled with its full module atom — never // bare. (`i32.add` is inlined and leaves no seam to observe here.) @@ -424,10 +452,13 @@ pub fn mangle_collision_on_separator_bearing_module_test() { ) let name = "twocore@link@collide" let core = - bit_array.from_string( - "module '" - <> name - <> "' ['f'/1]\n attributes []\n'f'/1 =\n fun (X) -> call 'foo__bar':'g'(X)\nend\n", + synth_forms( + name, + "f", + ["X"], + CCall(CAtom("foo__bar"), CAtom("g"), [ + CVar("X"), + ]), ) let assert Error(MangleCollision(_, _)) = beam_link.link_program(core, name, ambient()) @@ -448,11 +479,11 @@ pub fn unmergeable_on_load_module_test() { ) let name = "twocore@link@onload" let core = - bit_array.from_string( - "module '" - <> name - <> "' ['f'/0]\n attributes []\n'f'/0 =\n" - <> " fun () -> call 'twocore_link_onload_synth':'f'()\nend\n", + synth_forms( + name, + "f", + [], + CCall(CAtom("twocore_link_onload_synth"), CAtom("f"), []), ) let assert Error(UnmergeableConstruct(_)) = beam_link.link_program(core, name, ambient()) @@ -460,12 +491,13 @@ pub fn unmergeable_on_load_module_test() { // ════════════════════ 10. Trust boundary — malformed input never crashes ════════════════════ -/// Trust boundary (fail-closed, D8): a syntactically broken generated `.core` -/// yields `Error(MalformedCore(_))` — never a panic. Captured normally (no -/// `let assert`) so a crash would fail the test. +/// Trust boundary (fail-closed, D8): a semantically broken generated module +/// (a body referencing an unbound variable — rejected by `erl_lint` inside the +/// linker's `to_core` acquisition) yields `Error(MalformedCore(_))` — never a +/// panic. Captured normally (no `let assert`) so a crash would fail the test. pub fn malformed_core_yields_typed_error_test() { - let core = bit_array.from_string("module @@@ not valid core @@@") - let result = beam_link.link_program(core, "whatever", ambient()) + let core = synth_forms("twocore@link@broken", "f", [], CVar("Unbound")) + let result = beam_link.link_program(core, "twocore@link@broken", ambient()) let assert Error(MalformedCore(_)) = result } @@ -473,7 +505,7 @@ pub fn malformed_core_yields_typed_error_test() { /// the requested `module_name` is rejected as `MalformedCore` (the returned atom /// must be trustworthy for P11-05's `code:which`). pub fn declared_name_mismatch_yields_typed_error_test() { - let core = gen_core(num_module("twocore@link@actual", [add_fn()])) + let core = forms_of(gen_cmod(num_module("twocore@link@actual", [add_fn()]))) let assert Error(MalformedCore(_)) = beam_link.link_program(core, "twocore@link@requested", ambient()) } diff --git a/test/twocore/backend/bindings_compile_call_test.gleam b/test/twocore/backend/bindings_compile_call_test.gleam index 3eff99ae..5175c33f 100644 --- a/test/twocore/backend/bindings_compile_call_test.gleam +++ b/test/twocore/backend/bindings_compile_call_test.gleam @@ -41,7 +41,6 @@ //// in-tree and always run. Because the Erlang and Elixir bindings present WASM values as the same //// BEAM terms, ONE differential body (`run_beam`) drives both. -import gleam/bit_array import gleam/dynamic.{type Dynamic} import gleam/erlang/atom.{type Atom} import gleam/erlang/process.{type Pid} @@ -53,7 +52,6 @@ import gleam/string import simplifile import twocore/backend/bindings import twocore/backend/build_beam -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/backend/emit_elixir_bindings import twocore/backend/emit_erlang_bindings @@ -324,14 +322,12 @@ fn setup() -> #(String, String, Oracle) { // Compile the matrix module; load its code AND get an Int-oracle instance process. let assert Ok(cm) = emit_core.emit_module(m, profiles.portable()) - let core = core_printer.print_module(cm) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(beam) = pipeline.cmod_to_beam(cm) let assert Ok(int_proc) = pipeline.instantiate(beam, m.name) // Load the stateless module's code (its binding dispatches into it; no oracle proc needed). let assert Ok(cmp) = emit_core.emit_module(mp, profiles.portable()) - let core_p = core_printer.print_module(cmp) - let assert Ok(_) = build_beam.compile_and_load(bit_array.from_string(core_p)) + let assert Ok(_) = build_beam.compile_and_load(cmp) // The term-ABI oracle process (for v128 / multi-value / externref — the non-Int rows). let assert Ok(term_proc) = start_instance(atom.create(m.name)) @@ -1152,8 +1148,7 @@ pub fn deterministic_emit_test() { pub fn folder_driver_shipped_path_test() { let m = matrix_module() let assert Ok(cm) = emit_core.emit_module(m, profiles.portable()) - let assert Ok(beam) = - pipeline.core_to_beam(core_printer.print_module(cm), m.name) + let assert Ok(beam) = pipeline.cmod_to_beam(cm) let dir_a = scratch_dir("a") let langs = [bindings.Gleam, bindings.Erlang, bindings.Elixir] diff --git a/test/twocore/backend/bindings_driver_test.gleam b/test/twocore/backend/bindings_driver_test.gleam index 741abf97..3ff8b6a7 100644 --- a/test/twocore/backend/bindings_driver_test.gleam +++ b/test/twocore/backend/bindings_driver_test.gleam @@ -128,8 +128,8 @@ pub fn default_off_byte_identical_test() { let assert Ok(m) = pipeline.source_to_ir(bytes) let assert Ok(binding) = twocore.resolve_binding(profiles.safe(), False, False, None, None, None) - let assert Ok(core) = pipeline.ir_to_core(m, binding) - let assert Ok(oracle_beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, binding) + let assert Ok(oracle_beam) = pipeline.cmod_to_beam(cmod) assert cli_beam == oracle_beam let _ = simplifile.delete(out) @@ -288,7 +288,7 @@ pub fn r17_lower_once_seam_test() { let assert Ok(m) = pipeline.source_to_ir(bytes) // The seam: the module `describe` sees IS the one the `.core` (hence `.beam`) is generated from. - let assert Ok(#(lowered, _core)) = pipeline.ir_to_lowered_core(m, binding) + let assert Ok(#(lowered, _cmod)) = pipeline.ir_to_lowered_cmod(m, binding) let assert Ok(desc) = iface.describe(lowered, binding) // Memory-mutating exports → Threaded, every export state-reaching. @@ -329,8 +329,8 @@ pub fn folder_emission_and_beam_non_perturbation_test() { let binding = threaded_binding() let assert Ok(bytes) = simplifile.read_bits(wasm) let assert Ok(m) = pipeline.source_to_ir(bytes) - let assert Ok(#(lowered, core)) = pipeline.ir_to_lowered_core(m, binding) - let assert Ok(oracle_beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(#(lowered, cmod)) = pipeline.ir_to_lowered_cmod(m, binding) + let assert Ok(oracle_beam) = pipeline.cmod_to_beam(cmod) assert folder_beam == oracle_beam // Each companion file's content EQUALS the emitter's output for the same descriptor. @@ -359,8 +359,8 @@ pub fn emit_bindings_deterministic_test() { let binding = threaded_binding() let assert Ok(bytes) = simplifile.read_bits(wasm) let assert Ok(m) = pipeline.source_to_ir(bytes) - let assert Ok(#(lowered, core)) = pipeline.ir_to_lowered_core(m, binding) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(#(lowered, cmod)) = pipeline.ir_to_lowered_cmod(m, binding) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) let d1 = "build/p12_det_1" let d2 = "build/p12_det_2" diff --git a/test/twocore/backend/boxing_test.gleam b/test/twocore/backend/boxing_test.gleam index f6566614..a67b1f7f 100644 --- a/test/twocore/backend/boxing_test.gleam +++ b/test/twocore/backend/boxing_test.gleam @@ -20,13 +20,11 @@ //// The harness (`load`/`module`/`fnN`/`catch_apply_dyn`/`to_dynamic`) mirrors //// `term_ops_test.gleam`. -import gleam/bit_array import gleam/dynamic.{type Dynamic} import gleam/erlang/atom.{type Atom} import gleam/list import gleam/option import twocore/backend/build_beam -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/ir import twocore/runtime/instance @@ -61,8 +59,7 @@ fn to_dynamic(x: a) -> Dynamic /// genuine test failure. fn load(module: ir.Module) -> Atom { let assert Ok(cm) = emit_core.emit_module(module, instance.safe_default()) - let core = core_printer.print_module(cm) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(mod) = build_beam.compile_and_load(cm) mod } diff --git a/test/twocore/backend/build_beam_test.gleam b/test/twocore/backend/build_beam_test.gleam index 614d2f89..c9d499c7 100644 --- a/test/twocore/backend/build_beam_test.gleam +++ b/test/twocore/backend/build_beam_test.gleam @@ -1,26 +1,30 @@ //// Tests for the `build_beam` driver + the «FFI-SHIM» (Unit 04). //// -//// These assert against the *defined behavior* of Core Erlang and the Erlang -//// compiler, and against ordinary integer arithmetic — NOT against whatever -//// bytes the compiler happens to emit (no change-detector tests, D8). `5` is -//// asserted for `add(2, 3)` because integer addition says so; the `.beam` byte -//// layout is never inspected. +//// These assert against the *defined behavior* of the Erlang Abstract Format +//// and the Erlang compiler, and against ordinary integer arithmetic — NOT +//// against whatever bytes the compiler happens to emit (no change-detector +//// tests, D8). `5` is asserted for `add(2, 3)` because integer addition says +//// so; the `.beam` byte layout is never inspected. //// -//// Canonical references: the Core Erlang language specification (Core Erlang -//// 1.0.3) and the OTP `compiler` application docs. This unit is what proves -//// high-level §9.2 (compiled output is real, preemptible BEAM code) and D10 -//// (it loads into and runs in the current VM). +//// Canonical references: the Erlang Abstract Format reference +//// (`erts/absform`) and the OTP `compiler` application docs. This unit is what +//// proves high-level §9.2 (compiled output is real, preemptible BEAM code) and +//// D10 (it loads into and runs in the current VM). //// -//// The `.core` fixtures are hand-authored for readability and cross-checked -//// against the canonical OTP-29 shape emitted by `erlc +to_core`. They are -//// embedded as string constants (committed under `test/`), keeping the test -//// self-contained with no file-reading dependency. +//// The fixtures are hand-built backend `CModule` values (the same AST +//// `emit_core` produces), lowered to abstract forms by `eaf.module_forms` +//// inside `build_beam` — keeping the tests self-contained with no file-reading +//// dependency and exercising the exact seam the pipeline compiles through. import gleam/bit_array import gleam/erlang/atom.{type Atom} import gleam/list import gleam/string import twocore/backend/build_beam.{CompileFailed} +import twocore/backend/core_erlang.{ + type CModule, CApply, CAtom, CCall, CCase, CClause, CFun, CInt, CModule, CVar, + FName, FunDef, PVar, +} // ───────────────────────── test-only externals ───────────────────────── // @@ -38,75 +42,82 @@ fn apply_atom(module: Atom, function: Atom, args: List(Int)) -> Atom // ───────────────────────────── fixtures ───────────────────────────── -/// A valid hand-written `.core` module: `id/1`, `add/2` (via `erlang:'+'`), and -/// `classify/1` (a `case` with two guards + a catch-all). Module name is +/// A valid hand-built module: `id/1`, `add/2` (via `erlang:'+'`), and +/// `classify/1` (a `case` with two guard clauses + a catch-all). Module name is /// `twocore@test@fixture` — `twocore@…`-namespaced so it cannot clobber OTP. -const fixture_core: String = "module 'twocore@test@fixture' ['add'/2, 'classify'/1, 'id'/1] - attributes [] - -'id'/1 = - fun (X) -> X - -'add'/2 = - fun (A, B) -> - call 'erlang':'+'(A, B) - -'classify'/1 = - fun (N) -> - case N of - when call 'erlang':'=<'(X, 0) -> - 'zero_or_neg' - when call 'erlang':'<'(X, 10) -> - 'small' - <_X> when 'true' -> - 'big' - end -end -" - -/// First version of a hot-swappable module `twocore@test@hotswap`: `val/0` -/// returns `1`. Loaded then replaced by `hotswap_v2_core` in the hot-replace -/// test to prove the loaded code is genuinely resident (D10), not a static -/// artifact. -const hotswap_v1_core: String = "module 'twocore@test@hotswap' ['val'/0] - attributes [] - -'val'/0 = - fun () -> 1 -end -" - -/// Second version of `twocore@test@hotswap`: `val/0` returns `2`. Same module -/// name as v1, so loading it hot-replaces v1. -const hotswap_v2_core: String = "module 'twocore@test@hotswap' ['val'/0] - attributes [] - -'val'/0 = - fun () -> 2 -end -" - -/// Syntactically broken `.core` (stray `@@@` tokens) — exercises the -/// `core_parse` error path. -const broken_syntax_core: String = "module 'twocore@test@broken' ['oops'/0] - attributes [] -'oops'/0 = - fun () -> @@@ not valid @@@ -" - -/// Semantically broken `.core`: calls an undefined local function `missing/0`. -/// Scans and parses fine, but `compile:forms` rejects it — exercises the -/// `compile` error path (a different error shape than `core_parse`). -const broken_semantic_core: String = "module 'twocore@test@badsem' ['go'/0] - attributes [] -'go'/0 = - fun () -> apply 'missing'/0 () -end -" - -/// Helper: `.core` source string → byte-aligned `BitArray` for `compile_core`. -fn core(src: String) -> BitArray { - bit_array.from_string(src) +fn fixture_module() -> CModule { + CModule( + name: "twocore@test@fixture", + exports: [FName("add", 2), FName("classify", 1), FName("id", 1)], + attributes: [], + defs: [ + FunDef(FName("id", 1), CFun(["X"], CVar("X"))), + FunDef( + FName("add", 2), + CFun( + ["A", "B"], + CCall(CAtom("erlang"), CAtom("+"), [CVar("A"), CVar("B")]), + ), + ), + FunDef( + FName("classify", 1), + CFun( + ["N"], + CCase(CVar("N"), [ + CClause( + [PVar("X")], + CCall(CAtom("erlang"), CAtom("=<"), [CVar("X"), CInt(0)]), + CAtom("zero_or_neg"), + ), + CClause( + [PVar("Y")], + CCall(CAtom("erlang"), CAtom("<"), [CVar("Y"), CInt(10)]), + CAtom("small"), + ), + CClause([PVar("W")], CAtom("true"), CAtom("big")), + ]), + ), + ), + ], + ) +} + +/// One version of a hot-swappable module `twocore@test@hotswap`: `val/0` +/// returns `value`. Loaded twice with different values in the hot-replace test +/// to prove the loaded code is genuinely resident (D10), not a static artifact. +fn hotswap_module(value: Int) -> CModule { + CModule( + name: "twocore@test@hotswap", + exports: [FName("val", 0)], + attributes: [], + defs: [FunDef(FName("val", 0), CFun([], CInt(value)))], + ) +} + +/// A structurally broken module: `oops/0`'s body references an UNBOUND +/// variable. The AST lowers to forms fine, but `erl_lint` inside +/// `compile:forms` rejects it — exercising the compiler's error path (an +/// unbound variable is the forms-level analog of the old scanner's `@@@`). +fn broken_unbound_module() -> CModule { + CModule( + name: "twocore@test@broken", + exports: [FName("oops", 0)], + attributes: [], + defs: [FunDef(FName("oops", 0), CFun([], CVar("never_bound")))], + ) +} + +/// A semantically broken module: `go/0` applies an undefined local function +/// `missing/0`. Lowers fine, but `compile:forms` rejects it (undefined +/// function) — a differently-shaped compiler diagnostic than the unbound-var +/// case. +fn broken_semantic_module() -> CModule { + CModule( + name: "twocore@test@badsem", + exports: [FName("go", 0)], + attributes: [], + defs: [FunDef(FName("go", 0), CFun([], CApply(FName("missing", 0), [])))], + ) } // ───────────────────── 1. happy path, numeric assertion ───────────────────── @@ -114,11 +125,11 @@ fn core(src: String) -> BitArray { /// Compiling, loading, and running the valid fixture must yield the spec-defined /// arithmetic / guard results: `add(2,3) == 5`, `id(42) == 42`, and each /// `classify` input lands in the documented guard arm. Proves the full -/// text → `.beam` → loaded → `apply` seam (D10, §9.2). +/// AST → forms → `.beam` → loaded → `apply` seam (D10, §9.2). pub fn compile_load_run_happy_path_test() { - let assert Ok(mod) = build_beam.compile_and_load(core(fixture_core)) + let assert Ok(mod) = build_beam.compile_and_load(fixture_module()) - // The loaded module name comes from the `.core` `module` header. + // The loaded module name comes from the `-module` attribute. assert atom.to_string(mod) == "twocore@test@fixture" // add/2 is integer addition; 2 + 3 is 5. @@ -138,12 +149,13 @@ pub fn compile_load_run_happy_path_test() { // ───────────────── 2. malformed input → typed Error, no crash ───────────────── -/// Syntactically broken `.core` must produce `Error(CompileFailed(lines))` with -/// non-empty, human-readable `": "` lines — never a panic -/// (fail-closed, D4). The result is captured normally (no `let assert`), so a -/// panic would fail the test rather than be silently caught. -pub fn malformed_syntax_yields_typed_error_test() { - let result = build_beam.compile_core(core(broken_syntax_core)) +/// A module whose body references an unbound variable must produce +/// `Error(CompileFailed(lines))` with non-empty, human-readable +/// `": "` lines — never a panic (fail-closed, D4). The result is +/// captured normally (no `let assert`), so a panic would fail the test rather +/// than be silently caught. +pub fn malformed_unbound_yields_typed_error_test() { + let result = build_beam.compile_module(broken_unbound_module()) let assert Error(CompileFailed(lines)) = result assert lines != [] @@ -151,13 +163,12 @@ pub fn malformed_syntax_yields_typed_error_test() { assert list.all(lines, fn(l) { l != "" && string.contains(l, ": ") }) } -/// Semantically broken `.core` (a call to an undefined function) scans and -/// parses but is rejected by `compile:forms`. It must ALSO surface as +/// A module applying an undefined local function lowers to forms but is +/// rejected by `compile:forms`. It must ALSO surface as /// `Error(CompileFailed(lines))` with non-empty lines — proving the shim -/// normalizes the (differently-shaped) `compile` errors into the same flat list -/// as the parse errors. No panic. +/// normalizes every compiler diagnostic into the same flat list. No panic. pub fn malformed_semantic_yields_typed_error_test() { - let result = build_beam.compile_core(core(broken_semantic_core)) + let result = build_beam.compile_module(broken_semantic_module()) let assert Error(CompileFailed(lines)) = result assert lines != [] @@ -167,21 +178,21 @@ pub fn malformed_semantic_yields_typed_error_test() { // ───────────────────────── 3. FFI shape validation ───────────────────────── /// Trust-boundary check (Gleam cannot type-check the `.erl` return): on success -/// `compile_core` must hand back the expected module atom AND a non-empty `beam` -/// binary. +/// `compile_module` must hand back the expected module atom AND a non-empty +/// `beam` binary. pub fn ffi_success_shape_test() { - let assert Ok(#(mod, beam)) = build_beam.compile_core(core(fixture_core)) + let assert Ok(#(mod, beam)) = build_beam.compile_module(fixture_module()) assert atom.to_string(mod) == "twocore@test@fixture" // A real `.beam` binary is non-empty. assert bit_array.byte_size(beam) > 0 } -/// Trust-boundary check: on failure `compile_core` must hand back a non-empty +/// Trust-boundary check: on failure `compile_module` must hand back a non-empty /// `List(String)` (every element a non-empty string), regardless of which stage /// failed. pub fn ffi_failure_shape_test() { let assert Error(CompileFailed(lines)) = - build_beam.compile_core(core(broken_syntax_core)) + build_beam.compile_module(broken_unbound_module()) assert lines != [] assert list.all(lines, fn(l) { string.length(l) > 0 }) } @@ -192,30 +203,30 @@ pub fn ffi_failure_shape_test() { /// load must hot-replace the first — demonstrating that the loaded module is /// genuinely resident BEAM code, not a static artifact. After loading v1, /// `val/0` returns `1`; after loading v2 (same module name), `val/0` returns -/// `2`. (Core Erlang / OTP code-loading semantics: `code:load_binary` replaces -/// current code with the new version.) +/// `2`. (OTP code-loading semantics: `code:load_binary` replaces current code +/// with the new version.) pub fn hot_replace_resident_module_test() { let val = atom.create("val") - let assert Ok(mod1) = build_beam.compile_and_load(core(hotswap_v1_core)) + let assert Ok(mod1) = build_beam.compile_and_load(hotswap_module(1)) assert atom.to_string(mod1) == "twocore@test@hotswap" assert apply_int(mod1, val, []) == 1 - let assert Ok(mod2) = build_beam.compile_and_load(core(hotswap_v2_core)) + let assert Ok(mod2) = build_beam.compile_and_load(hotswap_module(2)) assert atom.to_string(mod2) == "twocore@test@hotswap" // The resident module was hot-replaced: the export now returns v2's value. assert apply_int(mod2, val, []) == 2 } -// ───────────────── split-surface: compile_core then load_module ───────────────── +// ───────────────── split-surface: compile_module then load_module ───────────────── -/// `compile_core` and `load_module` compose: compiling separately, then loading -/// the returned binary under its own module atom, yields a callable module — -/// the same outcome as `compile_and_load`, but via the granular two-step API. -/// `load_module` returns the module atom on success. +/// `compile_module` and `load_module` compose: compiling separately, then +/// loading the returned binary under its own module atom, yields a callable +/// module — the same outcome as `compile_and_load`, but via the granular +/// two-step API. `load_module` returns the module atom on success. pub fn split_compile_then_load_test() { - let assert Ok(#(mod, beam)) = build_beam.compile_core(core(fixture_core)) - let assert Ok(loaded) = build_beam.load_module(mod, "fixture.core", beam) + let assert Ok(#(mod, beam)) = build_beam.compile_module(fixture_module()) + let assert Ok(loaded) = build_beam.load_module(mod, "fixture.forms", beam) assert loaded == mod assert apply_int(loaded, atom.create("add"), [40, 2]) == 42 } diff --git a/test/twocore/backend/closures_test.gleam b/test/twocore/backend/closures_test.gleam index 0a757d83..8e07b65f 100644 --- a/test/twocore/backend/closures_test.gleam +++ b/test/twocore/backend/closures_test.gleam @@ -13,13 +13,11 @@ //// whatever the current code happens to print. The harness (`load`/`module`) mirrors //// `term_ops_test.gleam` / `emit_core_e2e_test.gleam`. -import gleam/bit_array import gleam/dynamic.{type Dynamic} import gleam/erlang/atom.{type Atom} import gleam/list import gleam/option import twocore/backend/build_beam -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/ir import twocore/runtime/instance @@ -52,8 +50,7 @@ fn to_dynamic(x: a) -> Dynamic /// genuine test failure. fn load(module: ir.Module) -> Atom { let assert Ok(cm) = emit_core.emit_module(module, instance.safe_default()) - let core = core_printer.print_module(cm) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(mod) = build_beam.compile_and_load(cm) mod } diff --git a/test/twocore/backend/core_printer_test.gleam b/test/twocore/backend/core_printer_test.gleam index 28871de3..3cf4f4dd 100644 --- a/test/twocore/backend/core_printer_test.gleam +++ b/test/twocore/backend/core_printer_test.gleam @@ -22,7 +22,6 @@ //// `compiler` app (`core_scan`/`core_parse`/`core_lint`, `core_pp.erl`, //// `io_lib:write_string/2`). -import gleam/bit_array import gleam/dynamic.{type Dynamic} import gleam/erlang/atom.{type Atom} import gleam/list @@ -350,10 +349,7 @@ pub fn integration_add_compiles_loads_runs_test() { ), ), ]) - let assert Ok(mod) = - build_beam.compile_and_load( - bit_array.from_string(core_printer.print_module(m)), - ) + let assert Ok(mod) = build_beam.compile_and_load(m) assert atom.to_string(mod) == "twocore@test@printer_add" let add = atom.create("add") assert apply_int(mod, add, [2, 3]) == 5 @@ -390,10 +386,7 @@ pub fn integration_factorial_recursion_test() { CModule("twocore@test@printer_fac", [FName("fac", 1)], [], [ FunDef(FName("fac", 1), CFun(["n"], body)), ]) - let assert Ok(mod) = - build_beam.compile_and_load( - bit_array.from_string(core_printer.print_module(m)), - ) + let assert Ok(mod) = build_beam.compile_and_load(m) let fac = atom.create("fac") assert apply_int(mod, fac, [0]) == 1 assert apply_int(mod, fac, [5]) == 120 @@ -423,10 +416,7 @@ pub fn integration_classify_guards_test() { CModule("twocore@test@printer_classify", [FName("classify", 1)], [], [ FunDef(FName("classify", 1), CFun(["n"], body)), ]) - let assert Ok(mod) = - build_beam.compile_and_load( - bit_array.from_string(core_printer.print_module(m)), - ) + let assert Ok(mod) = build_beam.compile_and_load(m) let c = atom.create("classify") assert apply_atom(mod, c, [-3]) == atom.create("zero_or_neg") assert apply_atom(mod, c, [0]) == atom.create("zero_or_neg") diff --git a/test/twocore/backend/emit_core_e2e_test.gleam b/test/twocore/backend/emit_core_e2e_test.gleam index a79f6b3e..b035738c 100644 --- a/test/twocore/backend/emit_core_e2e_test.gleam +++ b/test/twocore/backend/emit_core_e2e_test.gleam @@ -8,14 +8,12 @@ //// two's-complement wrap, shift-count masking, the div/rem zero & signed-overflow traps, //// the deny-all capability boundary, and the resolved `own` stdlib. -import gleam/bit_array import gleam/dynamic.{type Dynamic} import gleam/erlang/atom.{type Atom} import gleam/list import gleam/option import gleam/string import twocore/backend/build_beam -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/ir import twocore/pipeline @@ -54,8 +52,7 @@ fn to_dynamic(x: a) -> Dynamic /// emit/compile/load is a genuine test failure, not an expected path. fn load(module: ir.Module) -> Atom { let assert Ok(cm) = emit_core.emit_module(module, instance.safe_default()) - let core = core_printer.print_module(cm) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(mod) = build_beam.compile_and_load(cm) mod } @@ -844,8 +841,7 @@ fn threaded_binding() -> instance.Binding { /// Emit `module` under `Threaded` to Core text, compile it, and load it; return the module atom. fn load_threaded(module: ir.Module) -> Atom { let assert Ok(cm) = emit_core.emit_module(module, threaded_binding()) - let core = core_printer.print_module(cm) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(mod) = build_beam.compile_and_load(cm) mod } @@ -1938,8 +1934,7 @@ pub fn simd_extract_replace_lane_e2e_test() { /// would exceed the default budget — an orthogonal concern to the memory64 correctness under test). fn load_binding(module: ir.Module, binding: instance.Binding) -> Atom { let assert Ok(cm) = emit_core.emit_module(module, binding) - let core = core_printer.print_module(cm) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(mod) = build_beam.compile_and_load(cm) mod } @@ -2380,9 +2375,7 @@ fn eh_module_full( /// an uncaught `{wasm_exn,…}` from a trap — T8). `let assert` is the test's success contract. fn to_beam(module: ir.Module) -> BitArray { let assert Ok(cm) = emit_core.emit_module(module, instance.safe_default()) - let core = core_printer.print_module(cm) - let assert Ok(#(_atom, beam)) = - build_beam.compile_core(bit_array.from_string(core)) + let assert Ok(#(_atom, beam)) = build_beam.compile_module(cm) beam } diff --git a/test/twocore/backend/emit_core_tailcall_test.gleam b/test/twocore/backend/emit_core_tailcall_test.gleam index 7fa32675..15b3306d 100644 --- a/test/twocore/backend/emit_core_tailcall_test.gleam +++ b/test/twocore/backend/emit_core_tailcall_test.gleam @@ -17,7 +17,6 @@ //// fire in order, an imported `return_call` is value-correct, and an even/odd program compiles + //// runs correctly under the AGGRESSIVE inliner (proving the inliner excludes tail-call bodies). -import gleam/bit_array import gleam/dynamic.{type Dynamic} import gleam/erlang/atom.{type Atom} import gleam/list @@ -30,7 +29,6 @@ import twocore/backend/core_erlang.{ CCons, CFun, CInt, CLet, CNil, CTuple, CVar, FName, FunDef, PAtom, PTuple, PVar, } -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/ir import twocore/middle/ir_opt @@ -115,8 +113,7 @@ fn body_of(module: ir.Module, b: instance.Binding, name: String) -> CExpr { /// Emit `module` (under `b`), compile it, and load it; return the loaded module atom. fn load(module: ir.Module, b: instance.Binding) -> Atom { let assert Ok(cm) = emit_core.emit_module(module, b) - let core = core_printer.print_module(cm) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(mod) = build_beam.compile_and_load(cm) mod } diff --git a/test/twocore/backend/emit_elixir_bindings_test.gleam b/test/twocore/backend/emit_elixir_bindings_test.gleam index 086c6e75..bfe773b5 100644 --- a/test/twocore/backend/emit_elixir_bindings_test.gleam +++ b/test/twocore/backend/emit_elixir_bindings_test.gleam @@ -34,7 +34,6 @@ import gleam/list import gleam/option import gleam/string import twocore/backend/build_beam -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/backend/emit_elixir_bindings import twocore/backend/iface @@ -180,14 +179,10 @@ fn module_b() -> ir.Module { /// Returns the loaded module atom (= `m.name`). A `let assert` here is the test's success contract. fn load(m: ir.Module) -> Atom { let assert Ok(cm) = emit_core.emit_module(m, profiles.portable()) - let core = core_printer.print_module(cm) - let assert Ok(mod) = build_beam.compile_and_load(string_to_bits(core)) + let assert Ok(mod) = build_beam.compile_and_load(cm) mod } -@external(erlang, "gleam_stdlib", "identity") -fn string_to_bits(s: String) -> BitArray - // The raw run-ABI oracle (`test/twocore_threaded_test_ffi.erl`): drive `instantiate/0` and an // export DIRECTLY (leading `St`, `{Package, St'}` return), bypassing the binding — the in-process // pipeline truth the compiled binding must match. diff --git a/test/twocore/backend/emit_erlang_bindings_test.gleam b/test/twocore/backend/emit_erlang_bindings_test.gleam index b71465bd..069f1248 100644 --- a/test/twocore/backend/emit_erlang_bindings_test.gleam +++ b/test/twocore/backend/emit_erlang_bindings_test.gleam @@ -26,7 +26,6 @@ import gleam/list import gleam/option import gleam/string import twocore/backend/build_beam -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/backend/emit_erlang_bindings import twocore/backend/iface @@ -172,14 +171,10 @@ fn module_b() -> ir.Module { /// Returns the loaded module atom (= `m.name`). A `let assert` here is the test's success contract. fn load(m: ir.Module) -> Atom { let assert Ok(cm) = emit_core.emit_module(m, profiles.portable()) - let core = core_printer.print_module(cm) - let assert Ok(mod) = build_beam.compile_and_load(string_to_bits(core)) + let assert Ok(mod) = build_beam.compile_and_load(cm) mod } -@external(erlang, "gleam_stdlib", "identity") -fn string_to_bits(s: String) -> BitArray - // The raw run-ABI oracle (`test/twocore_threaded_test_ffi.erl`): drive `instantiate/0` and an // export DIRECTLY (leading `St`, `{Package, St'}` return), bypassing the binding — the in-process // pipeline truth the compiled binding must match. diff --git a/test/twocore/backend/emit_gleam_bindings_test.gleam b/test/twocore/backend/emit_gleam_bindings_test.gleam index aeddfb5e..da5852a9 100644 --- a/test/twocore/backend/emit_gleam_bindings_test.gleam +++ b/test/twocore/backend/emit_gleam_bindings_test.gleam @@ -15,14 +15,12 @@ //// The pure unit tests (no toolchain) pin determinism, the three-file drop, the no-`.beam`-atom- //// collision module name, and the Stateless vs Threaded surface shapes (R19). -import gleam/bit_array import gleam/dynamic.{type Dynamic} import gleam/erlang/atom.{type Atom} import gleam/list import gleam/option import gleam/string import twocore/backend/build_beam -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/backend/emit_gleam_bindings import twocore/backend/iface @@ -168,8 +166,7 @@ fn module_b() -> ir.Module { /// Returns the loaded module atom (= `m.name`). A `let assert` here is the test's success contract. fn load(m: ir.Module) -> Atom { let assert Ok(cm) = emit_core.emit_module(m, profiles.portable()) - let core = core_printer.print_module(cm) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(mod) = build_beam.compile_and_load(cm) mod } diff --git a/test/twocore/backend/emit_trust_memory_test.gleam b/test/twocore/backend/emit_trust_memory_test.gleam index f523e001..6dfc5a23 100644 --- a/test/twocore/backend/emit_trust_memory_test.gleam +++ b/test/twocore/backend/emit_trust_memory_test.gleam @@ -179,8 +179,8 @@ fn run( args: List(Int), ) -> pipeline.RunResult { let m = rt_module() - let assert Ok(c) = pipeline.ir_to_core(m, binding) - let assert Ok(beam) = pipeline.core_to_beam(c, m.name) + let assert Ok(c) = pipeline.ir_to_cmod(m, binding) + let assert Ok(beam) = pipeline.cmod_to_beam(c) let assert Ok(proc) = pipeline.instantiate(beam, m.name) let out = pipeline.invoke_instance(proc, export, args) pipeline.stop_instance(proc) diff --git a/test/twocore/backend/emit_unchecked_test.gleam b/test/twocore/backend/emit_unchecked_test.gleam index 37384734..21e090c5 100644 --- a/test/twocore/backend/emit_unchecked_test.gleam +++ b/test/twocore/backend/emit_unchecked_test.gleam @@ -194,8 +194,8 @@ fn run_module( export: String, args: List(Int), ) -> pipeline.RunResult { - let assert Ok(c) = pipeline.ir_to_core(m, binding) - let assert Ok(beam) = pipeline.core_to_beam(c, m.name) + let assert Ok(c) = pipeline.ir_to_cmod(m, binding) + let assert Ok(beam) = pipeline.cmod_to_beam(c) let assert Ok(proc) = pipeline.instantiate(beam, m.name) let out = pipeline.invoke_instance(proc, export, args) pipeline.stop_instance(proc) diff --git a/test/twocore/backend/fastpath_test.gleam b/test/twocore/backend/fastpath_test.gleam index 4543010b..40358078 100644 --- a/test/twocore/backend/fastpath_test.gleam +++ b/test/twocore/backend/fastpath_test.gleam @@ -17,13 +17,11 @@ //// path. The harness (`load`/`module`/`fnN`/`catch_apply_dyn`/`to_dynamic`) mirrors //// `term_ops_test.gleam` + `rt_js_boundary_test.gleam`. -import gleam/bit_array import gleam/dynamic.{type Dynamic} import gleam/erlang/atom.{type Atom} import gleam/list import gleam/option import twocore/backend/build_beam -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/ir import twocore/runtime/instance @@ -50,8 +48,7 @@ fn to_dynamic(x: a) -> Dynamic /// test VM; return the loaded module atom. `let assert` is the success contract. fn load(module: ir.Module) -> Atom { let assert Ok(cm) = emit_core.emit_module(module, instance.safe_default()) - let core = core_printer.print_module(cm) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(mod) = build_beam.compile_and_load(cm) mod } diff --git a/test/twocore/backend/inline_joins_test.gleam b/test/twocore/backend/inline_joins_test.gleam index 48180f6b..a8b0932c 100644 --- a/test/twocore/backend/inline_joins_test.gleam +++ b/test/twocore/backend/inline_joins_test.gleam @@ -17,7 +17,6 @@ //// that survives even under `True`; and a small module compiles + loads + RUNS to the spec-correct //// value under `inline_joins: True` (proving the transform preserves semantics through real codegen). -import gleam/bit_array import gleam/erlang/atom.{type Atom} import gleam/list import gleam/option @@ -26,7 +25,6 @@ import twocore/backend/core_erlang.{ type CExpr, type CModule, CApply, CAtom, CCase, CClause, CFun, CInt, CLet, CLetrec, CModule, CTry, CTuple, CVar, FName, FunDef, PInt, PVar, } -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/ir import twocore/runtime/instance @@ -122,8 +120,7 @@ fn module(name: String, functions: List(ir.Function)) -> ir.Module { /// Emit `module` under `binding`, compile it, and load it; return the loaded module atom. fn load(module: ir.Module, binding: instance.Binding) -> Atom { let assert Ok(cm) = emit_core.emit_module(module, binding) - let core = core_printer.print_module(cm) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(mod) = build_beam.compile_and_load(cm) mod } diff --git a/test/twocore/backend/lazy_mask_test.gleam b/test/twocore/backend/lazy_mask_test.gleam index 76b3ba57..6025fd4e 100644 --- a/test/twocore/backend/lazy_mask_test.gleam +++ b/test/twocore/backend/lazy_mask_test.gleam @@ -12,7 +12,6 @@ //// in one bottom-up pass. //// - **Gating** — OFF by default (byte-identical Core); ON only under `lazy_mask`. -import gleam/bit_array import gleam/erlang/atom.{type Atom} import gleam/list import twocore/backend/build_beam @@ -20,7 +19,6 @@ import twocore/backend/core_erlang.{ type CExpr, type CModule, CApply, CAtom, CCall, CFun, CInt, CModule, CVar, FName, FunDef, } -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/runtime/instance import twocore/runtime/profiles @@ -175,7 +173,6 @@ fn mask_mod(name: String, body: CExpr) -> CModule { /// Print, compile, and load `cm`; return the loaded module atom. fn load(cm: CModule) -> Atom { - let core = core_printer.print_module(cm) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(mod) = build_beam.compile_and_load(cm) mod } diff --git a/test/twocore/backend/link_manifest_test.gleam b/test/twocore/backend/link_manifest_test.gleam index e7619a7f..d54a253d 100644 --- a/test/twocore/backend/link_manifest_test.gleam +++ b/test/twocore/backend/link_manifest_test.gleam @@ -85,10 +85,10 @@ pub fn mangle_injective_rejects_double_underscore_test() { // R1 — the Core-acquisition rule. // --------------------------------------------------------------------------- -/// R1: the generated module uses `GeneratedCoreText`; every discovered in-closure module uses the +/// R1: the generated module uses `GeneratedAbstractForms`; every discovered in-closure module uses the /// uniform `ResidentBeamCore` primary; the single fallback is `CompileFileToCore`. pub fn acquisition_rule_test() { - assert m.primary_acquisition(True) == m.GeneratedCoreText + assert m.primary_acquisition(True) == m.GeneratedAbstractForms assert m.primary_acquisition(False) == m.ResidentBeamCore assert m.fallback_acquisition() == m.CompileFileToCore } diff --git a/test/twocore/backend/linked_selfcontained_test.gleam b/test/twocore/backend/linked_selfcontained_test.gleam index a10523b2..7549fa12 100644 --- a/test/twocore/backend/linked_selfcontained_test.gleam +++ b/test/twocore/backend/linked_selfcontained_test.gleam @@ -33,7 +33,6 @@ //// bounds/traps . Every value //// is bit-pattern-compared (D5/D7); every trap via `runner.trap_matches` / raw-reason identity. -import gleam/bit_array import gleam/dict import gleam/dynamic.{type Dynamic} import gleam/erlang/atom.{type Atom} @@ -50,6 +49,9 @@ import twocore/backend/beam_link.{ UnmergeableConstruct, } import twocore/backend/build_beam +import twocore/backend/core_erlang +import twocore/backend/core_printer +import twocore/backend/eaf import twocore/backend/link_manifest import twocore/conformance/driver import twocore/conformance/ffi @@ -113,6 +115,13 @@ fn ambient() -> List(String) { link_manifest.ambient_allowlist() } +/// Lower a backend `CModule` to the abstract FORMS the linker consumes +/// (`let assert` — a lowering failure is a genuine test failure). +fn forms_of(cmod: core_erlang.CModule) -> List(eaf.Form) { + let assert Ok(forms) = eaf.module_forms(cmod) + forms +} + /// Render a `LinkError` as a one-line diagnostic covering ALL SEVEN variants (so a merge failure /// surfaces a readable reason instead of a `let assert` panic). Contract: total; the returned /// string names the offending module/detail. Used by the linked driver's error channel and the @@ -205,17 +214,13 @@ fn linked_instantiate( |> result.map_error(fn(e) { "link: " <> link.import_error_phrase(e) }) } }) - use core_text <- result.try( - pipeline.ir_to_core(irmod, binding) + use cmod <- result.try( + pipeline.ir_to_cmod(irmod, binding) |> result.map_error(pipeline.describe), ) // THE ONE DIFFERENCE from the oracle: merge the runtime closure IN, rather than call it out. use pair <- result.try( - beam_link.link_program( - bit_array.from_string(core_text), - irmod.name, - ambient(), - ) + beam_link.link_program(forms_of(cmod), irmod.name, ambient()) |> result.map_error(fn(e) { "link_program: " <> describe_link_error(e) }), ) let #(mod_atom, beam) = pair @@ -499,9 +504,9 @@ pub fn l1_instantiate_root_seeds_state_test() { pub fn l1_import_bearing_instantiate1_merges_test() { let m = import_bearing_module("twocore@link@importfix") let binding = combos.binding_for(combos.cell_paged) - let assert Ok(core) = pipeline.ir_to_core(m, binding) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, binding) let assert Ok(#(mod, beam)) = - beam_link.link_program(bit_array.from_string(core), m.name, ambient()) + beam_link.link_program(forms_of(cmod), m.name, ambient()) let assert Ok(_) = build_beam.load_module(mod, "importfix.beam", beam) let assert Ok(imports) = link.link_imports(m, []) // seed the merged instantiate/1 with the provided import vector, then read the global. @@ -541,10 +546,10 @@ fn import_bearing_module(name: String) -> ir.Module { pub fn determinism_link_twice_byte_identical_test() { let assert Ok(bytes) = combos.read_wasm("mem") let assert Ok(m) = pipeline.source_to_ir(bytes) - let assert Ok(core) = pipeline.ir_to_core(m, profiles.unsafe()) - let bits = bit_array.from_string(core) - let assert Ok(#(_, beam1)) = beam_link.link_program(bits, m.name, ambient()) - let assert Ok(#(_, beam2)) = beam_link.link_program(bits, m.name, ambient()) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, profiles.unsafe()) + let forms = forms_of(cmod) + let assert Ok(#(_, beam1)) = beam_link.link_program(forms, m.name, ambient()) + let assert Ok(#(_, beam2)) = beam_link.link_program(forms, m.name, ambient()) assert beam1 == beam2 } @@ -572,14 +577,12 @@ pub fn d3a_structural_over_merged_corpus_test() { fn d3a_failures(name: String) -> List(String) { let assert Ok(bytes) = combos.read_wasm(name) let assert Ok(m) = pipeline.source_to_ir(bytes) - case pipeline.ir_to_core(m, profiles.unsafe()) { + case pipeline.ir_to_cmod(m, profiles.unsafe()) { // Import-bearing / out-of-scope programs never compile to a linkable core; skip (not a bare-node // artifact). The differential (L1) already proves they REJECT identically. Error(_) -> [] - Ok(core) -> - case - beam_link.link_to_core(bit_array.from_string(core), m.name, ambient()) - { + Ok(cmod) -> + case beam_link.link_to_core(forms_of(cmod), m.name, ambient()) { Error(e) -> [ name <> ": link_to_core failed: " <> describe_link_error(e), ] @@ -661,9 +664,10 @@ pub fn merged_exports_exactly_original_plus_instantiate_plus_module_info_test() fn export_failures(name: String) -> List(String) { let assert Ok(bytes) = combos.read_wasm(name) let assert Ok(m) = pipeline.source_to_ir(bytes) - let assert Ok(core) = pipeline.ir_to_core(m, profiles.unsafe()) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, profiles.unsafe()) + let core = core_printer.print_module(cmod) let assert Ok(#(_, merged)) = - beam_link.link_to_core(bit_array.from_string(core), m.name, ambient()) + beam_link.link_to_core(forms_of(cmod), m.name, ambient()) let expected = set.union( set.from_list(header_exports(core)), @@ -886,9 +890,9 @@ pub fn l2_constant_space_sum_to_100000_bare_node_test() { // (b) measured constant space on the merged artifact (its own runtime), in-process. let assert Ok(m) = pipeline.source_to_ir(bytes) let um = ir.Module(..m, name: m.name <> "_cspace") - let assert Ok(core2) = pipeline.ir_to_core(um, binding) + let assert Ok(cmod2) = pipeline.ir_to_cmod(um, binding) let assert Ok(#(mod2, beam2)) = - beam_link.link_program(bit_array.from_string(core2), um.name, ambient()) + beam_link.link_program(forms_of(cmod2), um.name, ambient()) let assert Ok(_) = build_beam.load_module(mod2, "cspace.beam", beam2) let assert Ok(small) = ffi.start_instance(mod2) @@ -954,10 +958,10 @@ fn linked_beam_pair( Error(e) -> Error(pipeline.describe(e)) Ok(m0) -> { let m = ir.Module(..m0, name: uniquify(m0.name)) - case pipeline.ir_to_core(m, binding) { + case pipeline.ir_to_cmod(m, binding) { Error(e) -> Error(pipeline.describe(e)) - Ok(core) -> - beam_link.link_program(bit_array.from_string(core), m.name, ambient()) + Ok(cmod) -> + beam_link.link_program(forms_of(cmod), m.name, ambient()) |> result.map_error(describe_link_error) } } diff --git a/test/twocore/backend/maps_test.gleam b/test/twocore/backend/maps_test.gleam index 5e2cee59..dc2dc039 100644 --- a/test/twocore/backend/maps_test.gleam +++ b/test/twocore/backend/maps_test.gleam @@ -13,14 +13,12 @@ //// the ops under test. The harness (`load`/`module`/`catch_apply_dyn`/`to_dynamic`) mirrors //// `term_ops_test.gleam`. -import gleam/bit_array import gleam/dict import gleam/dynamic.{type Dynamic} import gleam/erlang/atom.{type Atom} import gleam/list import gleam/option import twocore/backend/build_beam -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/ir import twocore/runtime/instance @@ -46,8 +44,7 @@ fn to_dynamic(x: a) -> Dynamic /// genuine test failure. fn load(module: ir.Module) -> Atom { let assert Ok(cm) = emit_core.emit_module(module, instance.safe_default()) - let core = core_printer.print_module(cm) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(mod) = build_beam.compile_and_load(cm) mod } diff --git a/test/twocore/backend/reffunc_import_emit_test.gleam b/test/twocore/backend/reffunc_import_emit_test.gleam index ac690ad3..b2d202ee 100644 --- a/test/twocore/backend/reffunc_import_emit_test.gleam +++ b/test/twocore/backend/reffunc_import_emit_test.gleam @@ -13,7 +13,6 @@ //// under Cell AND Threaded. The e2e harness follows `emit_core_e2e_test` //// (`emit_module → core_printer.print_module → build_beam.compile_and_load → catch_apply`). -import gleam/bit_array import gleam/dynamic.{type Dynamic} import gleam/erlang/atom.{type Atom} import gleam/list @@ -21,7 +20,6 @@ import gleam/option import gleam/result import gleam/string import twocore/backend/build_beam -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/conformance/driver import twocore/ir @@ -89,11 +87,9 @@ fn emit_and_load( ) -> Result(Atom, String) { case emit_core.emit_module(module, binding) { Error(e) -> Error("emit: " <> string.inspect(e)) - Ok(cm) -> { - let core = core_printer.print_module(cm) - build_beam.compile_and_load(bit_array.from_string(core)) + Ok(cm) -> + build_beam.compile_and_load(cm) |> result.map_error(fn(e) { "build: " <> string.inspect(e) }) - } } } diff --git a/test/twocore/backend/rt_js_boundary_test.gleam b/test/twocore/backend/rt_js_boundary_test.gleam index 8e429afa..63d972b8 100644 --- a/test/twocore/backend/rt_js_boundary_test.gleam +++ b/test/twocore/backend/rt_js_boundary_test.gleam @@ -51,8 +51,7 @@ fn to_dynamic(x: a) -> Dynamic /// `let assert` is the success contract — a failure to emit/compile/load is a genuine failure. fn load(module: ir.Module) -> Atom { let assert Ok(cm) = emit_core.emit_module(module, instance.safe_default()) - let core = core_printer.print_module(cm) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(mod) = build_beam.compile_and_load(cm) mod } diff --git a/test/twocore/backend/term_ops_test.gleam b/test/twocore/backend/term_ops_test.gleam index 78526f1b..f95b9447 100644 --- a/test/twocore/backend/term_ops_test.gleam +++ b/test/twocore/backend/term_ops_test.gleam @@ -12,13 +12,11 @@ //// `emit_core_e2e_test.gleam` exactly — an IR module is compiled, loaded, and its exports //// applied; term results are compared as `Dynamic` (BEAM term equality). -import gleam/bit_array import gleam/dynamic.{type Dynamic} import gleam/erlang/atom.{type Atom} import gleam/list import gleam/option import twocore/backend/build_beam -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/ir import twocore/runtime/instance @@ -45,8 +43,7 @@ fn to_dynamic(x: a) -> Dynamic /// genuine test failure. fn load(module: ir.Module) -> Atom { let assert Ok(cm) = emit_core.emit_module(module, instance.safe_default()) - let core = core_printer.print_module(cm) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(mod) = build_beam.compile_and_load(cm) mod } diff --git a/test/twocore/cli_link_flag_test.gleam b/test/twocore/cli_link_flag_test.gleam index 92e95d6f..22a5a9e4 100644 --- a/test/twocore/cli_link_flag_test.gleam +++ b/test/twocore/cli_link_flag_test.gleam @@ -79,8 +79,8 @@ pub fn default_off_byte_identical_test() { option.None, option.None, ) - let assert Ok(core) = pipeline.ir_to_core(m, binding) - let assert Ok(oracle_beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, binding) + let assert Ok(oracle_beam) = pipeline.cmod_to_beam(cmod) assert cli_beam == oracle_beam let _ = simplifile.delete(out) diff --git a/test/twocore/cli_test.gleam b/test/twocore/cli_test.gleam index 4c2b74f8..98a40b8a 100644 --- a/test/twocore/cli_test.gleam +++ b/test/twocore/cli_test.gleam @@ -77,22 +77,26 @@ pub fn cli_emit_test() { assert string.contains(text, "module 'add'") } -/// `to-beam` compiles `.core` to a real `.beam` binary on disk (round-tripping the `.core` -/// produced by `to-core`). +/// `to-beam` compiles `.ir` to a real `.beam` binary on disk (the abstract-forms +/// backend — no textual `.core` round trip exists any more). pub fn cli_to_beam_writes_beam_test() { - let assert Ok(core) = twocore.run(["to-core", golden <> "/add.ir"]) - let tmp_core = "build/cli_test_add.core" let tmp_beam = "build/cli_test_add.beam" - let assert Ok(Nil) = simplifile.write(tmp_core, core) - let assert Ok(msg) = twocore.run(["to-beam", tmp_core, tmp_beam]) + let assert Ok(msg) = twocore.run(["to-beam", golden <> "/add.ir", tmp_beam]) assert string.contains(msg, "wrote") // the .beam exists and is non-trivial let assert Ok(beam) = simplifile.read_bits(tmp_beam) assert beam != <<>> - let _ = simplifile.delete(tmp_core) let _ = simplifile.delete(tmp_beam) } +/// `to-erl ` dumps the generated module as Erlang source (`erl_pp` over +/// the abstract forms `compile:forms/2` consumes). +pub fn cli_to_erl_test() { + let assert Ok(text) = twocore.run(["to-erl", golden <> "/add.ir"]) + assert string.contains(text, "-module(add).") + assert string.contains(text, "instantiate()") +} + /// `to-beam-wasm [--unsafe] ` compiles a `.wasm` to a `.beam` under EACH /// profile (the profile-selecting compile the benchmark needs), and `exec` runs the prebuilt /// `.beam` — both profiles compute the same spec-correct result (`add(2,3) == 5`), proving the diff --git a/test/twocore/conformance/corpus_test.gleam b/test/twocore/conformance/corpus_test.gleam index 912ce1c6..0764b3e0 100644 --- a/test/twocore/conformance/corpus_test.gleam +++ b/test/twocore/conformance/corpus_test.gleam @@ -24,7 +24,6 @@ import gleam/option import gleam/result import gleam/string import twocore/backend/build_beam -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/conformance/corpus.{ type Expect, InstantiateTraps, Rejects, Returns, Traps, @@ -344,8 +343,8 @@ fn compile_load(bytes: BitArray, binding: instance.Binding) -> Atom { let assert Ok(m0) = pipeline.source_to_ir(bytes) let m = ir.Module(..m0, name: m0.name <> "_" <> int.to_string(ffi.unique_int())) - let assert Ok(core) = pipeline.ir_to_core(m, binding) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, binding) + let assert Ok(mod) = build_beam.compile_and_load(cmod) mod } @@ -360,8 +359,7 @@ fn read_text(file: String) -> Result(String, String) { /// A failure here is a genuine test failure (the backend should compile valid IR). fn load_ir(m: ir.Module) -> Atom { let assert Ok(cmod) = emit_core.emit_module(m, instance.safe_default()) - let core = core_printer.print_module(cmod) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(mod) = build_beam.compile_and_load(cmod) mod } diff --git a/test/twocore/conformance/driver.gleam b/test/twocore/conformance/driver.gleam index 716d19c0..c513d05f 100644 --- a/test/twocore/conformance/driver.gleam +++ b/test/twocore/conformance/driver.gleam @@ -27,7 +27,6 @@ //// raw value / IEEE-754 bit pattern) for the numeric case, or a reference term / result tuple for //// the reference / multi-value case; `invoke` tags each back to a typed `SpecValue` for the oracle. -import gleam/bit_array import gleam/dict.{type Dict} import gleam/dynamic.{type Dynamic} import gleam/dynamic/decode as dyn_decode @@ -238,12 +237,12 @@ fn instantiate_typed( |> result.map_error(fn(e) { "link: " <> link.import_error_phrase(e) }) } }) - use core_text <- result.try( - pipeline.ir_to_core(irmod, binding) + use cmod <- result.try( + pipeline.ir_to_cmod(irmod, binding) |> result.map_error(pipeline.describe), ) use mod_atom <- result.try( - build_beam.compile_and_load(bit_array.from_string(core_text)) + build_beam.compile_and_load(cmod) |> result.map_error(fn(e) { "build: " <> string.inspect(e) }), ) // Dispatch the instantiate ABI by import-presence (R4): an import-free module keeps the diff --git a/test/twocore/frontend/wasm/lower_test.gleam b/test/twocore/frontend/wasm/lower_test.gleam index f8f961ef..9ceaf190 100644 --- a/test/twocore/frontend/wasm/lower_test.gleam +++ b/test/twocore/frontend/wasm/lower_test.gleam @@ -19,7 +19,6 @@ import gleam/option import gleam/set import gleeunit/should import twocore/backend/build_beam -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/frontend/wasm/ast import twocore/frontend/wasm/decode @@ -54,8 +53,7 @@ fn build(bytes: BitArray) -> ir.Module { /// Emit `irm` to Core text, compile it, and load it into the test VM (D10). fn load(irm: ir.Module) -> Atom { let assert Ok(cm) = emit_core.emit_module(irm, instance.safe_default()) - let core = core_printer.print_module(cm) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(mod) = build_beam.compile_and_load(cm) mod } diff --git a/test/twocore/middle/ir_lower_test.gleam b/test/twocore/middle/ir_lower_test.gleam index ec642ba6..576f23b0 100644 --- a/test/twocore/middle/ir_lower_test.gleam +++ b/test/twocore/middle/ir_lower_test.gleam @@ -326,8 +326,8 @@ fn run_metered( export: String, args: List(Int), ) -> #(pipeline.RunResult, Int) { - let assert Ok(core) = pipeline.ir_to_core(m, profiles.safe()) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, profiles.safe()) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) rt_meter.reset_fuel() let result = pipeline.invoke(beam, m.name, export, args) #(result, rt_meter.fuel_consumed()) diff --git a/test/twocore/optimize/bce_test.gleam b/test/twocore/optimize/bce_test.gleam index 4f89717d..887a887b 100644 --- a/test/twocore/optimize/bce_test.gleam +++ b/test/twocore/optimize/bce_test.gleam @@ -257,8 +257,8 @@ fn sumbuf_module(body: ir.Expr) -> ir.Module { } fn run(m: ir.Module, export: String, args: List(Int)) -> pipeline.RunResult { - let assert Ok(core) = pipeline.ir_to_core(m, profiles.safe()) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, profiles.safe()) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) let assert Ok(proc) = pipeline.instantiate(beam, m.name) let out = pipeline.invoke_instance(proc, export, args) pipeline.stop_instance(proc) diff --git a/test/twocore/optimize/coexistence_test.gleam b/test/twocore/optimize/coexistence_test.gleam index 89949301..d5cd54b5 100644 --- a/test/twocore/optimize/coexistence_test.gleam +++ b/test/twocore/optimize/coexistence_test.gleam @@ -22,7 +22,6 @@ //// Unit 10's `linker_coexist_test` proves the same for a hand-built stateful module; this //// capstone confirms it at CORPUS scale and adds the capability-isolation dimension. -import gleam/bit_array import gleam/erlang/atom.{type Atom} import gleam/int import gleam/option @@ -158,8 +157,8 @@ fn host_module(name: String) -> ir.Module { /// BEAM atom. `let assert Ok` is the success contract — a compile/build failure is a genuine test /// failure, not an expected path. fn compile_load(m: ir.Module, binding: Binding) -> Atom { - let assert Ok(core) = pipeline.ir_to_core(m, binding) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, binding) + let assert Ok(mod) = build_beam.compile_and_load(cmod) mod } diff --git a/test/twocore/optimize/cross_cf_test.gleam b/test/twocore/optimize/cross_cf_test.gleam index d20c8a0a..78c5de95 100644 --- a/test/twocore/optimize/cross_cf_test.gleam +++ b/test/twocore/optimize/cross_cf_test.gleam @@ -213,8 +213,8 @@ fn cross_module() -> ir.Module { } fn run(m: ir.Module, export: String, args: List(Int)) -> pipeline.RunResult { - let assert Ok(core) = pipeline.ir_to_core(m, profiles.safe()) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, profiles.safe()) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) let assert Ok(proc) = pipeline.instantiate(beam, m.name) let out = pipeline.invoke_instance(proc, export, args) pipeline.stop_instance(proc) diff --git a/test/twocore/optimize/licm_test.gleam b/test/twocore/optimize/licm_test.gleam index 09f051e0..f3f53b05 100644 --- a/test/twocore/optimize/licm_test.gleam +++ b/test/twocore/optimize/licm_test.gleam @@ -239,8 +239,8 @@ fn sumk_module() -> ir.Module { } fn run(m: ir.Module, export: String, args: List(Int)) -> pipeline.RunResult { - let assert Ok(core) = pipeline.ir_to_core(m, profiles.safe()) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, profiles.safe()) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) let assert Ok(proc) = pipeline.instantiate(beam, m.name) let out = pipeline.invoke_instance(proc, export, args) pipeline.stop_instance(proc) diff --git a/test/twocore/optimize/mem10_keystone_test.gleam b/test/twocore/optimize/mem10_keystone_test.gleam index 7c2d22ac..ca56690d 100644 --- a/test/twocore/optimize/mem10_keystone_test.gleam +++ b/test/twocore/optimize/mem10_keystone_test.gleam @@ -191,8 +191,8 @@ pub fn unchecked_nodes_run_like_checked_on_beam_test() { // At the freeze the unchecked nodes lower via the CHECKED path, so a module using them returns the // identical value as the checked equivalent (little-endian round-trip through memory). let m = unchecked_roundtrip_module() - let assert Ok(core) = pipeline.ir_to_core(m, profiles.safe()) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, profiles.safe()) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) let assert Ok(proc) = pipeline.instantiate(beam, m.name) let out = pipeline.invoke_instance(proc, "rt", [0, 305_419_896]) pipeline.stop_instance(proc) diff --git a/test/twocore/optimize/mem_bench_test.gleam b/test/twocore/optimize/mem_bench_test.gleam index c89680fd..7f720826 100644 --- a/test/twocore/optimize/mem_bench_test.gleam +++ b/test/twocore/optimize/mem_bench_test.gleam @@ -14,7 +14,6 @@ import gleam/int import gleam/io import gleam/list import gleam/option -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/ir import twocore/middle/ir_lower @@ -214,7 +213,6 @@ fn build_beam( ) -> BitArray { let optimized = pass.run_pipeline(m, passes) let assert Ok(cmod) = emit_core.emit_module(optimized, binding) - let core = core_printer.print_module(cmod) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) beam } diff --git a/test/twocore/optimize/mem_dse_test.gleam b/test/twocore/optimize/mem_dse_test.gleam index c80d8a99..647ebdf9 100644 --- a/test/twocore/optimize/mem_dse_test.gleam +++ b/test/twocore/optimize/mem_dse_test.gleam @@ -295,8 +295,8 @@ fn double_store_module() -> ir.Module { /// Compile `m` under `profiles.safe()` through the whole run-ABI and invoke `export(args)`. fn run(m: ir.Module, export: String, args: List(Int)) -> pipeline.RunResult { - let assert Ok(core) = pipeline.ir_to_core(m, profiles.safe()) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, profiles.safe()) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) let assert Ok(proc) = pipeline.instantiate(beam, m.name) let out = pipeline.invoke_instance(proc, export, args) pipeline.stop_instance(proc) diff --git a/test/twocore/optimize/mem_forward_test.gleam b/test/twocore/optimize/mem_forward_test.gleam index 6cbe11ec..2625dd10 100644 --- a/test/twocore/optimize/mem_forward_test.gleam +++ b/test/twocore/optimize/mem_forward_test.gleam @@ -349,8 +349,8 @@ fn roundtrip_module() -> ir.Module { /// Compile `m` under `profiles.safe()` through the whole run-ABI and invoke `export(args)`. fn run(m: ir.Module, export: String, args: List(Int)) -> pipeline.RunResult { - let assert Ok(core) = pipeline.ir_to_core(m, profiles.safe()) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, profiles.safe()) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) let assert Ok(proc) = pipeline.instantiate(beam, m.name) let out = pipeline.invoke_instance(proc, export, args) pipeline.stop_instance(proc) diff --git a/test/twocore/optimize/memory_differential_test.gleam b/test/twocore/optimize/memory_differential_test.gleam index eb4e8c1a..13acf4fb 100644 --- a/test/twocore/optimize/memory_differential_test.gleam +++ b/test/twocore/optimize/memory_differential_test.gleam @@ -209,8 +209,8 @@ fn run( export: String, args: List(Int), ) -> pipeline.RunResult { - let assert Ok(core) = pipeline.ir_to_core(m, binding) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, binding) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) let assert Ok(proc) = pipeline.instantiate(beam, m.name) let out = pipeline.invoke_instance(proc, export, args) pipeline.stop_instance(proc) diff --git a/test/twocore/optimize/metering_test.gleam b/test/twocore/optimize/metering_test.gleam index 1f926478..4c9c0375 100644 --- a/test/twocore/optimize/metering_test.gleam +++ b/test/twocore/optimize/metering_test.gleam @@ -25,7 +25,6 @@ //// `runner.trap_matches`, which is for WASM spec phrases), keeping the policy trap out of the //// conformance trap-phrase table. -import gleam/bit_array import gleam/erlang/atom.{type Atom} import gleam/int import gleam/list @@ -158,7 +157,7 @@ fn compile_load(name: String, binding: Binding) -> Atom { let assert Ok(m0) = pipeline.source_to_ir(bytes) let m = ir.Module(..m0, name: m0.name <> "_" <> int.to_string(ffi.unique_int())) - let assert Ok(core) = pipeline.ir_to_core(m, binding) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, binding) + let assert Ok(mod) = build_beam.compile_and_load(cmod) mod } diff --git a/test/twocore/optimize/phase10_bench_test.gleam b/test/twocore/optimize/phase10_bench_test.gleam index 09d826e1..ddc77c9c 100644 --- a/test/twocore/optimize/phase10_bench_test.gleam +++ b/test/twocore/optimize/phase10_bench_test.gleam @@ -11,7 +11,6 @@ import gleam/int import gleam/io import gleam/list import gleam/option -import twocore/backend/core_printer import twocore/backend/emit_core import twocore/ir import twocore/middle/ir_lower @@ -258,8 +257,7 @@ fn lower(m: ir.Module, binding: Binding) -> ir.Module { fn build(m: ir.Module, binding: Binding, passes: List(pass.Pass)) -> BitArray { let optimized = pass.run_pipeline(m, passes) let assert Ok(cmod) = emit_core.emit_module(optimized, binding) - let core = core_printer.print_module(cmod) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) beam } diff --git a/test/twocore/optimize/phase10_capstone_test.gleam b/test/twocore/optimize/phase10_capstone_test.gleam index 7bbbc7e3..13e4a96f 100644 --- a/test/twocore/optimize/phase10_capstone_test.gleam +++ b/test/twocore/optimize/phase10_capstone_test.gleam @@ -261,8 +261,8 @@ fn run( args: List(Int), ) -> pipeline.RunResult { let binding = Binding(..profiles.safe(), opt_level: level) - let assert Ok(core) = pipeline.ir_to_core(m, binding) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, binding) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) let assert Ok(proc) = pipeline.instantiate(beam, m.name) let out = pipeline.invoke_instance(proc, export, args) pipeline.stop_instance(proc) diff --git a/test/twocore/pipeline_opt_test.gleam b/test/twocore/pipeline_opt_test.gleam index ecceb61a..4b0ac7cb 100644 --- a/test/twocore/pipeline_opt_test.gleam +++ b/test/twocore/pipeline_opt_test.gleam @@ -120,8 +120,8 @@ fn count_charge_calls(m: CModule) -> Int { /// per-instance seeds (the instance runs `instantiate/0`, which seeds fuel + host policy). fn run_add(binding: Binding, a: Int, b: Int) -> pipeline.RunResult { let m = add_module() - let assert Ok(core) = pipeline.ir_to_core(m, binding) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, binding) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) let assert Ok(proc) = pipeline.instantiate(beam, m.name) let result = pipeline.invoke_instance(proc, "add", [a, b]) pipeline.stop_instance(proc) diff --git a/test/twocore/pipeline_tier_test.gleam b/test/twocore/pipeline_tier_test.gleam index 1a6a2bcc..dabe4a44 100644 --- a/test/twocore/pipeline_tier_test.gleam +++ b/test/twocore/pipeline_tier_test.gleam @@ -151,8 +151,8 @@ fn run_one( export: String, args: List(Int), ) -> pipeline.RunResult { - let assert Ok(core) = pipeline.ir_to_core(module, binding) - let assert Ok(beam) = pipeline.core_to_beam(core, module.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(module, binding) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) let assert Ok(proc) = pipeline.instantiate(beam, module.name) let result = pipeline.invoke_instance(proc, export, args) pipeline.stop_instance(proc) @@ -183,8 +183,8 @@ fn atomics_capped() -> Binding { /// P4-08 §C — cross-invoke threading.) pub fn threaded_state_persists_across_invokes_test() { let m = global_module() - let assert Ok(core) = pipeline.ir_to_core(m, threaded()) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, threaded()) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) let assert Ok(proc) = pipeline.instantiate(beam, m.name) // get reads the constant init 7. assert pipeline.invoke_instance(proc, "get", []) == pipeline.Returned([7]) @@ -207,8 +207,8 @@ pub fn threaded_pure_export_returns_value_test() { /// returns `X`, threaded across invokes (spec `exec/memory` little-endian round-trip). pub fn threaded_memory_roundtrip_test() { let m = memory_module() - let assert Ok(core) = pipeline.ir_to_core(m, threaded()) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, threaded()) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) let assert Ok(proc) = pipeline.instantiate(beam, m.name) let _ = pipeline.invoke_instance(proc, "store32", [0, 305_419_896]) assert pipeline.invoke_instance(proc, "load32", [0]) @@ -230,7 +230,8 @@ pub fn atomics_build_links_and_runs_test() { assert string.contains(core, "twocore@runtime@rt_mem_atomics") assert !string.contains(core, "'twocore@runtime@rt_mem':") // and it runs — the tier-O backend round-trips a store/load. - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, binding) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) let assert Ok(proc) = pipeline.instantiate(beam, m.name) let _ = pipeline.invoke_instance(proc, "store32", [0, 305_419_896]) assert pipeline.invoke_instance(proc, "load32", [0]) diff --git a/test/twocore/runtime/linker_coexist_test.gleam b/test/twocore/runtime/linker_coexist_test.gleam index 2f1d9e37..46e4a1af 100644 --- a/test/twocore/runtime/linker_coexist_test.gleam +++ b/test/twocore/runtime/linker_coexist_test.gleam @@ -106,8 +106,8 @@ fn state_module(name: String) -> ir.Module { /// compile/build failure is a genuine test failure, not an expected path. fn build(name: String, binding: Binding) -> BitArray { let m = state_module(name) - let assert Ok(core) = pipeline.ir_to_core(m, binding) - let assert Ok(beam) = pipeline.core_to_beam(core, m.name) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, binding) + let assert Ok(beam) = pipeline.cmod_to_beam(cmod) beam } diff --git a/test/twocore/tier/constant_space_threaded_test.gleam b/test/twocore/tier/constant_space_threaded_test.gleam index 96060295..2b0d712e 100644 --- a/test/twocore/tier/constant_space_threaded_test.gleam +++ b/test/twocore/tier/constant_space_threaded_test.gleam @@ -22,7 +22,6 @@ //// the evidence. Spec: variable/memory instructions //// . -import gleam/bit_array import gleam/erlang/atom import gleam/int import twocore/backend/build_beam @@ -41,8 +40,8 @@ fn compile_load(name: String, binding: Binding) -> atom.Atom { let assert Ok(m0) = pipeline.source_to_ir(bytes) let m = ir.Module(..m0, name: m0.name <> "_" <> int.to_string(ffi.unique_int())) - let assert Ok(core) = pipeline.ir_to_core(m, binding) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, binding) + let assert Ok(mod) = build_beam.compile_and_load(cmod) mod } diff --git a/test/twocore/tier/tailcall_capstone_test.gleam b/test/twocore/tier/tailcall_capstone_test.gleam index 34d7e76f..64adc27e 100644 --- a/test/twocore/tier/tailcall_capstone_test.gleam +++ b/test/twocore/tier/tailcall_capstone_test.gleam @@ -22,7 +22,6 @@ //// mutual / indirect (same-module) recursion runs in CONSTANT stack space. "call stack exhausted" is //// not a WASM trap and does not exist on the BEAM — which is exactly what these tests prove. -import gleam/bit_array import gleam/erlang/atom import gleam/int import gleam/io @@ -45,8 +44,8 @@ fn compile_load(name: String, binding: Binding) -> atom.Atom { let assert Ok(m0) = pipeline.source_to_ir(bytes) let m = ir.Module(..m0, name: m0.name <> "_" <> int.to_string(ffi.unique_int())) - let assert Ok(core) = pipeline.ir_to_core(m, binding) - let assert Ok(mod) = build_beam.compile_and_load(bit_array.from_string(core)) + let assert Ok(cmod) = pipeline.ir_to_cmod(m, binding) + let assert Ok(mod) = build_beam.compile_and_load(cmod) mod }