Files
AILang/docs/plans/0114-mir.1a-mir-infrastructure.md
Brummel 438a009b83 plan: mir.1a — typed-MIR infrastructure (additive half of spec mir.1)
First of two plans for spec iteration mir.1 (docs/specs/0060-typed-mir.md).
mir.1 is the project's largest iteration — a full codegen frontend
switch from walking &Term to walking &MTerm — and it does not fit one
bite-sized plan, because the switch is atomic: there is no compiling
intermediate between "all Term" and "all MTerm" (dual-path is
spec-forbidden). The MIR *producer* side, by contrast, compiles and
tests in isolation. So mir.1 is sequenced across two plans:

- mir.1a (this plan): the additive producer — new leaf crate
  ailang-mir (MTerm/MArg/Callee/Mode/StrRep, structurally complete over
  all 17 Term variants plus the Str split), ailang-check::lower_to_mir
  (post-mono walk that calls canonical synth per node and maintains
  locals/loop_stack exactly as synth does — no second type engine), and
  ailang-check::elaborate_workspace (check → desugar+lift → mono →
  lower_to_mir). Nothing consumes MIR yet; codegen untouched; whole
  suite stays green; three ty-fill pins load the #51/#53/#49 witnesses
  as fixtures and elaborate the full workspace (prelude included).

- mir.1b (next plan, against the landed types): flip codegen's walk to
  &MTerm, delete synth_with_extras + synth_arg_type (9 call sites across
  lib.rs/drop.rs/match_lower.rs), thread the 18 lower_workspace* callers,
  switch the CLI build path to elaborate_workspace.

Both together satisfy the spec's mir.1 row. The split is plan
granularity, not a spec change — the spec's mir.1 deliverable is
unchanged.

Orchestrator design calls baked in (planner judgement, no user fork):
dedicated MTerm::Str{lit,rep} variant (Str-split installs the mir.4
hook at the #49 loop seed); emit_ir keeps &Module (builds MIR
internally — minimal blast radius); lower_to_mir scaffolding mirrors
mono.rs:771-816; Match-arm binding reuses synth's own type_check_pattern.

Recon (plan-recon) mapped the full deletion blast radius for mir.1b
(9 synth_arg_type sites, not the 3 the focus-hint assumed; 18
lower_workspace* callers). The two new witness fixtures
(new_rawbuf_size_only, new_counter_user_adt) were verified ail
check-clean during planning.
2026-05-31 13:51:33 +02:00

