1a1a68df8b
Second half of spec iteration mir.1, planned against the landed mir.1a
infrastructure (135f4ce). This is the project's largest single
mechanical change and it is atomic: codegen's entire lowering walk
flips from &Term to &MTerm with no compiling intermediate (dual-path is
spec-forbidden). plan-recon proved the blast radius with a compile stub
(flipping only lower_term's signature → 58 cascading errors across
lib.rs/drop.rs/match_lower.rs/escape.rs/lambda.rs, baseline restored
green). The plan gives a Term→MTerm correspondence rule for the
mechanical arm renames (the build gate is the totality checker), exact
before→after for the judgement sites (the 9 synth_arg_type reads → ty(),
the dual-source Emitter, escape pointer-identity, entry-point
signatures, CLI diagnostics), and a satisfiable gate per task.
Four recon open-questions resolved by orchestrator judgement (within
the approved spec's intent; the spec's MirModule sketch is illustrative
and exact shape is the planner's):
- OQ1: MirModule gains `ast: Module` — codegen reads structural data
(Type/Const/ctor/intrinsic markers/symbol tables) from .ast, typed fn
bodies from .defs. MirWorkspace stays the single artefact (it now
contains the post-mono AST); structural reads were never a
re-derivation, so this does not reintroduce one. Also resolves the
intrinsic-fn gap (lower_module skips intrinsic bodies; codegen
iterates ast.defs and looks up MirDef bodies by name).
- OQ2: emit_fn borrows mir_def.body:&MTerm once and shares it between
escape::analyze_fn_body and lower_term (pointer identity is only
stable within one borrowed tree); casts flip to *const MTerm.
- OQ3: MTerm::New's codegen arm stays unreachable! — New is desugared
away before codegen (RawBuf→payload, monomorphic user-ADT new→dotted
App), exactly as Term::New is today; #51 builds unchanged.
- OQ4: the CLI build/emit-ir paths drop their separate check_workspace
call and let elaborate_workspace's Err drive diagnostics (one check,
not two); `ail check` stays on check_workspace.
- emit_ir keeps &Module, calls elaborate_workspace internally,
converts Err(diags)→CodegenError; its builtin-only in-source tests
check clean without a prelude.
Task structure: Task 1 (additive) extends MirModule + populates ast +
adds codegen's ailang-mir dep, gate cargo test --workspace. Task 2 (the
atomic flip) converts the whole codegen crate including its tests, gate
cargo test -p ailang-codegen (deliberately excludes crates/ail so the
not-yet-threaded external callers don't fail this gate). Task 3 threads
the crates/ail callers (CLI build/emit-ir/staticlib + 6 e2e callers),
gate cargo test --workspace == 102 ok (the mir.1a baseline), #51/#53
build+run, synth_with_extras/synth_arg_type grep-clean.
refs #49
656 lines
31 KiB
Markdown
656 lines
31 KiB
Markdown
# mir.1b — codegen consumes MIR (the atomic switch) — Implementation Plan
|
||
|
||
> **Parent spec:** `docs/specs/0060-typed-mir.md`
|
||
>
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement`
|
||
> skill to run this plan. Steps use `- [ ]` checkboxes for tracking.
|
||
|
||
**Goal:** Flip the entire codegen lowering walk from `&Term` to
|
||
`&MTerm`, delete `synth_with_extras` + `synth_arg_type` (the type
|
||
re-derivation mirror), switch their 9 call sites to read `MTerm::ty()`,
|
||
move the public `lower_workspace*` entry points to take `&MirWorkspace`,
|
||
and switch the CLI / test build paths to `elaborate_workspace` — so
|
||
codegen re-derives **no types**. The three other re-derivers
|
||
(`type_home_module`, `is_static_callee`, the 2nd `infer_module_with_cross`)
|
||
STAY for mir.1b; they leave in mir.2/mir.3.
|
||
|
||
**Architecture:** This is the second half of spec iteration mir.1,
|
||
planned against the landed mir.1a infrastructure (commit 135f4ce). It
|
||
is the project's **largest single mechanical change** and it is
|
||
**atomic**: there is no compiling intermediate between "codegen walks
|
||
`&Term`" and "codegen walks `&MTerm`" (dual-path is spec-forbidden).
|
||
The recon proved this — flipping only `lower_term`'s signature yields
|
||
58 compiler errors cascading through `lib.rs` / `drop.rs` /
|
||
`match_lower.rs` / `escape.rs` / `lambda.rs`. The compiler is therefore
|
||
the totality checker for the mechanical arm-by-arm rename; this plan
|
||
gives a **correspondence rule** for the mechanical flips, **exact
|
||
before→after** for the judgement sites (the 9 synth sites, the
|
||
dual-source Emitter, the escape pointer-identity, the entry-point
|
||
signatures, the CLI diagnostics), and a build gate per task.
|
||
|
||
**Tech Stack:** `ailang-mir` (one field added), `ailang-check`
|
||
(`lower_module` populates it), `ailang-codegen` (the bulk flip),
|
||
`crates/ail` (CLI build paths + e2e callers).
|
||
|
||
---
|
||
|
||
## Design decisions baked into this plan (orchestrator, resolving recon's open questions)
|
||
|
||
The recon (read-only) surfaced four design ambiguities. All four are
|
||
resolved here by orchestrator judgement, within the approved spec's
|
||
intent (the spec's MirModule sketch is illustrative; the spec mandates
|
||
removing the *re-derivers*, and exact MIR shape is the planner's to
|
||
fix). None is a user fork.
|
||
|
||
- **OQ1 — Emitter dual-source → `MirModule` carries the AST module.**
|
||
The Emitter reads `self.module.defs` (AST) for `Def::Type` drop-fns,
|
||
`Def::Const`, intrinsic-`new` markers, and the pass-1 symbol tables
|
||
(`module_user_fns` / `module_def_ail_types` / `module_ctor_index` /
|
||
`module_consts`), but each fn **body** must now come from
|
||
`MirDef.body` (the typed `MTerm`). `MirModule` (landed mir.1a) carries
|
||
only fn bodies. **Resolution:** add one field — `MirModule.ast:
|
||
Module` — so codegen reads structural data from `.ast` and typed
|
||
bodies from `.defs`. `MirWorkspace` stays the **single** artefact
|
||
codegen consumes (it now *contains* the post-mono AST). This is
|
||
spec-consistent: structural module reads were never a re-derivation;
|
||
the spec only requires deleting the type/callee/mode/rep
|
||
re-derivers. Carrying the AST also resolves the intrinsic-fn gap:
|
||
`lower_module` skips intrinsic-bodied fns (no `MirDef`), so codegen
|
||
iterates `mir_module.ast.defs` (all fns, incl. intrinsic ones handled
|
||
via the intercept registry) and looks up the `MirDef` body by name
|
||
for the non-intrinsic ones.
|
||
- **OQ2 — escape pointer-identity over `MTerm`.** `escape::analyze_fn_body`
|
||
and `lower_term` key nodes by raw-pointer identity (`(t as *const
|
||
Term) as usize`). After the flip they key `*const MTerm`, and the
|
||
invariant is that **both passes walk the *same* borrowed `&MTerm`
|
||
tree** — `emit_fn` borrows `mir_def.body: &MTerm` **once** and passes
|
||
the same reference to `analyze_fn_body` and `lower_term`. A clone
|
||
between the passes would silently break identity. The pointer-cast
|
||
sites flip `*const Term` → `*const MTerm`; mechanical once the
|
||
same-tree borrow is stated.
|
||
- **OQ3 — `MTerm::New` stays `unreachable!` in codegen.** Exactly as
|
||
`Term::New`'s `lower_term` arm is `unreachable!` today: every `New`
|
||
node is desugared away before codegen (RawBuf → payload ops;
|
||
monomorphic user-ADT `new` → dotted `App`, per mir.1a's commit). So
|
||
`elaborate_workspace`'s desugar pass (unchanged) eliminates `New`
|
||
before `lower_to_mir`, and codegen never hits a live `MTerm::New`.
|
||
#51 keeps building via its desugared payload path — untouched by
|
||
mir.1b. (The element-type work that makes `New` "real" is mir.5.)
|
||
- **OQ4 — CLI diagnostics: drop the separate `check_workspace` call.**
|
||
`elaborate_workspace` re-runs `check_workspace` internally and returns
|
||
`Err(Vec<Diagnostic>)` on a type error. The `build` / `emit-ir`
|
||
build paths currently print diagnostics from their *own*
|
||
`check_workspace` call then `process::exit(1)`. **Resolution:** those
|
||
build paths drop their separate check call and let
|
||
`elaborate_workspace`'s `Err` drive the diagnostic print + exit(1)
|
||
(one check, not two). The `ail check` subcommand stays on
|
||
`check_workspace` (diagnostics-only, no build).
|
||
- **emit_ir keeps `&Module`, calls `elaborate_workspace` internally,
|
||
converts `Err(Vec<Diagnostic>)` → `CodegenError`.** Its in-source
|
||
test callers build hand-written single-module `Module`s using
|
||
*builtins* (`add`, `==`), which resolve from `env.globals` without a
|
||
prelude, so `check_workspace` passes; the one ill-typed test
|
||
(`eq` on an ADT, asserting `unwrap_err`) now gets its error from
|
||
`check` (`NoInstance`) instead of lowering — same outcome
|
||
(`unwrap_err` still holds). Any in-source `emit_ir` test that
|
||
references a *prelude module* fn (not a builtin) and so fails the
|
||
internal check is migrated to a `load_workspace` fixture — bounded,
|
||
implement-surfaced, local.
|
||
|
||
**Files this plan creates or modifies:**
|
||
|
||
- Modify: `crates/ailang-mir/src/lib.rs` — add `ast: Module` to `MirModule`.
|
||
- Modify: `crates/ailang-check/src/lower_to_mir.rs` — populate `ast`.
|
||
- Modify: `crates/ailang-codegen/Cargo.toml` — add `ailang-mir` dep.
|
||
- Modify: `crates/ailang-codegen/src/lib.rs` — the bulk flip + deletes
|
||
+ entry-point signatures + emit_ir.
|
||
- Modify: `crates/ailang-codegen/src/drop.rs` — `&Term`→`&MTerm`, 4 synth sites.
|
||
- Modify: `crates/ailang-codegen/src/match_lower.rs` — `&Term`/`&[Term]`/`&[Arm]`→MIR, 3 synth sites.
|
||
- Modify: `crates/ailang-codegen/src/escape.rs` — analysis walkers `&Term`→`&MTerm`.
|
||
- Modify: `crates/ailang-codegen/src/lambda.rs` — `&Term`→`&MTerm`.
|
||
- Modify: `crates/ailang-codegen/tests/{embed_record_layout_pin,embed_staticlib_lowering,eq_primitives_pin}.rs` — callers build MIR.
|
||
- Modify: `crates/ail/src/main.rs` — build / emit-ir / staticlib paths → `elaborate_workspace`.
|
||
- Modify: `crates/ail/tests/e2e.rs` — 6 `lower_workspace_with_alloc` callers.
|
||
|
||
---
|
||
|
||
## The Term → MTerm correspondence rule (used throughout Task 2)
|
||
|
||
Every codegen `match` on a `Term` re-matches the `MTerm` counterpart.
|
||
The mapping is structural and 1:1 except for the four noted shifts.
|
||
**The build gate is the totality checker** — the compiler flags every
|
||
unconverted arm. Apply this rule at every match site the recon
|
||
enumerated; the judgement sites (synth reads, callee unwrap, escape
|
||
pointer, dual-source) get exact code in the steps below.
|
||
|
||
| `Term` arm | `MTerm` arm | Shift |
|
||
|---|---|---|
|
||
| `Lit { lit }` | `Lit { lit, ty }` for non-Str; **`Literal::Str` → new `Str { lit, rep }` arm** | Str split (rep unused at mir.1b) |
|
||
| `Var { name }` | `Var { name, ty }` | — |
|
||
| `App { callee, args, tail }` | `App { callee, args, tail, ty }` | `callee: Callee` (unwrap `Indirect`), `args: Vec<MArg>` |
|
||
| `Let { name, value, body }` | `Let { name, mode, init, body, ty }` | `value`→`init`; `mode` ignored (mir.3) |
|
||
| `If { cond, then, else_ }` | `If { cond, then, else_, ty }` | children `Box<MTerm>` |
|
||
| `Do { op, args, tail }` | `Do { op, args, tail, ty }` | `args: Vec<MArg>` |
|
||
| `Ctor { type_name, ctor, args }` | `Ctor { type_name, ctor, args, ty }` | `args: Vec<MArg>` |
|
||
| `Match { scrutinee, arms }` | `Match { scrutinee, arms, ty }` | `arms: Vec<MArm>` (`MArm.pat` reuses `ast::Pattern`) |
|
||
| `Lam { … }` | `Lam { …, ty }` | `ret_ty: Type` (not `Box`), body `Box<MTerm>` |
|
||
| `Seq { lhs, rhs }` | `Seq { lhs, rhs, ty }` | — |
|
||
| `Clone { value }` | `Clone { value, ty }` | — |
|
||
| `ReuseAs { source, body }` | `ReuseAs { source, body, ty }` | inner `MTerm::Ctor` destructure |
|
||
| `Loop { binders, body }` | `Loop { binders, body, ty }` | `binders: Vec<MLoopBinder>` (`.init` is `MTerm`, not `Box`) |
|
||
| `Recur { args }` | `Recur { args, ty }` | `args: Vec<MArg>` |
|
||
| `New { … }` | `New { … }` | arm stays `unreachable!` (OQ3) |
|
||
| `Intrinsic` | `Intrinsic { ty }` | unit → struct variant |
|
||
|
||
**Arg access:** wherever an arm reads `&args[i]` (a `&Term`), it now
|
||
reads `&args[i].term` (the `MTerm` inside the `MArg`). Wherever it read
|
||
`self.synth_arg_type(&args[i])`, it reads `args[i].term.ty()`.
|
||
**Callee access:** `App`'s `callee` is `Callee::Indirect(Box<MTerm>)`
|
||
at mir.1b; unwrap it to reach the inner `MTerm` (see Task 2 Step 4).
|
||
**Imports:** each flipped file adds `use ailang_mir::{MTerm, MArg,
|
||
MArm, MLoopBinder, Callee, …}` and keeps `use ailang_core::ast::{…,
|
||
Pattern, Type, Literal}` (patterns/literals/types are unchanged).
|
||
|
||
---
|
||
|
||
## Task 1: `MirModule.ast` + codegen's `ailang-mir` dep (additive, green)
|
||
|
||
This task is additive — it extends a landed type and wires a dep;
|
||
nothing consumes the new field yet, so the whole suite stays green.
|
||
|
||
**Files:**
|
||
- Modify: `crates/ailang-mir/src/lib.rs`
|
||
- Modify: `crates/ailang-check/src/lower_to_mir.rs`
|
||
- Modify: `crates/ailang-codegen/Cargo.toml`
|
||
|
||
- [ ] **Step 1: Add `ast: Module` to `MirModule`**
|
||
|
||
In `crates/ailang-mir/src/lib.rs`, extend the import and the struct:
|
||
|
||
```rust
|
||
use ailang_core::ast::{Literal, Module, Pattern, Type};
|
||
```
|
||
```rust
|
||
#[derive(Debug, Clone)]
|
||
pub struct MirModule {
|
||
pub name: String,
|
||
/// The post-mono AST module. codegen reads structural data
|
||
/// (Def::Type / Def::Const / ctor layouts / intrinsic-fn markers /
|
||
/// imports) from here; the typed fn bodies come from `defs`. This
|
||
/// keeps `MirWorkspace` the single artefact codegen consumes —
|
||
/// structural reads were never a re-derivation, so carrying the AST
|
||
/// alongside the typed bodies does not reintroduce one.
|
||
pub ast: Module,
|
||
pub defs: Vec<MirDef>,
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Populate `ast` in `lower_module`**
|
||
|
||
In `crates/ailang-check/src/lower_to_mir.rs`, the `lower_module` return
|
||
already builds `MirModule { name, defs }` — add the `ast` field with
|
||
the post-mono module clone:
|
||
|
||
```rust
|
||
Ok(MirModule { name: module.name.clone(), ast: module.clone(), defs })
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add the `ailang-mir` dependency to codegen**
|
||
|
||
In `crates/ailang-codegen/Cargo.toml`, under `[dependencies]` (after
|
||
`ailang-core.workspace = true`), add:
|
||
|
||
```toml
|
||
ailang-mir.workspace = true
|
||
```
|
||
|
||
(No cycle: codegen → check → mir → core; `ailang-mir` is a leaf. This
|
||
edge lets codegen name `MTerm` / `MArg` / `Callee` / `MirWorkspace`
|
||
directly rather than only transitively.)
|
||
|
||
- [ ] **Step 4: Build gate — additive, nothing breaks**
|
||
|
||
Run: `cargo test --workspace`
|
||
Expected: PASS — whole suite green (the `ast` field is additive;
|
||
`lower_module` populates it; the mir.1a `lower_to_mir_ty` pins still
|
||
pass; codegen does not yet read `ast`). This confirms the foundation
|
||
before the atomic flip.
|
||
|
||
---
|
||
|
||
## Task 2: the atomic codegen flip (lib + all codegen tests)
|
||
|
||
The whole of codegen's lowering walk flips `&Term` → `&MTerm` in one
|
||
task — there is no compiling intermediate. Apply the correspondence
|
||
rule (above) at every `Term::*` match the recon enumerated; the steps
|
||
below give exact code for the judgement sites. The task's build gate
|
||
(`cargo test -p ailang-codegen --no-run`) is the totality checker:
|
||
it does not go green until every arm, signature, and caller inside the
|
||
codegen crate (lib + in-source tests + `tests/`) is converted.
|
||
|
||
**Files:**
|
||
- Modify: `crates/ailang-codegen/src/lib.rs`
|
||
- Modify: `crates/ailang-codegen/src/drop.rs`
|
||
- Modify: `crates/ailang-codegen/src/match_lower.rs`
|
||
- Modify: `crates/ailang-codegen/src/escape.rs`
|
||
- Modify: `crates/ailang-codegen/src/lambda.rs`
|
||
- Modify: `crates/ailang-codegen/tests/{embed_record_layout_pin,embed_staticlib_lowering,eq_primitives_pin}.rs`
|
||
|
||
- [ ] **Step 1: Flip the entry-point signatures + `lower_workspace_inner` pairing**
|
||
|
||
In `crates/ailang-codegen/src/lib.rs`, change the five entry points
|
||
to take `&MirWorkspace`:
|
||
|
||
```rust
|
||
pub fn lower_workspace_with_alloc(mir: &MirWorkspace, alloc: AllocStrategy) -> Result<String> {
|
||
lower_workspace_inner(mir, alloc, Target::Executable)
|
||
}
|
||
pub fn lower_workspace_staticlib_with_alloc(mir: &MirWorkspace, alloc: AllocStrategy) -> Result<String> {
|
||
lower_workspace_inner(mir, alloc, Target::StaticLib)
|
||
}
|
||
pub fn lower_workspace(mir: &MirWorkspace) -> Result<String> {
|
||
lower_workspace_inner(mir, AllocStrategy::Rc, Target::Executable)
|
||
}
|
||
pub fn lower_workspace_staticlib(mir: &MirWorkspace) -> Result<String> {
|
||
lower_workspace_inner(mir, AllocStrategy::Rc, Target::StaticLib)
|
||
}
|
||
fn lower_workspace_inner(mir: &MirWorkspace, alloc: AllocStrategy, target: Target) -> Result<String> {
|
||
// …
|
||
}
|
||
```
|
||
|
||
In `lower_workspace_inner`: the desugar block (lib.rs:295-313) is
|
||
**deleted** — `elaborate_workspace` already desugared + lifted + mono'd
|
||
before producing the MIR. The pass-1 symbol tables (lib.rs:333-396)
|
||
now iterate `mir.modules` and read each `mir_module.ast.defs` (AST
|
||
structure) instead of `ws.modules`'s `m.defs`. The pass-2 per-module
|
||
`Emitter` construction (lib.rs:400-455) hands the Emitter **both**
|
||
`&mir_module.ast` (for structural reads) and `&mir_module.defs` (for
|
||
bodies) — see Step 2. The entry-`main` check (lib.rs:460-472) reads
|
||
`mir.modules[&mir.entry].ast` for the `main` signature.
|
||
|
||
> **Implementer note.** Read `lower_workspace_inner`'s current body
|
||
> (lib.rs:294-472) and re-point every `ws`/`m.defs` read at
|
||
> `mir`/`mir_module.ast.defs`. The structure of the two passes is
|
||
> unchanged — only the source of each module's defs moves from the raw
|
||
> `Workspace` to `mir_module.ast`, and a second source (`mir_module.defs`)
|
||
> is added for bodies.
|
||
|
||
- [ ] **Step 2: Emitter holds AST module + MIR bodies; `emit_module` / `emit_fn` pairing**
|
||
|
||
The `Emitter` (lib.rs:730) currently holds `module: &Module`. Keep
|
||
that (it now points at `mir_module.ast`) and add a body source — a map
|
||
or slice of the module's `MirDef`s. Minimal shape: store
|
||
`mir_bodies: &'a [MirDef]` (from `mir_module.defs`) on the Emitter, and
|
||
have `emit_module` look up each fn's body by name.
|
||
|
||
`emit_module` (lib.rs:1054) iterates `self.module.defs` (AST) as today
|
||
for `Def::Type` / `Def::Const` / intrinsic markers. For each
|
||
`Def::Fn(f)`:
|
||
- If `f` is intrinsic-bodied (`matches!(f.body, Term::Intrinsic)` —
|
||
reads the AST `FnDef`, unchanged): handle via the intercept registry
|
||
as today; no MIR body.
|
||
- Else: look up the `MirDef` whose `name == f.name` in `self.mir_bodies`,
|
||
and call `emit_fn(f, &mir_def.body)` — `f` (AST `FnDef`) supplies
|
||
sig / params / export / the intrinsic marker; `&mir_def.body`
|
||
(`&MTerm`) supplies the typed body.
|
||
|
||
`emit_fn` (lib.rs:1265) signature gains the body: `fn emit_fn(&mut
|
||
self, f: &FnDef, body: &MTerm)`. Inside:
|
||
- The param-bind loop (lib.rs:1344) that pushed the AST param `Type`
|
||
into `locals`' 4th slot stays — param types come from `f.ty` via
|
||
`crate::fn_param_types`-equivalent (the AST signature), unchanged.
|
||
- `escape::analyze_fn_body(body)` (was `&f.body`) and
|
||
`self.lower_term(body)` (was `&f.body`) both take the **same**
|
||
borrowed `&MTerm` `body` (OQ2 — one borrow, shared, no clone).
|
||
- The intrinsic early-return (`matches!(f.body, Term::Intrinsic)`,
|
||
lib.rs:1394/1126) still reads the AST `f.body` — it is a
|
||
signature-only marker; unchanged.
|
||
|
||
> **Implementer note.** The exact Emitter field plumbing (lifetime on
|
||
> `mir_bodies`, how `emit_module` threads it) is yours to wire against
|
||
> the landed Emitter struct; the contract is: structural reads off
|
||
> `self.module` (= `mir_module.ast`), body off the matched
|
||
> `MirDef.body`, one `&MTerm` borrow shared between escape + lowering.
|
||
|
||
- [ ] **Step 3: Flip `lower_term` (lib.rs:1604) per the correspondence rule**
|
||
|
||
`fn lower_term(&mut self, t: &MTerm) -> Result<(String, String)>`.
|
||
Re-match every arm per the correspondence table. Children that were
|
||
`&Box<Term>` are now `&Box<MTerm>` (same `self.lower_term(child)`
|
||
call); arg lists that were `&[Term]` are `&[MArg]` (`self.lower_term(&arg.term)`).
|
||
Two arms need explicit handling:
|
||
|
||
- **`Lit` split.** Replace the single `Lit` arm with two:
|
||
```rust
|
||
MTerm::Lit { lit, .. } => { /* the existing non-Str literal lowering, unchanged */ }
|
||
MTerm::Str { lit, .. } => { /* the existing Literal::Str lowering body, lifted out of the old Lit arm */ }
|
||
```
|
||
(The old `Term::Lit` arm's `Literal::Str` branch becomes the `Str`
|
||
arm's body; the rep field is ignored at mir.1b.)
|
||
- **`Let` synth read (the binder type).** At lib.rs:1725, replace:
|
||
```rust
|
||
let val_ail = self.synth_arg_type(value)?;
|
||
```
|
||
with:
|
||
```rust
|
||
let val_ail = value.ty();
|
||
```
|
||
(`value` is the `&Box<MTerm>` init; `MTerm::ty()` returns the carried
|
||
`Type` directly — no `?`.) The 4th `locals` slot (lib.rs:1727) is now
|
||
sourced from this. The inherited-mode `Term::Var` read (lib.rs:1742)
|
||
becomes `MTerm::Var`.
|
||
|
||
- [ ] **Step 4: Flip `lower_app` (lib.rs:2340) — callee unwrap + the 2 arith synth reads**
|
||
|
||
`lower_app`'s `args: &[Term]` → `&[MArg]`; every `self.lower_term(&args[i])`
|
||
→ `self.lower_term(&args[i].term)`. The callee/name resolution
|
||
(`is_static_callee`, `type_home_module`) takes a `&str` name and is
|
||
**unchanged** (stays for mir.2). The `App` arm's static-dispatch test
|
||
(lib.rs:1918-1927) that currently reads `if let Term::Var { name } =
|
||
callee.as_ref()` now unwraps the `Callee`:
|
||
|
||
```rust
|
||
// callee is Callee::Indirect(Box<MTerm>) at mir.1b; the static name,
|
||
// if any, is the inner MTerm::Var. (mir.2 replaces this with
|
||
// Callee::Static and deletes is_static_callee.)
|
||
let callee_mterm: &MTerm = match callee {
|
||
Callee::Indirect(inner) => inner.as_ref(),
|
||
Callee::Static { .. } => unreachable!("callee is Indirect until mir.2"),
|
||
};
|
||
if let MTerm::Var { name, .. } = callee_mterm {
|
||
// … existing static-dispatch path on `name`, unchanged …
|
||
}
|
||
```
|
||
|
||
The two arithmetic/`neg` arg-type reads — lib.rs:2359 and lib.rs:2394:
|
||
```rust
|
||
let arg_ty = self.synth_arg_type(&args[0])?;
|
||
```
|
||
both become:
|
||
```rust
|
||
let arg_ty = args[0].term.ty();
|
||
```
|
||
(then the existing `builtin_binop_typed(name, &arg_ty)` / Int-vs-Float
|
||
match on `arg_ty` is unchanged).
|
||
|
||
> **Implementer note.** `lower_app` may be reached both from the
|
||
> `MTerm::App` arm of `lower_term` (callee + args already split) and as
|
||
> a helper. Confirm how the `App` arm calls `lower_app` (it currently
|
||
> destructures `Term::App { callee, args, tail }` then dispatches on the
|
||
> callee name); thread the `Callee` unwrap so `lower_app` still gets a
|
||
> `&str` name and `&[MArg]` args. `emit_call` (lib.rs:2625),
|
||
> `emit_indirect_call` (lib.rs:2724), `lower_effect_op` (lib.rs:2972)
|
||
> all take `args: &[Term]` → `&[MArg]`; their `args.iter()` /
|
||
> `.zip(...)` read `.term`.
|
||
|
||
- [ ] **Step 5: Delete `synth_with_extras` + `synth_arg_type`**
|
||
|
||
Delete `synth_arg_type` (lib.rs:3238-3240) and `synth_with_extras`
|
||
(lib.rs:3242-3519) entirely (~280 lines). Their internal calls to
|
||
`type_home_module` / `collect_owner_local_types` vanish with them
|
||
(those helpers stay — used elsewhere). The `module_def_ail_types`
|
||
reads *inside* `synth_with_extras` (lib.rs:3292/3301/3321) vanish; the
|
||
field itself stays (read at lib.rs:2687 for mir.3 mode work) and so do
|
||
its other readers.
|
||
|
||
Verify the field `module_def_ail_types` (lib.rs:759) has no remaining
|
||
reader that was only `synth_with_extras` — the `:2687` read (in
|
||
`emit_call`'s borrow-drop) stays. (If, after the delete, the field has
|
||
*zero* readers, leave it — mir.3 needs it; a `#[allow(dead_code)]` is
|
||
acceptable rather than removing and re-adding it. Confirm the `:2687`
|
||
reader survives.)
|
||
|
||
- [ ] **Step 6: Flip `drop.rs` (4 synth sites + signatures)**
|
||
|
||
Apply the correspondence rule to `drop.rs`'s walkers and rewrite the 4
|
||
synth reads:
|
||
- `is_rc_heap_allocated` (`:457`, `value: &Term` → `&MTerm`); arms
|
||
`:462/466/478` re-match `MTerm`; the `Loop` arm at `:487`:
|
||
```rust
|
||
match self.synth_arg_type(value) {
|
||
Ok(t) => { /* is_ptr && !is_str on t */ }
|
||
Err(_) => false,
|
||
}
|
||
```
|
||
becomes (no `Result`):
|
||
```rust
|
||
let t = value.ty();
|
||
let is_ptr = matches!(crate::synth::llvm_type(&t).as_deref(), Ok("ptr"));
|
||
let is_str = matches!(&t, Type::Con { name, .. } if name == "Str");
|
||
is_ptr && !is_str
|
||
```
|
||
- `synth_callee_ret_mode` (`:513`, `callee: &Term` → `&MTerm`): `:514`
|
||
`let cty = self.synth_arg_type(callee).ok()?;` → `let cty =
|
||
callee.ty();` (and the `match cty { Type::Fn { ret_mode, .. } =>
|
||
Some(ret_mode), _ => None }` stays).
|
||
- `drop_symbol_for_binder` (`:533`, `value: &Term` → `&MTerm`); arms
|
||
`:535/546/560` re-match `MTerm`; `:561` `if let Ok(Type::Con { name,
|
||
.. }) = self.synth_arg_type(value)` → `if let Type::Con { name, .. }
|
||
= value.ty()`.
|
||
- `emit_inlined_partial_drop` (`:844`, `value: &Term` → `&MTerm`); arm
|
||
`:851` re-match; `:862`
|
||
`self.synth_arg_type(value).ok().and_then(|ty| …)` →
|
||
`self.partial_drop_symbol_for_type(&value.ty())` (wrap to match the
|
||
surrounding `if let (Some(sym), Some(mask))` shape — `value.ty()`
|
||
is infallible, so the `.ok()` disappears).
|
||
- `:463` `term_ptr` identity cast `*const Term` → `*const MTerm`.
|
||
|
||
- [ ] **Step 7: Flip `match_lower.rs` (3 synth sites + signatures)**
|
||
|
||
- `lower_ctor` (`:42`, `args: &[Term]` → `&[MArg]`): `:82`
|
||
`.map(|a| self.synth_arg_type(a)).collect::<Result<_>>()?` →
|
||
`.map(|a| a.term.ty()).collect::<Vec<_>>()` (no `Result`); `:98/99`
|
||
lower `a.term`.
|
||
- `lower_reuse_as_rc` (`:179`, `source: &Term` → `&MTerm`, `body_args:
|
||
&[Term]` → `&[MArg]`): `:201` `Term::Var` → `MTerm::Var`; `:263`
|
||
same `.map(|a| a.term.ty())`; `:288` lower `a.term`.
|
||
- `lower_match` (`:440`, `scrutinee: &Term` → `&MTerm`, `arms: &[Arm]`
|
||
→ `&[MArm]`): `:445` `let s_ail = self.synth_arg_type(scrutinee)?;`
|
||
→ `let s_ail = scrutinee.ty();`; `:459/460` `Term::Var` → `MTerm::Var`;
|
||
`:476/477` the `Vec<(CtorRef, &Arm, …)>` → `&MArm`; `:480` `arm.pat`
|
||
and `:481/484/488` `Pattern::*` are **unchanged** (`MArm.pat` reuses
|
||
`ast::Pattern`); `arm.body` is now `MTerm`.
|
||
|
||
- [ ] **Step 8: Flip `escape.rs` + `lambda.rs` (pure walkers)**
|
||
|
||
`escape.rs`: `analyze_fn_body` (`:97`), `walk` (`:110`), `escapes`
|
||
(`:227`), `collect_free_vars` (`:446`) flip `&Term` → `&MTerm`; all
|
||
`Term::*` arms re-match `MTerm::*` (the `Str` literal becomes its own
|
||
arm); `pattern_bound_names` (`:430`, `&Pattern`) is **unchanged**.
|
||
Import (`:83`): add `use ailang_mir::MTerm;` (keep `Pattern`/`NewArg`
|
||
from `ast`). The `#[cfg(test)]` `ctor(...) -> Term` helper (`:574`):
|
||
if the escape unit tests build `Term` directly, either flip the helper
|
||
to build `MTerm` or keep the tests AST-only by testing a thin `MTerm`
|
||
wrapper — implementer judgement; the gate is that `escape`'s tests
|
||
compile and pass.
|
||
|
||
`lambda.rs`: `lower_lambda` (`:39`, `lam_body: &Term` → `&MTerm`;
|
||
`:222` calls `lower_term`), and the thunk-body walker / `collect_captures`
|
||
(`:373`, `t: &Term` → `&MTerm`); `pattern_bound_names` (`:509`,
|
||
`&Pattern`) unchanged.
|
||
|
||
- [ ] **Step 9: Flip `emit_ir` (lib.rs:213) — build MIR internally**
|
||
|
||
`emit_ir` keeps `&Module`. Replace its body's terminal
|
||
`lower_workspace(&ws)` (lib.rs:230) with an `elaborate_workspace` call,
|
||
converting the diagnostics error to a `CodegenError`:
|
||
|
||
```rust
|
||
pub fn emit_ir(m: &Module) -> Result<String> {
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert(m.name.clone(), m.clone());
|
||
let ws = Workspace {
|
||
entry: m.name.clone(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let mir = ailang_check::elaborate_workspace(&ws)
|
||
.map_err(|diags| CodegenError::Internal(format!(
|
||
"elaborate failed: {}",
|
||
diags.iter().map(|d| d.message.clone()).collect::<Vec<_>>().join("; ")
|
||
)))?;
|
||
lower_workspace(&mir)
|
||
}
|
||
```
|
||
|
||
> **Implementer note.** Confirm `CodegenError::Internal(String)` is the
|
||
> right variant (grep the enum); confirm `Diagnostic` exposes `.message`
|
||
> (else use its `Display`). `ailang_check` is a direct dep of codegen.
|
||
> The two in-source `#[cfg(test)]` `lower_workspace_with_alloc` callers
|
||
> (lib.rs:3709/3728) build a `ws` via test helpers — flip them to
|
||
> `ailang_check::elaborate_workspace(&ws)` then pass `&mir`. The
|
||
> ill-typed `emit_ir` test (`eq` on ADT, `unwrap_err`) now errors from
|
||
> `check` inside `elaborate` — `unwrap_err` still holds.
|
||
|
||
- [ ] **Step 10: Flip codegen's `tests/` integration callers**
|
||
|
||
- `eq_primitives_pin.rs:30` `lower_workspace(&ws)` — it already runs
|
||
`check_workspace` + `monomorphise_workspace` (`:23-29`); collapse
|
||
that block to `let mir = ailang_check::elaborate_workspace(&ws).expect("elaborates"); lower_workspace(&mir)`.
|
||
- `embed_record_layout_pin.rs:28` `lower_workspace_with_alloc(&ws, Rc)`
|
||
and `embed_staticlib_lowering.rs:23/58/78`
|
||
(`lower_workspace_staticlib_with_alloc` ×2, `lower_workspace_with_alloc`)
|
||
— these do not yet import `ailang_check`; add
|
||
`use ailang_check::elaborate_workspace;`, build `let mir =
|
||
elaborate_workspace(&ws).expect("elaborates");` from the
|
||
`load_workspace` `ws`, and pass `&mir` to the lowering call. (`ailang-check`
|
||
is reachable from codegen's own integration tests — confirmed by
|
||
`eq_primitives_pin.rs:9`'s existing `use ailang_check::…`.)
|
||
|
||
- [ ] **Step 11: Build gate — codegen crate fully converted**
|
||
|
||
Run: `cargo test -p ailang-codegen 2>&1 | tail -20`
|
||
Expected: PASS — codegen lib + in-source tests + `tests/` integration
|
||
all compile and pass. The `synth_with_extras` / `synth_arg_type`
|
||
deletion is grep-clean within codegen:
|
||
|
||
Run: `git grep -nE 'synth_with_extras|synth_arg_type' crates/ailang-codegen/src/`
|
||
Expected: no matches (only possibly a doc-comment mention to reword —
|
||
if any remain in prose, reword them; the recon found module/fn
|
||
doc-comments naming `synth_arg_type` at match_lower.rs:18,
|
||
subst.rs:75, synth.rs:60/204, drop.rs:856 — reword these to describe
|
||
the `MTerm::ty()` read).
|
||
|
||
---
|
||
|
||
## Task 3: external callers (CLI build paths + e2e)
|
||
|
||
The entry-point signature change to `&MirWorkspace` breaks the callers
|
||
in `crates/ail`. Thread them all here; the gate is the full workspace.
|
||
|
||
**Files:**
|
||
- Modify: `crates/ail/src/main.rs`
|
||
- Modify: `crates/ail/tests/e2e.rs`
|
||
|
||
- [ ] **Step 1: CLI `build` path → `elaborate_workspace` (+ OQ4 diagnostics)**
|
||
|
||
In `crates/ail/src/main.rs`, the `build_to` fn (`:2286+`): replace the
|
||
block that runs `check_workspace` (`:2293`) + per-module desugar/lift
|
||
(`:2320-2330`) + `monomorphise_workspace` (`:2346`) +
|
||
`lower_workspace_with_alloc(&ws, alloc)` (`:2348`) with:
|
||
|
||
```rust
|
||
let mir = match ailang_check::elaborate_workspace(&ws) {
|
||
Ok(mir) => mir,
|
||
Err(diags) => {
|
||
for d in &diags {
|
||
eprintln!("{}: [{}] {}", /* severity */ d.severity_str(), d.code, d.message);
|
||
}
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
let ir = ailang_codegen::lower_workspace_with_alloc(&mir, alloc)?;
|
||
```
|
||
|
||
> **Implementer note.** Match the existing diagnostic-print format used
|
||
> by the `ail check` human path (main.rs ~:611-620) — reuse the same
|
||
> `eprintln!` shape (severity / code / message / span) so build-time
|
||
> and check-time diagnostics read identically. Drop `build_to`'s own
|
||
> separate `check_workspace` call (OQ4: `elaborate_workspace` runs it
|
||
> internally). The `has_export` gate and any read of `ws.modules…f.export`
|
||
> stays on the **pre-elaborate AST `ws`** (it is structural, not in MIR
|
||
> at the CLI level) — confirm it reads `ws`, not `mir`.
|
||
|
||
- [ ] **Step 2: CLI `staticlib` build path → `elaborate_workspace`**
|
||
|
||
`build_staticlib` (`:2456+`): same shape — replace its `check_workspace`
|
||
(`:2464`) + lift (`:2482-2488`) + `monomorphise_workspace` (`:2495`) +
|
||
`lower_workspace_staticlib_with_alloc(&ws, alloc)` (`:2512`) with an
|
||
`elaborate_workspace` call + `lower_workspace_staticlib_with_alloc(&mir,
|
||
alloc)`. The `has_export` check (`:2499-2505`) stays on the AST `ws`.
|
||
|
||
- [ ] **Step 3: CLI `emit-ir` path → `elaborate_workspace`**
|
||
|
||
`Cmd::EmitIr` arm (`:647+`): it currently prints diagnostics from its
|
||
own `check_workspace` (`:651`) then lifts/monos (`:682-695`) then calls
|
||
`lower_workspace` / `lower_workspace_staticlib` (`:705/707`). Replace
|
||
with `elaborate_workspace`, driving diagnostics from its `Err` (OQ4):
|
||
|
||
```rust
|
||
let mir = match ailang_check::elaborate_workspace(&ws) {
|
||
Ok(mir) => mir,
|
||
Err(diags) => { /* print diags as above */ std::process::exit(1); }
|
||
};
|
||
let ir = if emit == "staticlib" {
|
||
ailang_codegen::lower_workspace_staticlib(&mir)?
|
||
} else {
|
||
ailang_codegen::lower_workspace(&mir)?
|
||
};
|
||
```
|
||
|
||
The `has_export` gate (`:697-704`) stays on the AST `ws`.
|
||
|
||
- [ ] **Step 4: e2e.rs — 6 `lower_workspace_with_alloc` callers**
|
||
|
||
In `crates/ail/tests/e2e.rs`, each of the 6 callers (`:1537/1708/1782/1871/1994/2084`)
|
||
is preceded by its own load + lift + mono block. For each: replace the
|
||
lift/mono block with `let mir = ailang_check::elaborate_workspace(&ws).expect("elaborates");`
|
||
and pass `&mir` to `lower_workspace_with_alloc(&mir, AllocStrategy::Rc)`.
|
||
|
||
> **Implementer note.** Read each caller's preceding block (e.g.
|
||
> `:1517-1536` for the first) and collapse its explicit
|
||
> desugar/lift/mono into the single `elaborate_workspace` call. The
|
||
> `ws` source (a `load_workspace` or hand-built workspace) stays; only
|
||
> the lift→mono→lower bridge changes.
|
||
|
||
- [ ] **Step 5: Full workspace gate**
|
||
|
||
Run: `cargo test --workspace 2>&1 | grep -E 'test result:|error\[|FAILED' | grep -v 'ok\.' | sort | uniq -c`
|
||
Expected: no `error[` / no `FAILED` lines.
|
||
|
||
Run: `cargo test --workspace 2>&1 | grep -c 'test result: ok'`
|
||
Expected: the same count as before mir.1b (102) — every existing test
|
||
still green, the #49 pin still the single `ignored` (lifts at mir.4).
|
||
|
||
- [ ] **Step 6: Acceptance — #51/#53 still build + run; synth grep-clean**
|
||
|
||
Run (the two regression witnesses build and run unchanged):
|
||
`cargo run -q -p ail -- run examples/new_counter_user_adt.ail`
|
||
Expected: prints `ok` (exit 0).
|
||
Run: `cargo run -q -p ail -- run examples/new_rawbuf_size_only.ail`
|
||
Expected: builds and runs (prints the size; exit 0).
|
||
|
||
Run (no type re-derivation left in codegen):
|
||
`git grep -nE 'synth_with_extras|synth_arg_type' crates/ailang-codegen/src/`
|
||
Expected: no matches.
|
||
|
||
---
|
||
|
||
## Notes for the orchestrator (commit + handoff)
|
||
|
||
- **Commit shape:** one cohesive commit — the atomic switch is one
|
||
logical change. Subject e.g. `feat(codegen): mir.1b — codegen
|
||
consumes MIR; delete the synth_with_extras type-derivation mirror`.
|
||
Body: the boundary now reads `ty` off MIR (one engine, not two); the
|
||
three other re-derivers stay (mir.2/mir.3); MirModule carries the AST
|
||
for structural reads (single artefact preserved); `refs #49` (closure
|
||
at mir.4).
|
||
- **Spec mir.1 row is now complete** across mir.1a + mir.1b: structural
|
||
MIR + `ty` landed; `synth_with_extras` + `synth_arg_type` gone.
|
||
- **Next (mir.2):** `Callee::Static` pre-resolved; delete
|
||
`type_home_module` ×3 + `is_static_callee`; strike the `lower_app ↔
|
||
is_static_callee` lockstep pair from CLAUDE.md.
|