diff --git a/docs/plans/2026-05-10-23.2-eq-class-and-primitive-instances.md b/docs/plans/2026-05-10-23.2-eq-class-and-primitive-instances.md new file mode 100644 index 0000000..5f55378 --- /dev/null +++ b/docs/plans/2026-05-10-23.2-eq-class-and-primitive-instances.md @@ -0,0 +1,1022 @@ +# 23.2 — Eq Class + Primitive Instances — Implementation Plan + +> **Parent spec:** `docs/specs/2026-05-10-23-eq-ord-prelude.md` (Components row 23.2) +> +> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` +> to run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Ship `class Eq a where eq : (a borrow, a borrow) -> Bool` in +the auto-loaded prelude with three primitive instances (Eq Int, Eq +Bool, Eq Str), backed by a new `runtime/str.c` providing +`ail_str_eq`. After this iter, a user program can call `eq x y` on +Int / Bool / Str and the monomorphiser will synthesise `eq__Int` / +`eq__Bool` / `eq__Str` top-level fns in the prelude module that +codegen lowers to `icmp eq i64` / `icmp eq i1` / +`call zeroext i1 @ail_str_eq` respectively. + +**Architecture:** Eq Int and Eq Bool instance bodies are +`fn (x, y) { x == y }` lambdas — they ride the existing `lower_eq` +primitive dispatch and produce the right IR with zero new codegen +mechanism. Eq Str cannot ride that path because the spec mandates +`@ail_str_eq` rather than the existing `@strcmp + icmp eq i32 0` +shape; so codegen grows a single intercept in `emit_fn` keyed on the +fn name `eq__Str` that emits a hand-rolled two-instruction body. The +`@ail_str_eq` declaration is added to the IR header unconditionally +and `runtime/str.c` is compiled+linked alongside every alloc +strategy (Gc / Bump / Rc). The intercept is the narrowest possible +change — `==` on Str stays on `@strcmp` this iter, the mono symbol +`eq__Str` is the only path that goes through `@ail_str_eq`. + +**Tech Stack:** `crates/ailang-core` (workspace + prelude JSON), +`crates/ailang-codegen` (`emit_fn` intercept + IR header), +`crates/ail/src/main.rs` (`locate_str_runtime` + clang link), +new `runtime/str.c`. + +--- + +**Files this plan creates or modifies:** + +- Create: `runtime/str.c` — single small C file with + `ail_str_eq(const char*, const char*) -> _Bool` over libc strcmp. +- Modify: `crates/ail/src/main.rs` — add `locate_str_runtime` helper + (mirror of `locate_rc_runtime`); compile+link `runtime/str.c` + unconditionally inside `build_to` for all three alloc strategies. +- Modify: `crates/ailang-codegen/src/lib.rs` — IR header gains + `declare zeroext i1 @ail_str_eq(ptr, ptr)`; `emit_fn` gains a + `try_emit_primitive_instance_body` intercept that hand-rolls the + body for the fn name `eq__Str`. +- Modify: `examples/prelude.ail.json` — add `class Eq a` + three + instance defs (Eq Int, Eq Bool, Eq Str). Existing Ordering def + stays. +- Create: `examples/eq_primitives_smoke.ail.json` — E2E fixture + calling `eq` on each primitive with both equal and unequal pairs. +- Modify: `crates/ailang-core/src/workspace.rs` — extend + `loads_workspace_auto_injects_prelude` to assert the Eq class is + present. +- Modify: `crates/ail/tests/e2e.rs` — new E2E test + `eq_primitives_smoke_compiles_and_runs`. +- Modify: `crates/ailang-codegen/src/lib.rs` `mod tests` — three new + unit tests asserting the IR shape of `eq__Int`, `eq__Bool`, + `eq__Str`. + +--- + +## Task 1: `runtime/str.c` + `locate_str_runtime` + clang link wiring + +**Files:** +- Create: `runtime/str.c` +- Modify: `crates/ail/src/main.rs:1944-2136` (new `locate_str_runtime` + helper near `locate_rc_runtime`; new clang compile+link block + inside `build_to`) + +- [ ] **Step 1: Write the failing test** + +```rust +// crates/ail/src/main.rs, inside the existing `#[cfg(test)] mod tests` block: +#[test] +fn locate_str_runtime_finds_workspace_str_c() { + let p = super::locate_str_runtime().expect("locate runtime/str.c"); + assert!( + p.ends_with("runtime/str.c"), + "expected path ending in runtime/str.c, got {}", + p.display() + ); + assert!(p.exists(), "located path must exist on disk: {}", p.display()); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --workspace -p ail locate_str_runtime_finds_workspace_str_c` +Expected: FAIL — compile error `cannot find function locate_str_runtime in this scope` (helper not declared yet). + +- [ ] **Step 3: Write `runtime/str.c`** + +```c +/* AILang Str runtime primitives. + * + * Provides ABI-stable string operations against the existing + * constant-Str layout (NUL-terminated byte buffer, passed as + * `ptr` in LLVM IR). Lives separately from runtime/rc.c so that + * a future heap-Str ABI milestone can swap the implementations + * without touching the RC runtime. + * + * Linked unconditionally by `ail build` (Gc, Bump, Rc) so that + * any program that monomorphises `eq__Str` (auto-loaded from + * the prelude) finds the symbol. Programs that never call + * `eq` on Str leave @ail_str_eq as a dead-stripped no-op at + * link time under clang -O2. + */ + +#include +#include + +/* Byte-equality on NUL-terminated strings. Mirrors strcmp(...) == 0 + * but exposed under a stable AILang-namespaced ABI so the codegen + * caller does not assume libc's strcmp shape. Heap-Str ABI milestone + * will replace the body with length-prefixed comparison without + * changing the signature. */ +bool ail_str_eq(const char *a, const char *b) { + return strcmp(a, b) == 0; +} +``` + +- [ ] **Step 4: Add `locate_str_runtime` helper** + +In `crates/ail/src/main.rs` directly after `locate_rc_runtime` +(currently ending around line 1971), insert: + +```rust +/// Locate the workspace-root `runtime/str.c` file relative to the +/// `ail` binary, mirroring [`locate_rc_runtime`]. Iter 23.2: +/// `runtime/str.c` houses Str-runtime primitives (`ail_str_eq`, +/// later `ail_str_compare`) and is linked unconditionally for every +/// alloc strategy — `@ail_str_eq` is declared in the emitted IR +/// header (codegen) without an alloc-strategy guard, so the symbol +/// must always be resolvable at link time. +fn locate_str_runtime() -> Result { + let candidates = [ + std::env::current_exe().ok(), + std::env::current_dir().ok(), + ]; + for start in candidates.iter().flatten() { + let mut cur: &Path = start.as_path(); + loop { + let candidate = cur.join("runtime").join("str.c"); + if candidate.exists() { + return Ok(candidate); + } + match cur.parent() { + Some(p) => cur = p, + None => break, + } + } + } + anyhow::bail!( + "could not locate `runtime/str.c` (linked unconditionally). \ + Run `ail` from inside the AILang workspace." + ) +} +``` + +- [ ] **Step 5: Run the locate-helper test to verify it passes** + +Run: `cargo test --workspace -p ail locate_str_runtime_finds_workspace_str_c` +Expected: PASS. + +- [ ] **Step 6: Wire str.c compile+link into `build_to`** + +In `crates/ail/src/main.rs` `build_to`, between the `clang.arg(opt).arg("-o").arg(&out_bin).arg(&ll_path);` +line and the `match alloc` block (currently at line 2063), insert: + +```rust +// Iter 23.2: compile and link `runtime/str.c` unconditionally — +// `@ail_str_eq` is emitted in the IR header without an alloc-strategy +// guard (it backs the prelude `eq__Str` mono symbol). The .o is +// cached at /str.o per build invocation; if a program never +// monomorphises `eq__Str`, clang -O2 dead-strips the symbol so the +// linker overhead is fixed and negligible. +let str_src = locate_str_runtime()?; +let str_obj = tmpdir.join("str.o"); +let cstatus = std::process::Command::new("clang") + .arg("-O2") + .arg("-c") + .arg(&str_src) + .arg("-o") + .arg(&str_obj) + .status() + .context("compiling runtime/str.c")?; +if !cstatus.success() { + anyhow::bail!( + "clang failed compiling str.c (status {})", + cstatus + ); +} +clang.arg(&str_obj); +``` + +- [ ] **Step 7: Verify the workspace still builds and existing E2E suite is green** + +Run: `cargo build --workspace` +Expected: PASS. + +Run: `cargo test --workspace -p ail` +Expected: PASS (str.c is linked into every build but no IR yet calls `@ail_str_eq`; clang strips it cleanly). + +- [ ] **Step 8: Commit** + +```bash +git add runtime/str.c crates/ail/src/main.rs +git commit -m "iter 23.2.1: runtime/str.c with ail_str_eq + unconditional link" +``` + +--- + +## Task 2: Codegen — declare `@ail_str_eq` + `eq__Str` body intercept + +**Files:** +- Modify: `crates/ailang-codegen/src/lib.rs:475` (IR header — add + `declare zeroext i1 @ail_str_eq(ptr, ptr)`) +- Modify: `crates/ailang-codegen/src/lib.rs` `emit_fn` (currently at + line 966) — insert intercept call after `start_block("entry")` +- Modify: `crates/ailang-codegen/src/lib.rs` — new private method + `try_emit_primitive_instance_body` +- Modify: `crates/ailang-codegen/src/lib.rs` `mod tests` — new + unit test `eq_str_mono_symbol_emits_ail_str_eq_call` + +- [ ] **Step 1: Write the failing test** + +Append to the existing `#[cfg(test)] mod tests` block in +`crates/ailang-codegen/src/lib.rs`: + +```rust +/// Iter 23.2: codegen intercepts a fn named `eq__Str` (the +/// monomorphiser's synthesised symbol for `Eq Str.eq`) and emits +/// a two-instruction body that calls @ail_str_eq, regardless of +/// what the lambda body in the source would lower to. Pinned with +/// a synthetic FnDef so the test does not depend on prelude +/// auto-injection. +#[test] +fn eq_str_mono_symbol_emits_ail_str_eq_call() { + let m = Module { + schema: SCHEMA.into(), + name: "prelude".into(), + imports: vec![], + defs: vec![ + Def::Fn(FnDef { + name: "eq__Str".into(), + ty: Type::Fn { + params: vec![Type::str_(), Type::str_()], + ret: Box::new(Type::bool_()), + effects: vec![], + param_modes: vec![ParamMode::Borrow, ParamMode::Borrow], + ret_mode: ParamMode::Implicit, + }, + params: vec!["x".into(), "y".into()], + // Body is a placeholder — the intercept must + // ignore it. We use Lit::Bool false so even if the + // intercept misfires the test still compiles. + body: Term::Lit { lit: Literal::Bool { value: false } }, + suppress: vec![], + doc: None, + }), + Def::Fn(FnDef { + name: "main".into(), + ty: Type::Fn { + params: vec![], + ret: Box::new(Type::unit()), + effects: vec![], + param_modes: vec![], + ret_mode: ParamMode::Implicit, + }, + params: vec![], + body: Term::Lit { lit: Literal::Unit }, + suppress: vec![], + doc: None, + }), + ], + }; + let ir = emit_ir(&m).unwrap(); + assert!( + ir.contains("declare zeroext i1 @ail_str_eq(ptr, ptr)"), + "header missing @ail_str_eq declaration; ir was:\n{ir}" + ); + assert!( + ir.contains("call zeroext i1 @ail_str_eq("), + "eq__Str body must call @ail_str_eq; ir was:\n{ir}" + ); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --workspace -p ailang-codegen eq_str_mono_symbol_emits_ail_str_eq_call` +Expected: FAIL — `header missing @ail_str_eq declaration` (declaration not yet emitted) or `eq__Str body must call @ail_str_eq` (intercept not yet wired). + +- [ ] **Step 3: Add the `@ail_str_eq` declaration to the IR header** + +In `crates/ailang-codegen/src/lib.rs`, directly after line 475 +(`out.push_str("declare i32 @strcmp(ptr, ptr)\n");`), insert: + +```rust +// Iter 23.2: `runtime/str.c::ail_str_eq` backs the prelude's +// `eq__Str` mono symbol (see `try_emit_primitive_instance_body`). +// Declared unconditionally — the codegen intercept emits a call +// to it whenever the monomorphiser synthesises `eq__Str` in any +// module; the .o supplying the symbol is linked unconditionally +// by `ail build` (see `crates/ail/src/main.rs::build_to`). The +// `zeroext i1` return matches clang's lowering of C `_Bool`. +out.push_str("declare zeroext i1 @ail_str_eq(ptr, ptr)\n"); +``` + +- [ ] **Step 4: Add the `try_emit_primitive_instance_body` helper** + +In `crates/ailang-codegen/src/lib.rs`, inside the `impl<'a> Emitter<'a>` +block (any location between existing methods is fine — recommend +placing it directly above `lower_eq` at line 2430 since they share +the "primitive-typed equality" topic), insert: + +```rust +/// Iter 23.2: hand-rolled body for monomorphiser-synthesised +/// primitive instance methods whose natural lambda-lowering would +/// not produce the spec-mandated IR shape. Currently the only +/// inhabitant is `eq__Str`, which must call `@ail_str_eq` (the +/// stable AILang-namespaced Str-equality ABI in `runtime/str.c`) +/// rather than the `@strcmp + icmp eq i32 0` shape that +/// `lower_eq`'s Str arm would emit if we let the lambda body +/// `(== x y)` go through the normal path. Returns `Ok(true)` if +/// the body was emitted (caller must return immediately from +/// `emit_fn`); `Ok(false)` lets `emit_fn` continue with the +/// normal body lowering. +/// +/// Future iters add `compare__Str` here (23.3); int/bool primitive +/// methods (`eq__Int`, `eq__Bool`, `compare__Int`, `compare__Bool`) +/// ride the natural `lower_eq` / `lower_compare` dispatch and do +/// not need a hand-rolled body. +fn try_emit_primitive_instance_body( + &mut self, + fn_name: &str, + param_tys: &[String], + ret_ty: &str, +) -> Result { + match fn_name { + "eq__Str" => { + if param_tys != ["ptr", "ptr"] || ret_ty != "i1" { + return Err(CodegenError::Internal(format!( + "eq__Str body intercept: unexpected signature \ + ({param_tys:?}) -> {ret_ty} (want (ptr, ptr) -> i1)" + ))); + } + // The two params are the two most recently pushed locals; + // `emit_fn` populated `self.locals` from `f.params` before + // dispatching here. Pull their SSA names without assuming + // a specific surface-level binder name. + let n = self.locals.len(); + let a_ssa = self.locals[n - 2].1.clone(); + let b_ssa = self.locals[n - 1].1.clone(); + let dst = self.fresh_ssa(); + self.body.push_str(&format!( + " {dst} = call zeroext i1 @ail_str_eq(ptr {a_ssa}, ptr {b_ssa})\n" + )); + self.body.push_str(&format!(" ret i1 {dst}\n")); + self.body.push_str("}\n\n"); + self.block_terminated = true; + Ok(true) + } + _ => Ok(false), + } +} +``` + +- [ ] **Step 5: Wire the intercept into `emit_fn`** + +In `crates/ailang-codegen/src/lib.rs::emit_fn`, directly after the +`self.start_block("entry");` call at line 1052 and before the +`let (val, val_ty) = self.lower_term(&f.body)?;` line at 1054, +insert: + +```rust +// Iter 23.2: primitive-instance body intercept. Returns true if +// the body was emitted in full; we then skip the normal body +// lowering for this fn. +if self.try_emit_primitive_instance_body(&f.name, &llvm_param_tys, &llvm_ret)? { + return Ok(()); +} +``` + +- [ ] **Step 6: Run the unit test to verify it passes** + +Run: `cargo test --workspace -p ailang-codegen eq_str_mono_symbol_emits_ail_str_eq_call` +Expected: PASS. + +- [ ] **Step 7: Re-run the full codegen test suite to confirm no regressions** + +Run: `cargo test --workspace -p ailang-codegen` +Expected: PASS — the intercept is keyed on the literal fn name +`eq__Str`; no existing test fixture uses that name. + +- [ ] **Step 8: Commit** + +```bash +git add crates/ailang-codegen/src/lib.rs +git commit -m "iter 23.2.2: codegen — declare @ail_str_eq + eq__Str body intercept" +``` + +--- + +## Task 3: Prelude — `class Eq a` + three primitive instances + +**Files:** +- Modify: `examples/prelude.ail.json` — append class + 3 instance defs +- Modify: `crates/ailang-core/src/workspace.rs::loads_workspace_auto_injects_prelude` + test to also assert `Eq` class is present + +- [ ] **Step 1: Write the failing test** + +In `crates/ailang-core/src/workspace.rs`, extend the existing +`loads_workspace_auto_injects_prelude` test (currently at line 897). +After the `Ordering` assertion block, append: + +```rust +// Iter 23.2: prelude also ships the `Eq` class plus three +// primitive instances (Eq Int, Eq Bool, Eq Str). +assert!( + prelude.defs.iter().any(|d| matches!( + d, + crate::ast::Def::Class(c) if c.name == "Eq" + )), + "prelude must contain Eq class def" +); +let eq_instance_types: Vec<&str> = prelude + .defs + .iter() + .filter_map(|d| match d { + crate::ast::Def::Instance(i) if i.class == "Eq" => { + if let crate::ast::Type::Con { name, .. } = &i.type_ { + Some(name.as_str()) + } else { + None + } + } + _ => None, + }) + .collect(); +assert!( + eq_instance_types.contains(&"Int"), + "prelude must contain `instance Eq Int`; saw Eq instances on: {eq_instance_types:?}" +); +assert!( + eq_instance_types.contains(&"Bool"), + "prelude must contain `instance Eq Bool`; saw Eq instances on: {eq_instance_types:?}" +); +assert!( + eq_instance_types.contains(&"Str"), + "prelude must contain `instance Eq Str`; saw Eq instances on: {eq_instance_types:?}" +); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --workspace -p ailang-core loads_workspace_auto_injects_prelude` +Expected: FAIL — `prelude must contain Eq class def` (the prelude +JSON has only Ordering today). + +- [ ] **Step 3: Extend `examples/prelude.ail.json`** + +Replace the entire file (currently the `Ordering` type def only) +with: + +```json +{ + "schema": "ailang/v0", + "name": "prelude", + "imports": [], + "defs": [ + { + "kind": "type", + "name": "Ordering", + "vars": [], + "doc": "Result of a three-way comparison: LT (less than), EQ (equal), GT (greater than). Ships in milestone 23 as the codomain of Ord.compare.", + "ctors": [ + { "name": "LT", "fields": [] }, + { "name": "EQ", "fields": [] }, + { "name": "GT", "fields": [] } + ] + }, + { + "kind": "class", + "name": "Eq", + "param": "a", + "doc": "Structural equality. Ships in milestone 23 alongside Ord. The primitive `==` operator stays as the surface-level comparator this iter; routing `==` through `Eq.eq` is the declared P2 follow-up (see docs/specs/2026-05-10-23-eq-ord-prelude.md, `Out of scope`).", + "methods": [ + { + "name": "eq", + "type": { + "k": "fn", + "params": [ + { "k": "var", "name": "a" }, + { "k": "var", "name": "a" } + ], + "param_modes": ["borrow", "borrow"], + "ret": { "k": "con", "name": "Bool" }, + "effects": [] + } + } + ] + }, + { + "kind": "instance", + "class": "Eq", + "type": { "k": "con", "name": "Int" }, + "doc": "Eq Int. Body lowers to `icmp eq i64` via the existing primitive `==` dispatch in `lower_eq`.", + "methods": [ + { + "name": "eq", + "body": { + "t": "lam", + "params": ["x", "y"], + "paramTypes": [ + { "k": "var", "name": "a" }, + { "k": "var", "name": "a" } + ], + "retType": { "k": "con", "name": "Bool" }, + "body": { + "t": "app", + "fn": { "t": "var", "name": "==" }, + "args": [ + { "t": "var", "name": "x" }, + { "t": "var", "name": "y" } + ] + } + } + } + ] + }, + { + "kind": "instance", + "class": "Eq", + "type": { "k": "con", "name": "Bool" }, + "doc": "Eq Bool. Body lowers to `icmp eq i1` via the existing primitive `==` dispatch in `lower_eq`.", + "methods": [ + { + "name": "eq", + "body": { + "t": "lam", + "params": ["x", "y"], + "paramTypes": [ + { "k": "var", "name": "a" }, + { "k": "var", "name": "a" } + ], + "retType": { "k": "con", "name": "Bool" }, + "body": { + "t": "app", + "fn": { "t": "var", "name": "==" }, + "args": [ + { "t": "var", "name": "x" }, + { "t": "var", "name": "y" } + ] + } + } + } + ] + }, + { + "kind": "instance", + "class": "Eq", + "type": { "k": "con", "name": "Str" }, + "doc": "Eq Str. The lambda body shape mirrors Int/Bool for round-trip stability, but the codegen intercept in `emit_fn` overrides the body to call `@ail_str_eq` directly — see `try_emit_primitive_instance_body` in `crates/ailang-codegen/src/lib.rs`.", + "methods": [ + { + "name": "eq", + "body": { + "t": "lam", + "params": ["x", "y"], + "paramTypes": [ + { "k": "var", "name": "a" }, + { "k": "var", "name": "a" } + ], + "retType": { "k": "con", "name": "Bool" }, + "body": { + "t": "app", + "fn": { "t": "var", "name": "==" }, + "args": [ + { "t": "var", "name": "x" }, + { "t": "var", "name": "y" } + ] + } + } + } + ] + } + ] +} +``` + +- [ ] **Step 4: Run the prelude-load test to verify it passes** + +Run: `cargo test --workspace -p ailang-core loads_workspace_auto_injects_prelude` +Expected: PASS. + +- [ ] **Step 5: Re-run the full workspace test suite** + +Run: `cargo build --workspace && cargo test --workspace` +Expected: PASS — the prelude is still parser-valid Form-A, no +existing module imports `Eq` yet so no monomorphisation is +triggered, every existing test stays green. + +- [ ] **Step 6: Commit** + +```bash +git add examples/prelude.ail.json crates/ailang-core/src/workspace.rs +git commit -m "iter 23.2.3: prelude — class Eq a + Eq Int/Bool/Str instances" +``` + +--- + +## Task 4: E2E fixture + integration tests (eq on three primitives) + +**Files:** +- Create: `examples/eq_primitives_smoke.ail.json` — calls `eq` on + Int, Bool, Str (both equal and unequal pairs) and prints 1 / 0 + via `if`. +- Modify: `crates/ail/tests/e2e.rs` — new test + `eq_primitives_smoke_compiles_and_runs` asserting stdout matches + the expected six-line sequence. +- Modify: `crates/ailang-codegen/src/lib.rs` `mod tests` — three + integration tests asserting the IR shape of the monomorphiser- + synthesised `@ail_prelude_eq__Int`, `@ail_prelude_eq__Bool`, + `@ail_prelude_eq__Str` definitions when fed a fixture that + calls `eq` on each primitive. + +- [ ] **Step 1: Write the failing E2E test** + +In `crates/ail/tests/e2e.rs`, after the existing +`ordering_match_via_prelude_prints_1` test, append: + +```rust +/// Iter 23.2: end-to-end coverage for the auto-loaded `Eq` class +/// + three primitive instances. The fixture calls `eq` on Int / +/// Bool / Str with both an equal and an unequal pair each; the +/// monomorphiser synthesises `eq__Int` / `eq__Bool` / `eq__Str` +/// in the prelude module; the binary prints six lines, one per +/// call. Equal-call lines are `1`, unequal-call lines are `0`. +#[test] +fn eq_primitives_smoke_compiles_and_runs() { + let stdout = build_and_run("eq_primitives_smoke.ail.json"); + assert_eq!(stdout, "1\n0\n1\n0\n1\n0\n"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --workspace -p ail eq_primitives_smoke_compiles_and_runs` +Expected: FAIL — `examples/eq_primitives_smoke.ail.json` does not +exist; `ail build` exits non-zero with a load error. + +- [ ] **Step 3: Write the fixture** + +Create `examples/eq_primitives_smoke.ail.json`: + +```json +{ + "schema": "ailang/v0", + "name": "eq_primitives_smoke", + "imports": [], + "defs": [ + { + "kind": "fn", + "name": "to_int", + "doc": "1 if the bool is true, 0 otherwise. Used to render Eq results as printable Ints.", + "type": { + "k": "fn", + "params": [{ "k": "con", "name": "Bool" }], + "ret": { "k": "con", "name": "Int" }, + "effects": [] + }, + "params": ["b"], + "body": { + "t": "if", + "cond": { "t": "var", "name": "b" }, + "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } }, + "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } } + } + }, + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "body": { + "t": "seq", + "lhs": { + "t": "do", + "op": "io/print_int", + "args": [{ + "t": "app", + "fn": { "t": "var", "name": "to_int" }, + "args": [{ + "t": "app", + "fn": { "t": "var", "name": "eq" }, + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 7 } }, + { "t": "lit", "lit": { "kind": "int", "value": 7 } } + ] + }] + }] + }, + "rhs": { + "t": "seq", + "lhs": { + "t": "do", + "op": "io/print_int", + "args": [{ + "t": "app", + "fn": { "t": "var", "name": "to_int" }, + "args": [{ + "t": "app", + "fn": { "t": "var", "name": "eq" }, + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 7 } }, + { "t": "lit", "lit": { "kind": "int", "value": 8 } } + ] + }] + }] + }, + "rhs": { + "t": "seq", + "lhs": { + "t": "do", + "op": "io/print_int", + "args": [{ + "t": "app", + "fn": { "t": "var", "name": "to_int" }, + "args": [{ + "t": "app", + "fn": { "t": "var", "name": "eq" }, + "args": [ + { "t": "lit", "lit": { "kind": "bool", "value": true } }, + { "t": "lit", "lit": { "kind": "bool", "value": true } } + ] + }] + }] + }, + "rhs": { + "t": "seq", + "lhs": { + "t": "do", + "op": "io/print_int", + "args": [{ + "t": "app", + "fn": { "t": "var", "name": "to_int" }, + "args": [{ + "t": "app", + "fn": { "t": "var", "name": "eq" }, + "args": [ + { "t": "lit", "lit": { "kind": "bool", "value": true } }, + { "t": "lit", "lit": { "kind": "bool", "value": false } } + ] + }] + }] + }, + "rhs": { + "t": "seq", + "lhs": { + "t": "do", + "op": "io/print_int", + "args": [{ + "t": "app", + "fn": { "t": "var", "name": "to_int" }, + "args": [{ + "t": "app", + "fn": { "t": "var", "name": "eq" }, + "args": [ + { "t": "lit", "lit": { "kind": "str", "value": "hello" } }, + { "t": "lit", "lit": { "kind": "str", "value": "hello" } } + ] + }] + }] + }, + "rhs": { + "t": "do", + "op": "io/print_int", + "args": [{ + "t": "app", + "fn": { "t": "var", "name": "to_int" }, + "args": [{ + "t": "app", + "fn": { "t": "var", "name": "eq" }, + "args": [ + { "t": "lit", "lit": { "kind": "str", "value": "hello" } }, + { "t": "lit", "lit": { "kind": "str", "value": "world" } } + ] + }] + }] + } + } + } + } + } + } + } + ] +} +``` + +- [ ] **Step 4: Run E2E test to verify it passes** + +Run: `cargo test --workspace -p ail eq_primitives_smoke_compiles_and_runs` +Expected: PASS — stdout is `"1\n0\n1\n0\n1\n0\n"`. + +If FAIL: the most likely cause is the monomorphiser not finding +the prelude's `Eq` instances for the implicit import. Re-read +`crates/ailang-check/src/lib.rs::build_check_env` lines around the +prelude-overlay added in iter 23.1 to confirm the implicit import +is reaching the registry for class-instance lookup; the +`registry::build_registry` path in `ailang-core::workspace` must +also pick up the prelude's `Def::Class` and `Def::Instance` entries +without an explicit user import. If a gap is found, report it via +`NEEDS_CONTEXT` to the orchestrator (this is the kind of cross- +crate wiring that is in scope for the iter but may need a small +follow-up commit). + +- [ ] **Step 5: Add three codegen-IR integration tests** + +Append to the existing `#[cfg(test)] mod tests` block in +`crates/ailang-codegen/src/lib.rs`: + +```rust +/// Iter 23.2: integration with the auto-loaded prelude. Compiling +/// a workspace that calls `eq` on an Int pair must produce a +/// mono-synthesised `@ail_prelude_eq__Int` whose body lowers to +/// `icmp eq i64`. The test runs the full pipeline end-of-typecheck → +/// monomorphise → codegen, then asserts substring shape on the IR. +#[test] +fn eq_int_call_produces_icmp_i64_mono_fn() { + use ailang_core::load_workspace; + use ailang_check::{check_workspace, monomorphise_workspace}; + let manifest = env!("CARGO_MANIFEST_DIR"); + let entry = std::path::Path::new(manifest) + .parent().unwrap().parent().unwrap() + .join("examples").join("eq_primitives_smoke.ail.json"); + let ws = load_workspace(&entry).expect("load workspace"); + let diags = check_workspace(&ws); + assert!( + diags.iter().all(|d| !matches!(d.severity, ailang_check::Severity::Error)), + "unexpected check_workspace errors: {diags:?}" + ); + let ws = monomorphise_workspace(&ws).expect("monomorphise"); + let ir = lower_workspace(&ws).expect("lower"); + assert!( + ir.contains("@ail_prelude_eq__Int"), + "ir must contain mono-synthesised @ail_prelude_eq__Int; ir was:\n{ir}" + ); + assert!( + ir.contains("icmp eq i64"), + "eq__Int body must lower to icmp eq i64; ir was:\n{ir}" + ); +} + +/// Iter 23.2: same shape as `eq_int_call_produces_icmp_i64_mono_fn` +/// for Bool. Asserts the mono-synthesised `@ail_prelude_eq__Bool` +/// lowers to `icmp eq i1`. +#[test] +fn eq_bool_call_produces_icmp_i1_mono_fn() { + use ailang_core::load_workspace; + use ailang_check::{check_workspace, monomorphise_workspace}; + let manifest = env!("CARGO_MANIFEST_DIR"); + let entry = std::path::Path::new(manifest) + .parent().unwrap().parent().unwrap() + .join("examples").join("eq_primitives_smoke.ail.json"); + let ws = load_workspace(&entry).expect("load workspace"); + let _ = check_workspace(&ws); + let ws = monomorphise_workspace(&ws).expect("monomorphise"); + let ir = lower_workspace(&ws).expect("lower"); + assert!( + ir.contains("@ail_prelude_eq__Bool"), + "ir must contain mono-synthesised @ail_prelude_eq__Bool; ir was:\n{ir}" + ); + assert!( + ir.contains("icmp eq i1"), + "eq__Bool body must lower to icmp eq i1; ir was:\n{ir}" + ); +} + +/// Iter 23.2: Str's mono-symbol body is hand-rolled by the +/// `try_emit_primitive_instance_body` intercept. Asserts the +/// intercept fires for the prelude-loaded `eq__Str` synthesised +/// against a real user fixture that calls `eq` on Str. +#[test] +fn eq_str_call_produces_ail_str_eq_call_mono_fn() { + use ailang_core::load_workspace; + use ailang_check::{check_workspace, monomorphise_workspace}; + let manifest = env!("CARGO_MANIFEST_DIR"); + let entry = std::path::Path::new(manifest) + .parent().unwrap().parent().unwrap() + .join("examples").join("eq_primitives_smoke.ail.json"); + let ws = load_workspace(&entry).expect("load workspace"); + let _ = check_workspace(&ws); + let ws = monomorphise_workspace(&ws).expect("monomorphise"); + let ir = lower_workspace(&ws).expect("lower"); + assert!( + ir.contains("@ail_prelude_eq__Str"), + "ir must contain mono-synthesised @ail_prelude_eq__Str; ir was:\n{ir}" + ); + assert!( + ir.contains("call zeroext i1 @ail_str_eq("), + "eq__Str body must call @ail_str_eq; ir was:\n{ir}" + ); +} +``` + +Add at the top of `crates/ailang-codegen/Cargo.toml` `[dev-dependencies]` +if not already present: + +```toml +ailang-core = { path = "../ailang-core" } +ailang-check = { path = "../ailang-check" } +``` + +Check before adding — these may already be in `[dependencies]`, +in which case the test can use them directly without a separate +dev-dependency entry. Run `cargo metadata --format-version 1 | +grep -E 'ailang-(core|check)'` to confirm. + +- [ ] **Step 6: Run the three integration tests to verify they pass** + +Run: `cargo test --workspace -p ailang-codegen eq_int_call_produces_icmp_i64_mono_fn eq_bool_call_produces_icmp_i1_mono_fn eq_str_call_produces_ail_str_eq_call_mono_fn` +Expected: PASS for all three. + +- [ ] **Step 7: Run the full workspace test suite once more to confirm no cross-test regressions** + +Run: `cargo build --workspace && cargo test --workspace` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add examples/eq_primitives_smoke.ail.json crates/ail/tests/e2e.rs crates/ailang-codegen/src/lib.rs crates/ailang-codegen/Cargo.toml +git commit -m "iter 23.2.4: e2e smoke fixture + ir-shape integration tests for eq__T mono symbols" +``` + +(If `Cargo.toml` didn't need updating, omit it from the `git add`.) + +--- + +## Task 5: JOURNAL entry for iter 23.2 + +**Files:** +- Modify: `docs/JOURNAL.md` — append a new entry at the top of the + reverse-chronological list. + +- [ ] **Step 1: Append the JOURNAL entry** + +At the top of `docs/JOURNAL.md` (after the existing iter-23.1 entry), +insert: + +```markdown +## 2026-05-10 — Iteration 23.2: Eq class + three primitive instances + +Shipped `class Eq a where eq : (a borrow, a borrow) -> Bool` in the +auto-loaded prelude plus three primitive instances (Eq Int, Eq +Bool, Eq Str). User programs can now call `eq x y` on any of the +three primitives; the monomorphiser synthesises `eq__Int` / +`eq__Bool` / `eq__Str` top-level fns in the prelude module and +codegen lowers them as follows: + +- `eq__Int` → `icmp eq i64` via existing `lower_eq` Int arm. +- `eq__Bool` → `icmp eq i1` via existing `lower_eq` Bool arm. +- `eq__Str` → `call zeroext i1 @ail_str_eq(...)` via a new codegen + intercept (`try_emit_primitive_instance_body` in + `crates/ailang-codegen/src/lib.rs`). + +The intercept is the narrowest possible change — `==` on Str stays +on `@strcmp + icmp eq i32 0` this iter; only the mono-synthesised +`eq__Str` symbol goes through `@ail_str_eq`. The new +`runtime/str.c` houses the C-side primitive (a one-liner wrapping +libc `strcmp`); the .o is linked unconditionally by `ail build` for +all alloc strategies (Gc, Bump, Rc). + +Per-task commits: 23.2.1 (runtime/str.c + locate_str_runtime + +unconditional link), 23.2.2 (codegen — `@ail_str_eq` declaration + +`eq__Str` intercept), 23.2.3 (prelude — class Eq a + three +instances), 23.2.4 (e2e fixture + IR-shape integration tests), +23.2.5 (this JOURNAL entry). + +Known debt: `compare__Str` will reuse the same intercept mechanism +in iter 23.3 (adds the `compare` arm + `ail_str_compare` in +`runtime/str.c`). The `==` ↔ `eq x y` redundancy on Int/Bool/Str is +ratified as transitional per spec `Out of scope` until the P2 +operator-routing milestone. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/JOURNAL.md +git commit -m "iter 23.2.5: journal entry — Eq class + primitive instances" +``` + +--- + +## Self-review + +1. **Spec coverage**: Components row 23.2 names six deliverables — + (a) `class Eq a where eq`, (b) three instances (Int/Bool/Str), + (c) `runtime/str.c` with `ail_str_eq`, (d) codegen arm for + `eq__Str` emitting `call zext i1 @ail_str_eq`, (e) codegen unit + tests for `eq__Int`, `eq__Bool`, `eq__Str`. Mapped: (a)+(b) in + Task 3; (c) in Task 1; (d) in Task 2; (e) in Task 4. ✓ +2. **Placeholder scan**: no `TBD`, `TODO`, `similar to`, + `implement later`, `add appropriate ... ` in the body. ✓ +3. **Type consistency**: `eq__Str`, `@ail_str_eq`, + `try_emit_primitive_instance_body`, `locate_str_runtime`, + `loads_workspace_auto_injects_prelude`, + `eq_primitives_smoke.ail.json` — names match across tasks. ✓ +4. **Step granularity**: every step is one shell command or one + small code insertion; longest insertion is the fixture JSON in + Task 4 step 3 (still a single Write). ✓