951 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# mir.1a — MIR infrastructure (additive) — 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:** Land the typed-MIR types (`ailang-mir`), the post-mono
lowering walk (`ailang-check::lower_to_mir`), and the front-end entry
(`ailang-check::elaborate_workspace`) as a **purely additive** layer —
nothing consumes MIR yet, codegen is untouched, the whole existing
suite stays green, and new tests pin that `lower_to_mir` fills `ty`
correctly on the three boundary witnesses.
**Architecture:** This is the first half of spec iteration mir.1. The
spec's mir.1 row ("structural MIR + `ty` + delete `synth_with_extras`")
is reached across two plans: **mir.1a** (this one) builds the MIR
producer additively; **mir.1b** (next plan) flips codegen to consume
MIR and deletes `synth_with_extras`/`synth_arg_type`. The split is
forced by the compile graph: the codegen walk switch from `&Term` to
`&MTerm` is atomic (no compiling intermediate, dual-path is
spec-forbidden), whereas the producer side compiles and tests in
isolation. `lower_to_mir` carries **no** second type engine: it calls
check's canonical `synth` per node (with throwaway inference
scaffolding) and maintains `locals`/`loop_stack` during descent exactly
as `synth` does, so the type on every MIR node is the checker's own.
**Tech Stack:** new leaf crate `ailang-mir` (depends only on
`ailang-core`); new module + entry in `ailang-check`; new integration
test in `ailang-check/tests/`. No change to `ailang-codegen`,
`crates/ail`, or any authoring/hash surface.
---
## Design decisions baked into this plan (orchestrator, from spec + recon)
- **Str split (spec line 195 made literal).** `MTerm` has a dedicated
`Str { lit, rep }` variant *distinct from* `Lit { lit, ty }`.
`lower_to_mir` routes `Term::Lit { Literal::Str }``MTerm::Str`
with `rep: StrRep::Static`; every other `Literal``MTerm::Lit`.
This installs the mir.4 hook at the exact node mir.4 needs (the #49
loop seed `"x"` is a `Str` literal whose `rep` flips to `Heap`).
- **Structurally complete from mir.1a.** Every MIR field that later
iterations *fill* already *exists* now, at a mir.1 default:
`App.callee = Callee::Indirect(<lowered callee>)` (→ `Static` in
mir.2), `MArg.mode = Owned`, `MArg.consume_count = 1` (→ filled
mir.3), `Str.rep = Static` (→ `Heap` for loop-carried seeds in
mir.4), `New.elem = None` (→ filled mir.5). The MIR *struct shape*
does not change across mir.2mir.5; only the values do.
- **One engine.** `lower_to_mir` never re-derives a type. It calls
`crate::synth` (canonical, `pub(crate)`, `lib.rs:3223`) on each node
with **fresh throwaway** `subst`/`counter`/`residuals`/`free_fn_calls`/
`effects`/`warnings`, and applies the resulting `subst` before
reading the type — so no node pollutes another and the real check
state is never touched. The only state `lower_to_mir` threads is the
lexical `locals`/`loop_stack`, maintained by the same push/pop rules
`synth` uses (`Term::Let` arm template at `lib.rs:3703-3715`).
- **`elaborate_workspace` env construction mirrors mono.** Per-def
scaffolding (env, per-module `imports`, `locals` from
`params.zip(param_tys)`) is built exactly as
`crates/ailang-check/src/mono.rs:771-816` already does for its
post-typecheck synth re-entry.
**Files this plan creates or modifies:**
- Create: `crates/ailang-mir/Cargo.toml` — new leaf crate manifest.
- Create: `crates/ailang-mir/src/lib.rs` — MIR types + `MTerm::ty()`.
- Create: `crates/ailang-check/src/lower_to_mir.rs` — the post-mono
lowering walk.
- Modify: `Cargo.toml:3-10` — add `crates/ailang-mir` to members.
- Modify: `crates/ailang-check/Cargo.toml:7-9` — add `ailang-mir` dep.
- Modify: `crates/ailang-check/src/lib.rs:372-379` — `pub mod
lower_to_mir;`; add `elaborate_workspace`; widen `type_check_pattern`
to `pub(crate)`.
- Create: `examples/new_rawbuf_size_only.ail` — #51 witness fixture.
- Create: `examples/new_counter_user_adt.ail` — #53 witness fixture.
(#49 reuses the existing `examples/loop_recur_str_binder_no_leak_pin.ail`.)
- Test: `crates/ailang-check/tests/lower_to_mir_ty.rs` — `ty`-fill
correctness on the #51 / #53 / #49 witnesses + the Str-split rep.
---
## Task 1: `ailang-mir` crate — the bridge types
**Files:**
- Create: `crates/ailang-mir/Cargo.toml`
- Create: `crates/ailang-mir/src/lib.rs`
- Modify: `Cargo.toml:3-10`
- [ ] **Step 1: Create the crate manifest**
Create `crates/ailang-mir/Cargo.toml`:
```toml
[package]
name = "ailang-mir"
version = "0.1.0"
edition = "2021"
[dependencies]
ailang-core = { path = "../ailang-core" }
```
- [ ] **Step 2: Add the crate to the workspace members list**
In `Cargo.toml`, the `members` array (lines 3-10) — add the new member
after `"crates/ailang-core"` so the leaf sorts near its only dep:
```toml
members = [
"crates/ailang-core",
"crates/ailang-mir",
"crates/ailang-check",
"crates/ailang-codegen",
"crates/ailang-kernel",
"crates/ailang-surface",
"crates/ailang-prose",
"crates/ail",
]
```
- [ ] **Step 3: Write the MIR types**
Create `crates/ailang-mir/src/lib.rs`. Every `Term` variant
(`ailang-core/src/ast.rs:436`) gets one `MTerm` counterpart, plus the
`Str` split. Each node carries `ty` (the checker's type) and the
later-iteration annotation fields at their mir.1 defaults:
```rust
//! Typed mid-level IR — the single artefact that crosses the
//! `check` → `codegen` boundary. Produced by
//! `ailang_check::lower_to_mir` from a monomorphised, typechecked
//! `Workspace`; consumed by `ailang_codegen`. Every node carries the
//! facts codegen used to re-derive: its type (`ty`), the resolved
//! callee (`Callee`, filled mir.2), parameter modes / consume counts
//! (`Mode` / `consume_count`, filled mir.3), and string representation
//! (`StrRep`, filled mir.4). MIR is never authored, never hashed,
//! never round-tripped — it is a compiler-internal derived form.
use ailang_core::ast::{Literal, Pattern, Type};
use std::collections::BTreeMap;
/// A whole monomorphised program, lowered to MIR.
#[derive(Debug, Clone)]
pub struct MirWorkspace {
pub entry: String,
pub modules: BTreeMap<String, MirModule>,
}
#[derive(Debug, Clone)]
pub struct MirModule {
pub name: String,
pub defs: Vec<MirDef>,
}
/// One lowered top-level fn. `sig` is the declared signature; `body`
/// is the lowered fn body with `ty` on every node.
#[derive(Debug, Clone)]
pub struct MirDef {
pub name: String,
pub sig: Type,
pub params: Vec<String>,
pub export: bool,
pub body: MTerm,
}
/// One MIR node — structural counterpart of `ast::Term`, plus the
/// `Str` split. `ty` is the checker-proved type at this node.
#[derive(Debug, Clone)]
pub enum MTerm {
Lit { lit: Literal, ty: Type },
/// String literal — the `Str`-representation carrier. `rep` is
/// `Static` for every literal at mir.1; mir.4 flips loop-carried
/// seeds to `Heap`. Split out of `Lit` so the rep annotation has a
/// home (spec §"Str split").
Str { lit: String, rep: StrRep },
Var { name: String, ty: Type },
/// `callee` is `Indirect(<lowered callee>)` at mir.1; mir.2
/// resolves static targets to `Callee::Static`.
App { callee: Callee, args: Vec<MArg>, tail: bool, ty: Type },
Let { name: String, mode: Mode, init: Box<MTerm>, body: Box<MTerm>, ty: Type },
LetRec {
name: String,
sig: Type,
params: Vec<String>,
body: Box<MTerm>,
in_term: Box<MTerm>,
ty: Type,
},
If { cond: Box<MTerm>, then: Box<MTerm>, else_: Box<MTerm>, ty: Type },
Do { op: String, args: Vec<MArg>, tail: bool, ty: Type },
Ctor { type_name: String, ctor: String, args: Vec<MArg>, ty: Type },
Match { scrutinee: Box<MTerm>, arms: Vec<MArm>, ty: Type },
Lam {
params: Vec<String>,
param_tys: Vec<Type>,
ret_ty: Type,
effects: Vec<String>,
body: Box<MTerm>,
ty: Type,
},
Seq { lhs: Box<MTerm>, rhs: Box<MTerm>, ty: Type },
Clone { value: Box<MTerm>, ty: Type },
ReuseAs { source: Box<MTerm>, body: Box<MTerm>, ty: Type },
Loop { binders: Vec<MLoopBinder>, body: Box<MTerm>, ty: Type },
Recur { args: Vec<MArg>, ty: Type },
/// `elem` is `None` at mir.1; mir.5 carries the element type.
New { type_name: String, elem: Option<Type>, args: Vec<MNewArg>, ty: Type },
Intrinsic { ty: Type },
}
/// A match arm. Patterns stay structural (`ast::Pattern`) — they carry
/// no type annotation the boundary re-derives.
#[derive(Debug, Clone)]
pub struct MArm {
pub pat: Pattern,
pub body: MTerm,
}
#[derive(Debug, Clone)]
pub struct MLoopBinder {
pub name: String,
pub ty: Type,
pub init: MTerm,
}
#[derive(Debug, Clone)]
pub enum MNewArg {
Type(Type),
Value(MTerm),
}
/// A call/ctor/recur argument with its mode and consume count. Both
/// fields are mir.1 defaults (`Owned` / `1`); mir.3 fills them.
#[derive(Debug, Clone)]
pub struct MArg {
pub term: MTerm,
pub mode: Mode,
pub consume_count: u32,
}
#[derive(Debug, Clone)]
pub enum Callee {
Static { module: String, fn_name: String },
Indirect(Box<MTerm>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StrRep {
Heap,
Static,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Owned,
Borrow,
}
impl MTerm {
/// The checker-proved type at this node. `Str` is always `Str`.
pub fn ty(&self) -> Type {
match self {
MTerm::Lit { ty, .. }
| MTerm::Var { ty, .. }
| MTerm::App { ty, .. }
| MTerm::Let { ty, .. }
| MTerm::LetRec { ty, .. }
| MTerm::If { ty, .. }
| MTerm::Do { ty, .. }
| MTerm::Ctor { ty, .. }
| MTerm::Match { ty, .. }
| MTerm::Lam { ty, .. }
| MTerm::Seq { ty, .. }
| MTerm::Clone { ty, .. }
| MTerm::ReuseAs { ty, .. }
| MTerm::Loop { ty, .. }
| MTerm::Recur { ty, .. }
| MTerm::New { ty, .. }
| MTerm::Intrinsic { ty } => ty.clone(),
MTerm::Str { .. } => Type::str_(),
}
}
}
```
- [ ] **Step 4: Build the new crate in isolation**
Run: `cargo build -p ailang-mir`
Expected: compiles, 0 errors. (If `Type::str_()` is not a `Type`
constructor in `ailang-core`, the build errors here — resolve by using
the same constructor `synth`'s `Lit` arm uses at `lib.rs:3254`, which
is `Type::str_()`; this step is the gate that confirms it.)
---
## Task 2: `ailang-check::lower_to_mir` — the post-mono walk
**Files:**
- Create: `crates/ailang-check/src/lower_to_mir.rs`
- Modify: `crates/ailang-check/Cargo.toml:7-9`
- Modify: `crates/ailang-check/src/lib.rs:372-379`
- [ ] **Step 1: Add the `ailang-mir` dependency to `ailang-check`**
In `crates/ailang-check/Cargo.toml`, under `[dependencies]` (the block
at lines 7-9, currently `ailang-core` + `ailang-surface`), add:
```toml
ailang-mir = { path = "../ailang-mir" }
```
- [ ] **Step 2: Declare the module**
In `crates/ailang-check/src/lib.rs`, in the `pub mod` / `pub use` block
around lines 372-379 (where `lift`, `mono`, `diagnostic` are declared),
add:
```rust
pub mod lower_to_mir;
```
- [ ] **Step 3: Write the lowering walk**
Create `crates/ailang-check/src/lower_to_mir.rs`. The walk takes a
typechecked, **post-mono** `Module`, and for each `FnDef` builds an
`MirDef` whose body is the `Term` lowered to `MTerm` with `ty` on every
node. `synth_pure` is the single type oracle (canonical `synth`, fresh
throwaway inference state); `lower_term` maintains `locals`/`loop_stack`
by the same push/pop rules `synth` uses.
```rust
//! Post-monomorphisation lowering from the typechecked `ast::Term`
//! to typed `MTerm`. Runs after `monomorphise_workspace`. Carries no
//! second type engine: each node's type comes from a fresh-scaffolding
//! call to the canonical `crate::synth` (lib.rs:3223). The only state
//! threaded across nodes is the lexical `locals` / `loop_stack`,
//! maintained exactly as `synth` maintains it (e.g. the `Term::Let`
//! push/pop at lib.rs:3703-3715).
use ailang_core::ast::{Literal, Module, Term, Type};
use ailang_mir::{
Callee, MArg, MArm, MLoopBinder, MNewArg, MTerm, MirDef, MirModule, Mode, StrRep,
};
use indexmap::IndexMap;
use std::collections::BTreeSet;
use crate::{Env, Result};
/// Per-walk scaffolding for the canonical `synth` re-entry. `env` and
/// `in_def` are fixed per fn; `locals` / `loop_stack` are mutated as
/// the walk descends and restored on the way up, mirroring `synth`.
struct Ctx<'a> {
env: &'a Env,
in_def: &'a str,
locals: IndexMap<String, Type>,
loop_stack: Vec<Vec<(String, Type)>>,
}
impl<'a> Ctx<'a> {
/// The canonical type of `t` in the current lexical scope, with no
/// side effects on real check state. Fresh inference scaffolding
/// per call; the result has its substitution applied so downstream
/// reads see a resolved type.
fn synth_pure(&mut self, t: &Term) -> Result<Type> {
let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = crate::Subst::default();
let mut counter: u32 = 0;
let mut residuals: Vec<crate::ResidualConstraint> = Vec::new();
let mut free_fn_calls: Vec<crate::FreeFnCall> = Vec::new();
let mut warnings: Vec<crate::diagnostic::Diagnostic> = Vec::new();
let ty = crate::synth(
t,
self.env,
&mut self.locals,
&mut self.loop_stack,
&mut effects,
self.in_def,
&mut subst,
&mut counter,
&mut residuals,
&mut free_fn_calls,
&mut warnings,
)?;
Ok(subst.apply(&ty))
}
}
/// Default arg wrapper — mode/consume_count are mir.1 placeholders
/// (mir.3 fills them).
fn arg(term: MTerm) -> MArg {
MArg { term, mode: Mode::Owned, consume_count: 1 }
}
/// Lower one term to `MTerm`, filling `ty` from `synth_pure`.
fn lower_term(ctx: &mut Ctx, t: &Term) -> Result<MTerm> {
let ty = ctx.synth_pure(t)?;
Ok(match t {
// ---- String literal: the rep carrier (Static at mir.1) ----
Term::Lit { lit: Literal::Str { value } } => {
MTerm::Str { lit: value.clone(), rep: StrRep::Static }
}
Term::Lit { lit } => MTerm::Lit { lit: lit.clone(), ty },
Term::Var { name } => MTerm::Var { name: name.clone(), ty },
// callee is Indirect at mir.1 (mir.2 resolves Static).
Term::App { callee, args, tail } => {
let m_callee = Callee::Indirect(Box::new(lower_term(ctx, callee)?));
let m_args = args
.iter()
.map(|a| Ok(arg(lower_term(ctx, a)?)))
.collect::<Result<Vec<_>>>()?;
MTerm::App { callee: m_callee, args: m_args, tail: *tail, ty }
}
// scope-affecting: insert binder type, lower body, restore —
// exactly synth's Term::Let arm (lib.rs:3703-3715).
Term::Let { name, value, body } => {
let m_init = lower_term(ctx, value)?;
let v_ty = ctx.synth_pure(value)?;
let prev = ctx.locals.insert(name.clone(), v_ty);
let m_body = lower_term(ctx, body)?;
match prev {
Some(p) => {
ctx.locals.insert(name.clone(), p);
}
None => {
ctx.locals.shift_remove(name);
}
}
MTerm::Let {
name: name.clone(),
mode: Mode::Owned,
init: Box::new(m_init),
body: Box::new(m_body),
ty,
}
}
Term::If { cond, then, else_ } => MTerm::If {
cond: Box::new(lower_term(ctx, cond)?),
then: Box::new(lower_term(ctx, then)?),
else_: Box::new(lower_term(ctx, else_)?),
ty,
},
Term::Do { op, args, tail } => {
let m_args = args
.iter()
.map(|a| Ok(arg(lower_term(ctx, a)?)))
.collect::<Result<Vec<_>>>()?;
MTerm::Do { op: op.clone(), args: m_args, tail: *tail, ty }
}
Term::Ctor { type_name, ctor, args } => {
let m_args = args
.iter()
.map(|a| Ok(arg(lower_term(ctx, a)?)))
.collect::<Result<Vec<_>>>()?;
MTerm::Ctor {
type_name: type_name.clone(),
ctor: ctor.clone(),
args: m_args,
ty,
}
}
// scope-affecting: each arm binds its pattern vars. Mirror
// synth's Match arm (lib.rs:3879-3896) exactly — including its
// own helper `type_check_pattern`, which resolves the binder
// types from the scrutinee's ADT. Push, lower the arm body,
// restore (innermost-first), per arm.
Term::Match { scrutinee, arms } => {
let m_scrut = Box::new(lower_term(ctx, scrutinee)?);
let s_ty = ctx.synth_pure(scrutinee)?;
let mut m_arms = Vec::with_capacity(arms.len());
for a in arms {
let bindings = crate::type_check_pattern(&a.pat, &s_ty, ctx.env)?;
let mut pushed = Vec::new();
for (n, t) in &bindings {
let prev = ctx.locals.insert(n.clone(), t.clone());
pushed.push((n.clone(), prev));
}
let m_body = lower_term(ctx, &a.body)?;
for (n, prev) in pushed.into_iter().rev() {
match prev {
Some(p) => {
ctx.locals.insert(n, p);
}
None => {
ctx.locals.shift_remove(&n);
}
}
}
m_arms.push(MArm { pat: a.pat.clone(), body: m_body });
}
MTerm::Match { scrutinee: m_scrut, arms: m_arms, ty }
}
// scope-affecting: params enter scope for the body. Mirror
// synth's Lam arm (lib.rs:3965): insert each param:param_ty
// into locals for the body, restore after.
Term::Lam { params, param_tys, ret_ty, effects, body } => {
let saved = ctx.locals.clone();
for (p, pty) in params.iter().zip(param_tys.iter()) {
ctx.locals.insert(p.clone(), pty.clone());
}
let m_body = Box::new(lower_term(ctx, body)?);
ctx.locals = saved;
MTerm::Lam {
params: params.clone(),
param_tys: param_tys.clone(),
ret_ty: (**ret_ty).clone(),
effects: effects.clone(),
body: m_body,
ty,
}
}
Term::Seq { lhs, rhs } => MTerm::Seq {
lhs: Box::new(lower_term(ctx, lhs)?),
rhs: Box::new(lower_term(ctx, rhs)?),
ty,
},
Term::Clone { value } => MTerm::Clone {
value: Box::new(lower_term(ctx, value)?),
ty,
},
Term::ReuseAs { source, body } => MTerm::ReuseAs {
source: Box::new(lower_term(ctx, source)?),
body: Box::new(lower_term(ctx, body)?),
ty,
},
// scope-affecting: binders enter scope and a loop frame is
// pushed for the body so an inner Recur resolves. Mirror
// synth's Loop arm (lib.rs:4189): push the binder frame onto
// loop_stack and the binder types onto locals before lowering
// the body, pop/restore after.
Term::Loop { binders, body } => {
let mut m_binders = Vec::with_capacity(binders.len());
for b in binders {
m_binders.push(MLoopBinder {
name: b.name.clone(),
ty: b.ty.clone(),
init: lower_term(ctx, &b.init)?,
});
}
let saved_locals = ctx.locals.clone();
let frame: Vec<(String, Type)> =
binders.iter().map(|b| (b.name.clone(), b.ty.clone())).collect();
for b in binders {
ctx.locals.insert(b.name.clone(), b.ty.clone());
}
ctx.loop_stack.push(frame);
let m_body = Box::new(lower_term(ctx, body)?);
ctx.loop_stack.pop();
ctx.locals = saved_locals;
MTerm::Loop { binders: m_binders, body: m_body, ty }
}
Term::Recur { args } => {
let m_args = args
.iter()
.map(|a| Ok(arg(lower_term(ctx, a)?)))
.collect::<Result<Vec<_>>>()?;
MTerm::Recur { args: m_args, ty }
}
// elem is None at mir.1 (mir.5 carries the element type).
Term::New { type_name, args } => {
let m_args = args
.iter()
.map(|a| {
Ok(match a {
ailang_core::ast::NewArg::Type(t) => MNewArg::Type(t.clone()),
ailang_core::ast::NewArg::Value(v) => {
MNewArg::Value(lower_term(ctx, v)?)
}
})
})
.collect::<Result<Vec<_>>>()?;
MTerm::New { type_name: type_name.clone(), elem: None, args: m_args, ty }
}
// scope-affecting: synth's LetRec arm (lib.rs:4029) binds
// `name: ty` for both `body` and `in_term`. Mirror it.
Term::LetRec { name, ty: rec_ty, params, body, in_term } => {
let saved = ctx.locals.clone();
ctx.locals.insert(name.clone(), rec_ty.clone());
let m_body = Box::new(lower_term(ctx, body)?);
let m_in = Box::new(lower_term(ctx, in_term)?);
ctx.locals = saved;
MTerm::LetRec {
name: name.clone(),
sig: rec_ty.clone(),
params: params.clone(),
body: m_body,
in_term: m_in,
ty,
}
}
Term::Intrinsic => MTerm::Intrinsic { ty },
})
}
```
> **Implementer note for Step 3.** The Match arm calls
> `crate::type_check_pattern` — synth's own pattern-binding helper
> (`lib.rs:3880`). It is currently private to `lib.rs`; widen its
> declaration to `pub(crate) fn type_check_pattern(...)` so the
> `lower_to_mir` module can call it. That is the one-token change this
> step needs in `lib.rs` beyond the module declaration; do **not**
> otherwise touch synth or its helpers.
- [ ] **Step 4: Write the module-lowering entry**
Append to `crates/ailang-check/src/lower_to_mir.rs`. `lower_module`
builds one `MirDef` per `FnDef`, constructing the per-fn `Ctx` exactly
as `mono.rs:771-816` builds its synth scaffolding (env with per-module
`imports`, `locals` from `params.zip(param_tys)`):
```rust
/// Lower one post-mono module to MIR. `env` is the workspace check
/// env (built by `build_check_env`); this fn sets `env.imports` for
/// the module and seeds per-fn `locals` from the declared params,
/// mirroring mono.rs:771-816.
pub fn lower_module(module: &Module, env: &Env) -> Result<MirModule> {
let mut env = env.clone();
if let Some(im) = env.module_imports.get(&module.name).cloned() {
env.imports = im;
}
let mut defs = Vec::new();
for def in &module.defs {
let ailang_core::ast::Def::Fn(f) = def else { continue };
// param types from the fn signature (same source mono uses).
let param_tys = crate::fn_param_types(&f.ty);
let mut locals: IndexMap<String, Type> = IndexMap::new();
for (n, t) in f.params.iter().zip(param_tys.iter()) {
locals.insert(n.clone(), t.clone());
}
let mut ctx = Ctx {
env: &env,
in_def: &f.name,
locals,
loop_stack: Vec::new(),
};
let body = lower_term(&mut ctx, &f.body)?;
defs.push(MirDef {
name: f.name.clone(),
sig: f.ty.clone(),
params: f.params.clone(),
export: f.export,
body,
});
}
Ok(MirModule { name: module.name.clone(), defs })
}
```
> **Implementer note for Step 4.** `crate::fn_param_types` is the
> helper that extracts a fn signature's parameter types — mono.rs uses
> the same `param_tys` at `:788`. Find what mono.rs binds `param_tys`
> from (it is in scope at `mono.rs:771-816`) and call the identical
> source; if it is a local computation rather than a shared fn, lift it
> into a `pub(crate)` helper so both sites share it. `f.export` — if
> `FnDef` has no `export: bool` field, drop that line from `MirDef`
> construction and the field from Task 1's `MirDef` (confirm against
> `ast.rs:224`).
- [ ] **Step 5: Build the check crate**
Run: `cargo build -p ailang-check`
Expected: compiles, 0 errors. This gates that `synth` / `Subst` /
`ResidualConstraint` / `FreeFnCall` / `Env` / `fn_param_types` are all
reachable from the new module under their referenced names (fix any
that differ by reading their declaration — they are all in
`crates/ailang-check/src/lib.rs`).
---
## Task 3: `elaborate_workspace` + ty-fill pins
**Files:**
- Modify: `crates/ailang-check/src/lib.rs` (add `elaborate_workspace`)
- Test: `crates/ailang-check/tests/lower_to_mir_ty.rs`
- [ ] **Step 1: Write `elaborate_workspace`**
In `crates/ailang-check/src/lib.rs`, after `check_workspace`
(`:1145`), add the front-end entry. It runs the diagnostics gate, then
lift + mono, then lowers each post-mono module to MIR:
```rust
/// The build-path front end: typecheck (diagnostics gate) → desugar +
/// lift → monomorphise → lower to MIR. Returns a `MirWorkspace` on
/// success, or the check diagnostics on a type error. `check_workspace`
/// stays the entry for diagnostics-only callers (`ail check`); this is
/// what the build / emit-ir paths call.
pub fn elaborate_workspace(
ws: &ailang_core::workspace::Workspace,
) -> std::result::Result<ailang_mir::MirWorkspace, Vec<crate::diagnostic::Diagnostic>> {
// 1. diagnostics gate — bail with the diagnostics on any error.
let diags = check_workspace(ws);
if diags.iter().any(|d| d.severity == crate::diagnostic::Severity::Error) {
return Err(diags);
}
// 2. desugar + lift + monomorphise — the exact sequence the CLI
// runs at main.rs:2324-2346. A lift/mono Err on a checked
// workspace is an internal compiler error → surface as a
// diagnostic, do not panic.
let mut lifted_modules = std::collections::BTreeMap::new();
for (mname, m) in &ws.modules {
let desugared = ailang_core::desugar::desugar_module(m);
let lifted = lift_letrecs(&desugared)
.map_err(|e| vec![internal_diag(format!("lift_letrecs in `{mname}`: {e}"))])?;
lifted_modules.insert(mname.clone(), lifted);
}
let lifted = ailang_core::workspace::Workspace {
entry: ws.entry.clone(),
modules: lifted_modules,
root_dir: ws.root_dir.clone(),
registry: ws.registry.clone(),
};
let mono = monomorphise_workspace(&lifted)
.map_err(|e| vec![internal_diag(format!("monomorphise_workspace: {e}"))])?;
// 3. lower each post-mono module to MIR.
let env = build_check_env(&mono);
let mut modules = std::collections::BTreeMap::new();
for (name, m) in &mono.modules {
let mir = lower_to_mir::lower_module(m, &env)
.map_err(|e| vec![internal_diag(format!("lower_to_mir in `{name}`: {e}"))])?;
modules.insert(name.clone(), mir);
}
Ok(ailang_mir::MirWorkspace { entry: mono.entry.clone(), modules })
}
```
> **Implementer note for Step 1.** The sequence above is transcribed
> from the CLI build path (`main.rs:2324-2346`) — `desugar_module` then
> `lift_letrecs` per module, rebuild the `Workspace`, then
> `monomorphise_workspace`. Three names to confirm against existing
> code as you wire it:
> - `build_check_env(&Workspace) -> Env` exists at `lib.rs:1632` —
> call it. (If its real signature differs, match it.)
> - `internal_diag(msg: String) -> Diagnostic` — a small new local
> helper building a `Diagnostic` with `Severity::Error` and `msg`.
> Copy the field shape from one existing `Diagnostic { … }` build
> site in this file. This is the mir.1a stand-in for the dedicated
> `MirLoweringError` the spec §"Error handling" names (introduced in
> mir.1b); "check passed but lowering failed" is that internal-
> compiler-error class.
> - `d.severity == Severity::Error` — confirm `Diagnostic` has a
> `severity: Severity` field with an `Error` variant (`Severity`
> re-exported at `:377`); adjust the predicate to the real
> field/variant names if they differ. If `check_workspace` only ever
> returns errors (no warnings in its `Vec`), the predicate can be
> `!diags.is_empty()` — verify which.
- [ ] **Step 2: Add the two missing witness fixtures**
The test loads the boundary witnesses as workspace fixtures (so
`load_workspace` injects the prelude — the witnesses reference
`print` / `ge` / `str_concat` / `io/print_str`, which live there). The
#49 witness already exists as
`examples/loop_recur_str_binder_no_leak_pin.ail` (reused as-is). Create
the two missing ones, each named by its module (the loader enforces
filename = module name).
Create `examples/new_rawbuf_size_only.ail`:
```ail
(module new_rawbuf_size_only
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(let b (new RawBuf (con Int) 3)
(app print (app RawBuf.size b))))))
```
Create `examples/new_counter_user_adt.ail`:
```ail
(module new_counter_user_adt
(data Counter (ctor MkCounter (con Int)))
(fn new (type (fn-type (params (con Int)) (ret (con Counter)))) (params n)
(body (term-ctor Counter MkCounter n)))
(fn main (type (fn-type (params) (ret (con Unit)) (effects IO))) (params)
(body (let c (new Counter 42) (do io/print_str "ok\n")))))
```
Run (parse + check gate — confirms both fixtures load clean before the
test depends on them):
`target/debug/ail check examples/new_rawbuf_size_only.ail && target/debug/ail check examples/new_counter_user_adt.ail`
Expected: both print `ok (… symbols across 3 modules)`, exit 0. (Both
were already verified `ail check`-clean during planning; this is the
implementer's re-confirmation after writing the files.)
- [ ] **Step 3: Write the ty-fill pins (integration test)**
Create `crates/ailang-check/tests/lower_to_mir_ty.rs`. The test loads
each witness fixture via `ailang_surface::load_workspace` (the
established ailang-check integration-test pattern — see
`crates/ailang-check/tests/method_collision_pin.rs:15-40`), elaborates
the whole workspace (witness **plus** prelude **plus** kernel — so
`lower_to_mir` is exercised over the entire prelude, not just the tiny
witness), and asserts `ty` is filled correctly. It is an **additive**
test — it exercises the new producer without touching codegen.
```rust
//! mir.1a: `lower_to_mir` fills `ty` on every node from the canonical
//! `synth`, and routes string literals to `MTerm::Str { rep: Static }`.
//! Loads the boundary witnesses as workspace fixtures (prelude +
//! kernel injected by `load_workspace`) and elaborates the whole
//! workspace, so the walk is exercised over the full prelude. These
//! pins protect the producer; codegen consumption arrives in mir.1b.
use ailang_check::elaborate_workspace;
use ailang_mir::{MTerm, MirWorkspace, StrRep};
use ailang_surface::load_workspace;
use std::path::{Path, PathBuf};
/// `<repo>/examples` — resolved from this crate's manifest dir, exactly
/// as `method_collision_pin.rs:19-25` does (cwd-independent).
fn examples_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent().expect("crates/ailang-check has parent crates/")
.parent().expect("crates/ has parent repo root")
.join("examples")
}
/// Load a witness fixture (prelude injected) and elaborate it to MIR.
fn elaborate_fixture(module: &str) -> MirWorkspace {
let entry = examples_dir().join(format!("{module}.ail"));
let ws = load_workspace(&entry).expect("fixture loads (prelude injected)");
elaborate_workspace(&ws).expect("witness elaborates to MIR")
}
/// A def's lowered body in the elaborated workspace.
fn body<'a>(mir: &'a MirWorkspace, module: &str, def: &str) -> &'a MTerm {
&mir.modules[module]
.defs
.iter()
.find(|d| d.name == def)
.expect("def present")
.body
}
#[test]
fn str_literal_lowers_to_static_str_node() {
// #49 witness: the loop seed "x" is a Str literal → MTerm::Str,
// rep = Static at mir.1 (mir.4 flips loop seeds to Heap).
let m = "loop_recur_str_binder_no_leak_pin";
let mir = elaborate_fixture(m);
assert!(
find_static_str_seed(body(&mir, m, "main")),
"loop seed \"x\" must lower to MTerm::Str {{ rep: Static }}"
);
}
#[test]
fn new_over_user_adt_carries_node_types() {
// #53 witness: every node has a filled `ty`; the `(new Counter 42)`
// node's type is the user ADT `Counter`.
let m = "new_counter_user_adt";
let mir = elaborate_fixture(m);
let MTerm::Let { init, .. } = body(&mir, m, "main") else {
panic!("main body is a let");
};
assert!(
matches!(init.as_ref(), MTerm::New { type_name, .. } if type_name == "Counter"),
"let init is the New node over Counter"
);
let ty_str = ailang_core::pretty::type_to_string(&init.ty());
assert!(ty_str.contains("Counter"), "New node ty is Counter, got {ty_str}");
}
#[test]
fn rawbuf_size_only_elaborates() {
// #51 witness: a RawBuf read only for size — must elaborate clean
// (the element type carried only by the author's annotation does
// not block lowering).
let mir = elaborate_fixture("new_rawbuf_size_only");
assert!(mir.modules.contains_key("new_rawbuf_size_only"));
}
/// Recursively search for a `MTerm::Str { rep: Static }` reachable
/// from a loop binder init (the seed).
fn find_static_str_seed(t: &MTerm) -> bool {
match t {
MTerm::Loop { binders, body } => {
binders.iter().any(|b| matches!(
&b.init,
MTerm::Str { rep: StrRep::Static, .. }
)) || find_static_str_seed(body)
}
MTerm::Let { init, body, .. } => {
find_static_str_seed(init) || find_static_str_seed(body)
}
_ => false,
}
}
```
> **Implementer note for Step 3.** `ailang_surface::load_workspace(&Path)
> -> Result<Workspace>` is the loader `ail check` uses (`main.rs:595`)
> and the one `method_collision_pin.rs:39` calls — it injects the
> prelude, so no hand-built single-module workspace is needed. Confirm
> `ailang-surface` is a (dev-)dependency of `ailang-check` reachable
> from `tests/` (it already is — the existing fixtures-loading pins
> import it). The `MTerm::Let`/`MTerm::New`/`MTerm::Loop` field names
> must match Task 1's type defs; if you renamed a field there, mirror
> it here.
- [ ] **Step 4: Run the new pins**
Run: `cargo test -p ailang-check --test lower_to_mir_ty`
Expected: PASS — all three tests green (`str_literal_lowers_to_static_str_node`,
`new_over_user_adt_carries_node_types`, `rawbuf_size_only_elaborates`).
A failure inside `elaborate_workspace` (not the assertion) means
`lower_to_mir` mishandles a `Term` shape somewhere in the prelude —
fix `lower_to_mir`, do not narrow the fixture.
- [ ] **Step 5: Full-suite regression gate (additive ⇒ nothing breaks)**
Run: `cargo test --workspace`
Expected: PASS — the entire existing suite stays green (mir.1a adds
code, consumes nothing; codegen, CLI, and all hash/round-trip pins are
untouched), plus the three new `lower_to_mir_ty` tests.
---
## Notes for the orchestrator (commit + handoff)
- **Commit shape:** mir.1a is one cohesive additive increment — a
single commit (`feat(check): mir.1a — typed-MIR types + post-mono
lower_to_mir + elaborate_workspace (additive)`) is appropriate;
`refs #49` (the open leg this milestone closes) in the body, not
`closes` (closure lands at mir.4).
- **Not in this plan (mir.1b, next planner run):** flipping codegen's
walk to `&MTerm`, deleting `synth_with_extras` + `synth_arg_type`
(9 call sites across lib.rs/drop.rs/match_lower.rs), threading the 18
`lower_workspace*` callers, and the CLI build-path switch to
`elaborate_workspace`. That plan is written against the *landed* MIR
types, not this plan's projections.