Skip to content

Compile to Erlang Abstract Format in-process via compile:forms/2 - #82

Merged
hiett merged 4 commits into
mainfrom
eaf-backend
Jul 19, 2026
Merged

Compile to Erlang Abstract Format in-process via compile:forms/2#82
hiett merged 4 commits into
mainfrom
eaf-backend

Conversation

@alii

@alii alii commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

What

Replaces the textual Core Erlang backend with Erlang Abstract Format (EAF) generation compiled in-process via compile:forms/2. The compiler now builds abstract forms as in-memory Erlang terms and hands them straight to the compiler — no Core Erlang text is ever printed, re-scanned, or re-parsed.

Why

  • No text round-trip: printing Core Erlang and re-parsing it was the source of the recent build-memory blowups (the whitespace issue was a symptom). Forms now go straight to the compiler as terms.
  • Stability: EAF is documented (ERTS "Abstract Format" chapter) and load-bearing for the whole ecosystem (Dialyzer, cover, debugger). Core Erlang's accepted dialect is explicitly undocumented and drifts across OTP releases; cerl has been undocumented since ~OTP 16.
  • Tooling: real debug_info/abstract_code chunk → Dialyzer, cover, and decompilation work on 2core output.
  • Line numbers: first-class Anno on every node → real stack traces.
  • Same optimisations: every erlc pass that matters (sys_core_fold, v3_kernel pattern-match compilation, all beam_ssa_* passes) runs downstream of both entry points — no runtime perf change expected.

Precedent: Elixir compiles to EAF + :compile.forms; Gleam abandoned Core Erlang (and is now exploring EAF in gleam-lang/gleam#5990); LFE moved off Core.

Design notes

  • New src/twocore/backend/eaf.gleam: IR → EAF forms. EAF tuples are plain tuples, so they're constructed directly from Gleam.
  • Variable scoping: Erlang has no shadowing/letrec, so codegen uses unique counter-suffixed variable renaming (the Elixir approach); letrec-style recursion lowers to named funs / top-level functions.
  • Pipeline/CLI compile through a CModule/forms seam; to-erl added for debugging (pretty-prints forms via erl_pp); linker acquires generated modules from abstract forms.
  • Annotations carry IR source positions where available, synthetic otherwise.

Test status

Full suite green: 2212 passed, 0 failures, including the WASM spec-suite capstones (tail calls 47734/683/0 headline, cross-module funcref elem init) and differential combos (OptNone ≡ Baseline ≡ Aggressive).

Open questions

  • to-erl output is for debugging only — happy to drop it if unwanted.
  • Old textual Core path is fully removed per "no intermediary states" guidance; shout if a fallback flag is wanted.

🤖 Generated with Claude Code

alii added 4 commits July 19, 2026 15:06
…le: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
  <legalized>@<counter> 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
  '<loc>: <message>' 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.
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.
…akes .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.
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.
@alii
alii marked this pull request as ready for review July 19, 2026 15:00
@hiett
hiett merged commit 7c91f5c into main Jul 19, 2026
1 check passed
@hiett
hiett deleted the eaf-backend branch July 19, 2026 19:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants