832375f2ac
All 176 files in the four accumulating directories now use a zero-padded 4-digit counter prefix that reflects creation order (`NNNN-slug.md`). The counter is assigned per directory in strict git-log creation order; ties broken alphabetically by original name. The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is dropped — the date is recoverable from git log and the counter carries the ordering. A file's counter is stable for the life of the file: never reassigned, never reused, never compacted. Deleted files retire their counter; subsequent files do not fill the gap. This is the property that lets cross-references stay literal — refs use the full filename including the counter (`design/contracts/0007-honesty-rule.md`) so they grep cleanly and resolve directly without a glob step. 313 cross-references updated across .md/.rs/.toml/.c/.json files (test pins, include_str! paths, design-INDEX entries, baseline notes, runtime C comments, inter-contract markdown links incl. bare basename and `../models/foo.md` forms). CLAUDE.md gets a new "File-naming convention" section spelling out the rule and rationale. skills/brainstorm/SKILL.md and skills/planner/SKILL.md updated so new spec/plan creation produces counter-prefixed names from the start. The full test suite (cargo test --workspace) passes.
1023 lines
36 KiB
Markdown
1023 lines
36 KiB
Markdown
# 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 <stdbool.h>
|
|
#include <string.h>
|
|
|
|
/* 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<PathBuf> {
|
|
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 <tmpdir>/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<bool> {
|
|
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). ✓
|