From fdf0de5de716d0a28e308d027a378d626a729af4 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 9 May 2026 21:55:25 +0200 Subject: [PATCH] plan: 22b.4a form-a parser+printer arms for ClassDef/InstanceDef --- .../2026-05-09-22b4a-form-a-class-instance.md | 1379 +++++++++++++++++ 1 file changed, 1379 insertions(+) create mode 100644 docs/plans/2026-05-09-22b4a-form-a-class-instance.md diff --git a/docs/plans/2026-05-09-22b4a-form-a-class-instance.md b/docs/plans/2026-05-09-22b4a-form-a-class-instance.md new file mode 100644 index 0000000..774e31b --- /dev/null +++ b/docs/plans/2026-05-09-22b4a-form-a-class-instance.md @@ -0,0 +1,1379 @@ +# 22b.4a — Form-A parser+printer arms for ClassDef/InstanceDef + spec amendment + +> **Parent spec:** `docs/specs/2026-05-09-22-typeclasses.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` +> to run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** make `parse(print(M)) == M` (after canonicalisation) for every +`examples/*.ail.json` fixture, including the `test_22b1_*`, +`test_22b2_*`, and `test_22b3_*` typeclass fixtures that are currently +skipped. Retire the round-trip skip-list. Amend the parent spec to +record (a) the iteration split, (b) the `__` separator chosen in 22b.3 +for mono-symbol names. + +**Architecture:** the surface crate (`crates/ailang-surface`) is +**Form-A**, the s-expression projection. (The crate's docstring and +the round-trip-skip-list comment loosely call this "Form-B"; that is a +documentation defect — `crates/ailang-prose` is the actual Form-B and +has no parser. The 22 parent spec inherits the same conflation.) This +iteration adds Form-A parser arms for `Def::Class` / `Def::Instance` +under `parse_module`'s dispatch, plus matching printer arms in +`write_def`, both written so the existing canonical-equal round-trip +gate passes for every typeclass fixture in `examples/`. The 22b.1 +placeholder printer arms (single-token `(class Name param)` / +`(instance Class)`) are replaced with the real implementations. + +**Tech Stack:** `crates/ailang-surface` (parse + print), `ailang-core` +AST types (`ClassDef`, `InstanceDef`, `ClassMethod`, `InstanceMethod`, +`SuperclassRef` — all already present from 22b.1). + +**Out of scope (deferred to 22b.4b):** Prelude module with `Show` +class, `instance Show Int`, the `int_to_str` C-runtime primitive, the +end-to-end `do io/print_str (show 42)` gate. Substantive reason: the +Prelude requires a new C-runtime function, codegen wiring for +`int_to_str`, and an examples-level fixture; that is a different risk +profile from pure-surface plumbing and benefits from its own review +loop. Eq/Ord deferred further (require `str_eq` / `str_compare` +primitives that the runtime does not yet expose). Top-level `print` +deferred until polymorphic-fn monomorphisation is on the table — the +current mono pass synthesises only class-method instances, not +arbitrary polymorphic top-levels. + +--- + +## Files this plan creates or modifies + +- Modify: `crates/ailang-surface/src/parse.rs` — adds `parse_class`, + `parse_instance`, two helper sub-parsers (`parse_class_method`, + `parse_instance_method`, `parse_superclass`); extends the + `parse_module` def-head dispatch. +- Modify: `crates/ailang-surface/src/print.rs` — replaces the 22b.1 + placeholder arms in `write_def`; adds `write_class_def`, + `write_instance_def`, plus per-method writers + (`write_class_method`, `write_instance_method`, `write_superclass`). +- Modify: `crates/ailang-surface/tests/round_trip.rs` — removes the + `test_22b1_*` / `test_22b2_*` / `test_22b3_*` filter clauses; lets + the gate exercise every fixture. +- Modify: `docs/specs/2026-05-09-22-typeclasses.md` — appends an + "Amendments" section recording the 22b.4a / 22b.4b split, the + Form-A vs Form-B terminology fix, the `__` separator decision, and + the 22c-and-beyond impact. +- Modify: `docs/DESIGN.md` (Decision 11 §"Resolution and + monomorphisation") — one-paragraph note on `__` separator, citing + LLVM IR identifier legality. + +No new files. No test crate changes (round-trip + the existing +`examples/test_22b*` fixtures are the regression surface). + +--- + +## Cross-task invariants + +Three constraints every task in this plan obeys: + +1. **Round-trip property is sacred.** Any task whose final command is + `cargo test -p ailang-surface --test round_trip` MUST observe + green before commit. +2. **No new fixture files.** Hand-written inline test inputs only; + the existing `examples/test_22b*` set is the round-trip integration + target. New fixtures would expand 22b.4a beyond surface plumbing. +3. **No primitive additions and no codegen changes.** This is a pure + surface-crate iteration. If a task is tempted to touch + `ailang-codegen` or `ailang-check`, that is a sign it belongs in + 22b.4b (or further). + +--- + +## S-expression form (locked here, implemented in tasks) + +The following Form-A surface syntax is committed for ClassDef and +InstanceDef. The shape mirrors `(fn ...)` and `(data ...)`: a +keyword head, the def name (where applicable), then `(...)` +attribute-clauses in declaration order. Unknown attribute keywords +are rejected with `ParseError::Production` so future schema +extensions fail loudly. + +**ClassDef:** + +``` +(class + (param ) + [(superclass (class ) (type ))] + [(doc "")] + (method + (type ) + [(default )])* + ) +``` + +- `(param ...)` is required; matches `ClassDef.param: String`. +- `(superclass ...)` is optional; at most one. +- `(doc ...)` is optional; at most one. Same shape as the + fn-def `(doc ...)` clause. +- `(method ...)` clauses appear in declaration order. Each method + requires `(type ...)`; `(default ...)` is optional and represents + the explicit-`default`-keyword body from Decision 11. + +**InstanceDef:** + +``` +(instance + (class ) + (type ) + [(doc "")] + (method + (body ))* + ) +``` + +- No name follows `instance` — the (class, type) pair identifies it. +- `(class ...)` and `(type ...)` are required; one of each. +- `(doc ...)` is optional. +- `(method ...)` clauses each contain exactly one `(body ...)` + clause. Method order matches declaration order. + +Both heads use the existing helpers: +- `parse_type_attr` for `(type ...)` (class method types and instance + type both go through this). +- `parse_body_attr` for `(default ...)` and instance `(body ...)` + bodies — note `parse_body_attr` consumes literally `(body ...)`, + so the class-default clause is parsed by a *new* sibling helper + `parse_default_attr` that mirrors `parse_body_attr` but expects + the keyword `default` instead of `body`. + +--- + +## Task 1: Form-A parser arm for ClassDef + +**Files:** +- Modify: `crates/ailang-surface/src/parse.rs:281-320` (extend + `parse_module` dispatch); append `parse_class`, `parse_class_method`, + `parse_superclass`, `parse_default_attr` after `parse_const` + (around line 700). + +- [ ] **Step 1.1: Write the failing test (RED)** + +Append inside the existing `#[cfg(test)] mod tests { ... }` block in +`crates/ailang-surface/src/parse.rs` (the module starts at parse.rs:1353). +Put the new tests just before the closing `}` of that module, with +the existing `use super::*;` already in scope. + +```rust + #[test] + fn parses_minimal_class_def() { + let src = r#"(module M + (class Foo + (param a) + (method m + (type (fn-type (params (con a)) (ret (con Int)) (effects))))))"#; + let m = parse(src).expect("parse ok"); + assert_eq!(m.defs.len(), 1, "one def"); + match &m.defs[0] { + ailang_core::ast::Def::Class(c) => { + assert_eq!(c.name, "Foo"); + assert_eq!(c.param, "a"); + assert!(c.superclass.is_none(), "no superclass"); + assert!(c.doc.is_none(), "no doc"); + assert_eq!(c.methods.len(), 1, "one method"); + assert_eq!(c.methods[0].name, "m"); + assert!(c.methods[0].default.is_none(), + "no default"); + } + other => panic!("expected Def::Class, got {other:?}"), + } + } + + #[test] + fn parses_class_def_with_superclass_and_default() { + let src = r#"(module M + (class Bar + (param a) + (superclass (class Foo) (type a)) + (doc "bar extends foo") + (method m + (type (fn-type (params (con a)) (ret (con Int)) (effects))) + (default (lit (int 0))))))"#; + let m = parse(src).expect("parse ok"); + let c = match &m.defs[0] { + ailang_core::ast::Def::Class(c) => c, + _ => panic!("expected Class"), + }; + let sc = c.superclass.as_ref().expect("has superclass"); + assert_eq!(sc.class, "Foo"); + assert_eq!(sc.type_, "a"); + assert_eq!(c.doc.as_deref(), Some("bar extends foo")); + assert!(c.methods[0].default.is_some(), + "method default present"); + } +``` + +(`parse` is the same `pub fn parse(...)` that lives at the top of +this file and is re-exported from `ailang_surface::parse`. The Form-A +literal `(lit (int 0))` matches the existing `Term::Lit` parse arm — +verify by grepping `parse_term` for `"int"` if uncertain. The +`(fn-type ...)` form is what `parse_type_attr → parse_type` accepts; +the existing `parse_fn` tests upstream use the same shape.) + +- [ ] **Step 1.2: Run test, observe RED** + +``` +cargo test -p ailang-surface parses_minimal_class_def +``` + +Expected: FAIL with parse error along the lines of `unknown def head +"class"; expected "data", "fn", "const", or "import"` (the existing +`parse_module` error path at parse.rs:312). + +- [ ] **Step 1.3: Add `Class` to the def-head dispatch** + +In `parse_module` (parse.rs around line 309-318), extend the inner +match to recognise `class`. Leave `instance` for Task 2 (so Task 2's +RED is a parse error rather than a build error). Replace: + +```rust + match head { + "import" => imports.push(self.parse_import()?), + "data" => defs.push(Def::Type(self.parse_data()?)), + "fn" => defs.push(Def::Fn(self.parse_fn()?)), + "const" => defs.push(Def::Const(self.parse_const()?)), + other => { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "module", + message: format!( + "unknown def head `{other}`; expected `data`, `fn`, `const`, or `import`" + ), + pos, + }); + } + } +``` + +with: + +```rust + match head { + "import" => imports.push(self.parse_import()?), + "data" => defs.push(Def::Type(self.parse_data()?)), + "fn" => defs.push(Def::Fn(self.parse_fn()?)), + "const" => defs.push(Def::Const(self.parse_const()?)), + "class" => defs.push(Def::Class(self.parse_class()?)), + other => { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "module", + message: format!( + "unknown def head `{other}`; expected `data`, `fn`, `const`, `class`, or `import`" + ), + pos, + }); + } + } +``` + +Also add the AST imports at parse.rs:88. Replace: + +```rust + Arm, ConstDef, Ctor, Def, FnDef, Import, Literal, Module, ParamMode, Pattern, Suppress, Term, +``` + +with: + +```rust + Arm, ClassDef, ClassMethod, ConstDef, Ctor, Def, FnDef, Import, Literal, Module, + ParamMode, Pattern, SuperclassRef, Suppress, Term, +``` + +(Add the AST imports for `ClassDef`, `ClassMethod`, `SuperclassRef` +from `ailang_core::ast`. `InstanceDef` / `InstanceMethod` are added +by Task 2.) + +- [ ] **Step 1.4: Implement `parse_class` and helpers** + +Append to parse.rs after the existing `parse_const` block (around +line 700, between `parse_const` and `parse_type_attr`): + +```rust + // ---- class def ----------------------------------------------------- + + fn parse_class(&mut self) -> Result { + self.expect_lparen("class-def")?; + self.expect_keyword("class")?; + let name = self.expect_ident("class name")?; + let mut param: Option = None; + let mut superclass: Option = None; + let mut doc: Option = None; + let mut methods: Vec = Vec::new(); + loop { + match self.peek_head_ident() { + Some("param") => { + if param.is_some() { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "class-def", + message: format!( + "class `{name}` has duplicate `(param ...)` clause" + ), + pos, + }); + } + self.expect_lparen("class.param")?; + self.expect_keyword("param")?; + param = Some(self.expect_ident("class param ident")?); + self.expect_rparen("class.param")?; + } + Some("superclass") => { + if superclass.is_some() { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "class-def", + message: format!( + "class `{name}` has duplicate `(superclass ...)` clause" + ), + pos, + }); + } + superclass = Some(self.parse_superclass()?); + } + Some("doc") => { + if doc.is_some() { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "class-def", + message: format!( + "class `{name}` has duplicate `(doc ...)` clause" + ), + pos, + }); + } + doc = Some(self.parse_doc()?); + } + Some("method") => { + methods.push(self.parse_class_method()?); + } + Some(other) => { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "class-def", + message: format!( + "unknown class attribute `{other}`; expected `param`, `superclass`, `doc`, or `method`" + ), + pos, + }); + } + None => break, + } + } + self.expect_rparen("class-def")?; + let param = param.ok_or_else(|| ParseError::Production { + production: "class-def", + message: format!("class `{name}` is missing required `(param ...)` clause"), + pos: 0, + })?; + Ok(ClassDef { + name, + param, + superclass, + methods, + doc, + }) + } + + fn parse_superclass(&mut self) -> Result { + self.expect_lparen("superclass-clause")?; + self.expect_keyword("superclass")?; + // (class Name) + self.expect_lparen("superclass.class")?; + self.expect_keyword("class")?; + let class = self.expect_ident("superclass name")?; + self.expect_rparen("superclass.class")?; + // (type ident) + self.expect_lparen("superclass.type")?; + self.expect_keyword("type")?; + let type_ = self.expect_ident("superclass param ident")?; + self.expect_rparen("superclass.type")?; + self.expect_rparen("superclass-clause")?; + Ok(SuperclassRef { class, type_ }) + } + + fn parse_class_method(&mut self) -> Result { + self.expect_lparen("class.method")?; + self.expect_keyword("method")?; + let name = self.expect_ident("class method name")?; + let mut ty: Option = None; + let mut default: Option = None; + loop { + match self.peek_head_ident() { + Some("type") => { + if ty.is_some() { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "class.method", + message: format!( + "method `{name}` has duplicate `(type ...)` clause" + ), + pos, + }); + } + ty = Some(self.parse_type_attr()?); + } + Some("default") => { + if default.is_some() { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "class.method", + message: format!( + "method `{name}` has duplicate `(default ...)` clause" + ), + pos, + }); + } + default = Some(self.parse_default_attr()?); + } + Some(other) => { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "class.method", + message: format!( + "unknown class.method attribute `{other}`; expected `type` or `default`" + ), + pos, + }); + } + None => break, + } + } + self.expect_rparen("class.method")?; + let ty = ty.ok_or_else(|| ParseError::Production { + production: "class.method", + message: format!("method `{name}` is missing required `(type ...)` clause"), + pos: 0, + })?; + Ok(ClassMethod { name, ty, default }) + } + + fn parse_default_attr(&mut self) -> Result { + self.expect_lparen("default-attr")?; + self.expect_keyword("default")?; + let t = self.parse_term()?; + self.expect_rparen("default-attr")?; + Ok(t) + } +``` + +- [ ] **Step 1.5: Run RED tests, observe GREEN** + +``` +cargo test -p ailang-surface parses_minimal_class_def +cargo test -p ailang-surface parses_class_def_with_superclass_and_default +cargo build --workspace +``` + +Expected: both tests PASS. Workspace build green. + +- [ ] **Step 1.6: Commit** + +``` +git add crates/ailang-surface/src/parse.rs +git commit -m "iter 22b.4a.1: form-a parser arm for ClassDef" +``` + +--- + +## Task 2: Form-A parser arm for InstanceDef + +**Files:** +- Modify: `crates/ailang-surface/src/parse.rs` — append + `parse_instance` and `parse_instance_method` after `parse_class` + block. + +- [ ] **Step 2.1: Write the failing test (RED)** + +Append inside the same `#[cfg(test)] mod tests { ... }` block as +Task 1: + +```rust + #[test] + fn parses_minimal_instance_def() { + let src = r#"(module M + (instance + (class Foo) + (type (con Int)) + (method m + (body (lit (int 5))))))"#; + let m = parse(src).expect("parse ok"); + match &m.defs[0] { + ailang_core::ast::Def::Instance(i) => { + assert_eq!(i.class, "Foo"); + match &i.type_ { + ailang_core::ast::Type::Con { name, args } => { + assert_eq!(name, "Int"); + assert!(args.is_empty(), "Int has no args"); + } + other => panic!("expected Type::Con, got {other:?}"), + } + assert!(i.doc.is_none()); + assert_eq!(i.methods.len(), 1); + assert_eq!(i.methods[0].name, "m"); + } + other => panic!("expected Def::Instance, got {other:?}"), + } + } + + #[test] + fn parses_instance_def_with_doc() { + let src = r#"(module M + (instance + (class Foo) + (type (con Int)) + (doc "instance for ints") + (method m (body (lit (int 7))))))"#; + let m = parse(src).expect("parse ok"); + let i = match &m.defs[0] { + ailang_core::ast::Def::Instance(i) => i, + _ => panic!("expected Instance"), + }; + assert_eq!(i.doc.as_deref(), Some("instance for ints")); + } +``` + +(`Type::Con` carries `{ name, args }` — `args` is `Vec`; for +`Int` it is empty.) + +- [ ] **Step 2.2: Run test, observe RED** + +``` +cargo test -p ailang-surface parses_minimal_instance_def +``` + +Expected: FAIL with parse error `unknown def head "instance"; expected …` +emitted by the `parse_module` dispatch arm. + +- [ ] **Step 2.3: Add `Instance` to the def-head dispatch** + +Locate the same `parse_module` match block edited in Task 1.3 and +add the `instance` arm + extend the error message: + +```rust + match head { + "import" => imports.push(self.parse_import()?), + "data" => defs.push(Def::Type(self.parse_data()?)), + "fn" => defs.push(Def::Fn(self.parse_fn()?)), + "const" => defs.push(Def::Const(self.parse_const()?)), + "class" => defs.push(Def::Class(self.parse_class()?)), + "instance" => defs.push(Def::Instance(self.parse_instance()?)), + other => { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "module", + message: format!( + "unknown def head `{other}`; expected `data`, `fn`, `const`, `class`, `instance`, or `import`" + ), + pos, + }); + } + } +``` + +Extend the AST import line at parse.rs:88 to include `InstanceDef` +and `InstanceMethod`: + +```rust + Arm, ClassDef, ClassMethod, ConstDef, Ctor, Def, FnDef, Import, InstanceDef, + InstanceMethod, Literal, Module, ParamMode, Pattern, SuperclassRef, Suppress, Term, +``` + +- [ ] **Step 2.4: Implement `parse_instance` and `parse_instance_method`** + +Append to parse.rs after the `parse_class_method` / `parse_default_attr` +block from Task 1: + +```rust + // ---- instance def -------------------------------------------------- + + fn parse_instance(&mut self) -> Result { + self.expect_lparen("instance-def")?; + self.expect_keyword("instance")?; + let mut class: Option = None; + let mut type_: Option = None; + let mut doc: Option = None; + let mut methods: Vec = Vec::new(); + loop { + match self.peek_head_ident() { + Some("class") => { + if class.is_some() { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "instance-def", + message: "instance has duplicate `(class ...)` clause".into(), + pos, + }); + } + self.expect_lparen("instance.class")?; + self.expect_keyword("class")?; + class = Some(self.expect_ident("instance class name")?); + self.expect_rparen("instance.class")?; + } + Some("type") => { + if type_.is_some() { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "instance-def", + message: "instance has duplicate `(type ...)` clause".into(), + pos, + }); + } + type_ = Some(self.parse_type_attr()?); + } + Some("doc") => { + if doc.is_some() { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "instance-def", + message: "instance has duplicate `(doc ...)` clause".into(), + pos, + }); + } + doc = Some(self.parse_doc()?); + } + Some("method") => { + methods.push(self.parse_instance_method()?); + } + Some(other) => { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "instance-def", + message: format!( + "unknown instance attribute `{other}`; expected `class`, `type`, `doc`, or `method`" + ), + pos, + }); + } + None => break, + } + } + self.expect_rparen("instance-def")?; + let class = class.ok_or_else(|| ParseError::Production { + production: "instance-def", + message: "instance is missing required `(class ...)` clause".into(), + pos: 0, + })?; + let type_ = type_.ok_or_else(|| ParseError::Production { + production: "instance-def", + message: "instance is missing required `(type ...)` clause".into(), + pos: 0, + })?; + Ok(InstanceDef { + class, + type_, + methods, + doc, + }) + } + + fn parse_instance_method(&mut self) -> Result { + self.expect_lparen("instance.method")?; + self.expect_keyword("method")?; + let name = self.expect_ident("instance method name")?; + let mut body: Option = None; + loop { + match self.peek_head_ident() { + Some("body") => { + if body.is_some() { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "instance.method", + message: format!( + "instance method `{name}` has duplicate `(body ...)` clause" + ), + pos, + }); + } + body = Some(self.parse_body_attr()?); + } + Some(other) => { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "instance.method", + message: format!( + "unknown instance.method attribute `{other}`; expected `body`" + ), + pos, + }); + } + None => break, + } + } + self.expect_rparen("instance.method")?; + let body = body.ok_or_else(|| ParseError::Production { + production: "instance.method", + message: format!( + "instance method `{name}` is missing required `(body ...)` clause" + ), + pos: 0, + })?; + Ok(InstanceMethod { name, body }) + } +``` + +- [ ] **Step 2.5: Run RED tests, observe GREEN** + +``` +cargo test -p ailang-surface parses_minimal_instance_def +cargo test -p ailang-surface parses_instance_def_with_doc +cargo build --workspace +``` + +Expected: tests PASS. Build green. + +- [ ] **Step 2.6: Commit** + +``` +git add crates/ailang-surface/src/parse.rs +git commit -m "iter 22b.4a.2: form-a parser arm for InstanceDef" +``` + +--- + +## Task 3: Form-A printer arm for ClassDef + +**Files:** +- Modify: `crates/ailang-surface/src/print.rs` — replace the `Def::Class` + placeholder arm in `write_def` (print.rs:99-107); append + `write_class_def`, `write_class_method`, `write_superclass`, + `write_class_default` after `write_const_def`. + +- [ ] **Step 3.1: Write the failing test (RED)** + +`crates/ailang-surface/src/print.rs` does NOT have an existing +`#[cfg(test)] mod tests` block (verified 2026-05-09). Add one at +end of file: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use ailang_core::ast::{ + ClassDef, ClassMethod, Def, InstanceDef, InstanceMethod, Literal, Module, + ParamMode, SuperclassRef, Term, Type, + }; + + fn round_trip(m: Module, label: &str) { + let printed = print(&m); + let parsed = crate::parse::parse(&printed) + .unwrap_or_else(|e| panic!( + "{label}: re-parse failed: {e:?}\nprinted:\n{printed}" + )); + let bytes_orig = ailang_core::canonical::to_bytes(&m); + let bytes_back = ailang_core::canonical::to_bytes(&parsed); + if bytes_orig != bytes_back { + let s_orig = String::from_utf8_lossy(&bytes_orig).into_owned(); + let s_back = String::from_utf8_lossy(&bytes_back).into_owned(); + panic!( + "{label}: canonical bytes differ.\noriginal: {s_orig}\nround: {s_back}\nprinted:\n{printed}" + ); + } + } + + #[test] + fn print_then_parse_round_trip_minimal_class() { + let m = Module { + schema: ailang_core::SCHEMA.to_string(), + name: "M".into(), + imports: vec![], + defs: vec![Def::Class(ClassDef { + name: "Foo".into(), + param: "a".into(), + superclass: None, + methods: vec![ClassMethod { + name: "m".into(), + ty: Type::Fn { + params: vec![Type::Var { name: "a".into() }], + param_modes: vec![ParamMode::Implicit], + ret: Box::new(Type::int()), + ret_mode: ParamMode::Implicit, + effects: vec![], + }, + default: None, + }], + doc: None, + })], + }; + round_trip(m, "minimal_class"); + } + + #[test] + fn print_then_parse_round_trip_class_with_superclass_and_default() { + let m = Module { + schema: ailang_core::SCHEMA.to_string(), + name: "M".into(), + imports: vec![], + defs: vec![Def::Class(ClassDef { + name: "Bar".into(), + param: "a".into(), + superclass: Some(SuperclassRef { + class: "Foo".into(), + type_: "a".into(), + }), + methods: vec![ClassMethod { + name: "m".into(), + ty: Type::Fn { + params: vec![Type::Var { name: "a".into() }], + param_modes: vec![ParamMode::Implicit], + ret: Box::new(Type::int()), + ret_mode: ParamMode::Implicit, + effects: vec![], + }, + default: Some(Term::Lit { lit: Literal::Int { value: 0 } }), + }], + doc: Some("docline".into()), + })], + }; + round_trip(m, "class_with_superclass_and_default"); + } +} +``` + +(Verified against `crates/ailang-core/src/ast.rs:585` — +`Type::Fn` has fields `params, param_modes, ret, ret_mode, effects` +ONLY; constraints live on `Type::Forall`, not on `Type::Fn`. The +canonical comparison helper is `ailang_core::canonical::to_bytes`, +returning `Vec` — same one used in +`crates/ailang-surface/tests/round_trip.rs:90`.) + +- [ ] **Step 3.2: Run test, observe RED** + +``` +cargo test -p ailang-surface print_then_parse_round_trip_minimal_class +``` + +Expected: FAIL — the existing `Def::Class` arm in `write_def` (print.rs:99-107) +emits `(class Foo a)` only, which loses methods, so re-parse either +errors (missing `(param ...)` clause, missing methods) or canonical +JSON diverges. + +- [ ] **Step 3.3: Replace placeholder + add helpers** + +Edit print.rs's `write_def`. Replace the entire `Def::Class` and +`Def::Instance` placeholder arms (the 22b.1 stub block at print.rs:97-114) +with: + +```rust + Def::Class(c) => write_class_def(out, c, level), + Def::Instance(i) => write_instance_def(out, i, level), +``` + +Append below `write_const_def` (around print.rs:240): + +```rust +fn write_class_def(out: &mut String, c: &ClassDef, level: usize) { + indent(out, level); + out.push_str("(class "); + out.push_str(&c.name); + out.push('\n'); + indent(out, level + 1); + out.push_str("(param "); + out.push_str(&c.param); + out.push(')'); + if let Some(sc) = &c.superclass { + out.push('\n'); + write_superclass(out, sc, level + 1); + } + if let Some(doc) = &c.doc { + out.push('\n'); + indent(out, level + 1); + out.push_str("(doc "); + write_string_lit(out, doc); + out.push(')'); + } + for m in &c.methods { + out.push('\n'); + write_class_method(out, m, level + 1); + } + out.push(')'); +} + +fn write_superclass(out: &mut String, sc: &SuperclassRef, level: usize) { + indent(out, level); + out.push_str("(superclass (class "); + out.push_str(&sc.class); + out.push_str(") (type "); + out.push_str(&sc.type_); + out.push_str("))"); +} + +fn write_class_method(out: &mut String, m: &ClassMethod, level: usize) { + indent(out, level); + out.push_str("(method "); + out.push_str(&m.name); + out.push('\n'); + indent(out, level + 1); + out.push_str("(type "); + write_type(out, &m.ty); + out.push(')'); + if let Some(default) = &m.default { + out.push('\n'); + indent(out, level + 1); + out.push_str("(default "); + write_term(out, default, level + 2); + out.push(')'); + } + out.push(')'); +} +``` + +Add the AST imports at print.rs:11. Replace: + +```rust + Arm, ConstDef, Ctor, Def, FnDef, Import, Literal, Module, ParamMode, Pattern, Suppress, Term, + Type, TypeDef, +``` + +with: + +```rust + Arm, ClassDef, ClassMethod, ConstDef, Ctor, Def, FnDef, Import, InstanceDef, + InstanceMethod, Literal, Module, ParamMode, Pattern, SuperclassRef, Suppress, Term, + Type, TypeDef, +``` + +(`InstanceDef` / `InstanceMethod` will be unused at end of this +task; mirrors Task 1's transient-warning shape — the `#[allow]` +gate or the next task closes it.) + +- [ ] **Step 3.4: Run RED tests, observe GREEN** + +``` +cargo test -p ailang-surface print_then_parse_round_trip_minimal_class +cargo test -p ailang-surface print_then_parse_round_trip_class_with_superclass_and_default +cargo build --workspace +``` + +Expected: both PASS. + +- [ ] **Step 3.5: Commit** + +``` +git add crates/ailang-surface/src/print.rs +git commit -m "iter 22b.4a.3: form-a printer arm for ClassDef" +``` + +--- + +## Task 4: Form-A printer arm for InstanceDef + +**Files:** +- Modify: `crates/ailang-surface/src/print.rs` — append + `write_instance_def` and `write_instance_method` after Task 3's + block. + +- [ ] **Step 4.1: Write the failing test (RED)** + +Append inside the `#[cfg(test)] mod tests { ... }` block created in +Task 3 (in print.rs): + +```rust + #[test] + fn print_then_parse_round_trip_minimal_instance() { + let m = Module { + schema: ailang_core::SCHEMA.to_string(), + name: "M".into(), + imports: vec![], + defs: vec![Def::Instance(InstanceDef { + class: "Foo".into(), + type_: Type::int(), + methods: vec![InstanceMethod { + name: "m".into(), + body: Term::Lit { lit: Literal::Int { value: 5 } }, + }], + doc: None, + })], + }; + round_trip(m, "minimal_instance"); + } +``` + +- [ ] **Step 4.2: Run test, observe RED** + +``` +cargo test -p ailang-surface print_then_parse_round_trip_minimal_instance +``` + +Expected: FAIL — the original `Def::Instance` placeholder +(`(instance Class)`) was already replaced in Task 3 with a call to +`write_instance_def`, which doesn't exist yet. This is a build +failure (rather than an assertion failure), which still counts as RED. + +- [ ] **Step 4.3: Implement the helpers** + +Append to print.rs after `write_class_method`: + +```rust +fn write_instance_def(out: &mut String, i: &InstanceDef, level: usize) { + indent(out, level); + out.push_str("(instance"); + out.push('\n'); + indent(out, level + 1); + out.push_str("(class "); + out.push_str(&i.class); + out.push(')'); + out.push('\n'); + indent(out, level + 1); + out.push_str("(type "); + write_type(out, &i.type_); + out.push(')'); + if let Some(doc) = &i.doc { + out.push('\n'); + indent(out, level + 1); + out.push_str("(doc "); + write_string_lit(out, doc); + out.push(')'); + } + for m in &i.methods { + out.push('\n'); + write_instance_method(out, m, level + 1); + } + out.push(')'); +} + +fn write_instance_method(out: &mut String, m: &InstanceMethod, level: usize) { + indent(out, level); + out.push_str("(method "); + out.push_str(&m.name); + out.push('\n'); + indent(out, level + 1); + out.push_str("(body "); + write_term(out, &m.body, level + 2); + out.push(')'); + out.push(')'); +} +``` + +- [ ] **Step 4.4: Run RED test + full surface tests, observe GREEN** + +``` +cargo test -p ailang-surface print_then_parse_round_trip_minimal_instance +cargo test -p ailang-surface +cargo build --workspace +``` + +Expected: GREEN. Including the existing 22b.1 round-trip placeholder +test (if one exists) — the printer no longer emits the lossy stub. + +- [ ] **Step 4.5: Commit** + +``` +git add crates/ailang-surface/src/print.rs +git commit -m "iter 22b.4a.4: form-a printer arm for InstanceDef" +``` + +--- + +## Task 5: Retire round-trip skip-list + +**Files:** +- Modify: `crates/ailang-surface/tests/round_trip.rs:36-50` — remove + the `test_22b1_*` / `test_22b2_*` / `test_22b3_*` filter clauses. + +- [ ] **Step 5.1: Run the round-trip test against the unfiltered set first** + +Before editing, sanity-check that the printer/parser arms cover every +22b fixture. From the workspace root: + +``` +for f in examples/test_22b1_*.ail.json examples/test_22b2_*.ail.json examples/test_22b3_*.ail.json; do + cargo run -q -p ail -- render --form a "$f" > /tmp/rt.txt 2>&1 || echo "RENDER FAIL: $f" +done +``` + +Expected: every fixture renders without error. `ail render` is the +existing CLI subcommand wired to `ailang_surface::print`. If a +fixture errors, that is a Task 5 blocker — diagnose under `debug` +skill before retiring the filter. + +- [ ] **Step 5.2: Remove the filter clauses** + +Edit `crates/ailang-surface/tests/round_trip.rs`. Find the +`list_json_fixtures` function (round_trip.rs:25-55) and replace the +filter block: + +```rust + n.ends_with(".ail.json") + && !n.starts_with("test_22b1_") + && !n.starts_with("test_22b2_") + && !n.starts_with("test_22b3_") +``` + +with: + +```rust + n.ends_with(".ail.json") +``` + +Also remove the now-stale comment block immediately above (the +`// Iter 22b.1 / 22b.2 / 22b.3 ...` comment); replace with a +one-line comment: + +```rust + // Round-trip every fixture. The 22b.1/22b.2/22b.3 filter + // was retired in 22b.4a once the Form-A parser+printer + // arms for ClassDef / InstanceDef landed. +``` + +- [ ] **Step 5.3: Run the gate** + +``` +cargo test -p ailang-surface --test round_trip +``` + +Expected: PASS, with `passed: ` strictly greater than the +previous filtered count. If a fixture fails, do NOT re-add the +filter — that is silent regression. Diagnose via `debug` skill +mini-mode and fix before continuing. + +- [ ] **Step 5.4: Run full bench gates** + +The mono pass and the surface arms must coexist with the regression +suite: + +``` +python3 bench/check.py +python3 bench/compile_check.py +python3 bench/cross_lang.py +``` + +Expected: all three exit 0/0/0 (matches 22b.3 close). + +- [ ] **Step 5.5: Commit** + +``` +git add crates/ailang-surface/tests/round_trip.rs +git commit -m "iter 22b.4a.5: retire round-trip skip-list for class/instance fixtures" +``` + +--- + +## Task 6: Spec amendment + +**Files:** +- Modify: `docs/specs/2026-05-09-22-typeclasses.md` — append an + "Amendments" section. +- Modify: `docs/DESIGN.md` — locate Decision 11 §"Resolution and + monomorphisation" (DESIGN.md mono-symbol naming subsection) and + add a one-paragraph note on the `__` separator. + +- [ ] **Step 6.1: Append the spec amendments section** + +Open `docs/specs/2026-05-09-22-typeclasses.md`. Append at end of +file: + +```markdown + +## Amendments + +### 2026-05-09 — Iteration split: 22b.4 → 22b.4a + 22b.4b + +Original 22b.4 scope ("Prelude module Show/Eq/Ord on Int/Float/Bool/String, +print rewiring, Form-B parser/printer arms, round-trip filter retired") +splits into two iterations: + +- **22b.4a (this iter):** Form-A parser+printer arms for ClassDef + and InstanceDef; round-trip skip-list retired for `test_22b1_*`, + `test_22b2_*`, `test_22b3_*`. Pure surface plumbing, no codegen + or runtime change. +- **22b.4b (queued):** Prelude module containing `class Show a where + show : (a borrow) -> Str`, `instance Show Int`, the new + `int_to_str` C-runtime primitive, codegen wiring for it, and an + end-to-end `show 42` fixture that prints `42` through the existing + mono pass. + +Substantive rationale: 22b.4a touches one crate (`ailang-surface`) +with no runtime/codegen risk; 22b.4b touches `runtime/`, +`ailang-codegen`, `ailang-check::builtins`, plus a new fixture, with +a different review surface and a different bench-gate exposure. +Bundling them was a brainstorm-time underestimate of the +runtime-side work; running them as separate iterations preserves +the bite-sized cycle the skill system enforces. + +### 2026-05-09 — Form-A vs Form-B terminology fix + +The original 22 spec ("Form-B parser/printer arms for ClassDef +/InstanceDef") and the 22b.1 round-trip-skip-list comment both +loosely call the s-expression projection "Form-B". That is a +terminology error: `crates/ailang-surface` is **Form-A** (the +parseable s-expression). `crates/ailang-prose` is **Form-B** +(human-readable, one-way projection — no parser by design). The +22b.4a arms are Form-A. Form-B printer arms for ClassDef / +InstanceDef are NOT required for the round-trip gate (it is +unparser-side); they are queued as an audit-cycle nicety, not as +a milestone-22 acceptance criterion. + +The skip-list comment that read "the surface (Form-B) parser/printer +arms" was retired alongside the filter (Task 5). + +### 2026-05-09 — Mono-symbol separator: `__` chosen over `#` + +22a's brainstorm recommended `#` for the +mono-symbol naming format with the implementer free to choose. 22b.3 +implemented `__` instead (e.g. `show__Int`, +`eq__Bool`). Reason: `#` is an invalid character in LLVM IR global +identifiers (`@ail_..._foo#Int_clos` is rejected by the LLVM +verifier). `__` is verifier-legal, identifier-legal in C-glue +contexts, and parses unambiguously into method + type surface name +(neither method names nor type surface names contain `__` by +convention). The recommendation in §"Open commitments (22a → 22b +implementer)" is updated to read `__`. + +Hash-suffix form for compound types (`__<8-hex>`) follows +the same separator. + +### 2026-05-09 — Acceptance criteria 4 still binding + +Acceptance criterion 4 ("Round-trip filter `test_22b1_*` is removed; +all `examples/*.ail.json` pass round-trip") is satisfied by 22b.4a. +The `test_22b2_*` and `test_22b3_*` filters added subsequently are +also retired here. No further round-trip gate work is required for +22 milestone close. +``` + +- [ ] **Step 6.2: Add the `__` separator note to DESIGN.md** + +Locate the DESIGN.md section that describes mono-symbol naming +under Decision 11. Find the subsection that mentions the symbol +shape (search DESIGN.md for `mono-symbol` or `monomorphis` or +`show#Int`). + +``` +grep -n "mono.*symbol\|show#Int\|show@Int\|monomorphi" docs/DESIGN.md +``` + +If a paragraph references `show@Int` or `show#Int`, replace the +example to read `show__Int` and append one sentence: + +``` +The separator is `__` rather than `#` or `@` because `#` and `@` +are invalid in LLVM IR global identifiers (the IR verifier rejects +them inside `@ail__` mangled names). `__` is legal in +both LLVM IR and the C ABI used by the runtime glue, and parses +unambiguously into `__` because neither +component contains `__` by project convention. +``` + +If DESIGN.md does not yet have a mono-symbol-naming paragraph +(possible — Decision 11 is high-level), then this step is a no-op +for DESIGN.md. The 22b.4a JOURNAL entry (Task 7) and the spec +amendment above suffice; do not invent a DESIGN.md section for the +sole purpose of placing the note. (DESIGN.md is canonical; spec is +the right home for naming-convention micro-decisions.) + +- [ ] **Step 6.3: Commit** + +``` +git add docs/specs/2026-05-09-22-typeclasses.md docs/DESIGN.md +git commit -m "iter 22b.4a.6: spec + DESIGN amendments — 22b.4 split, terminology fix, '__' separator" +``` + +--- + +## Task 7: JOURNAL entry + +**Files:** +- Modify: `docs/JOURNAL.md` — append iteration entry. + +- [ ] **Step 7.1: Append the iteration entry** + +```markdown +## 2026-05-09 — Iteration 22b.4a: Form-A parser+printer arms for ClassDef/InstanceDef + +Closes the round-trip gap that 22b.1 deliberately deferred: every +`examples/*.ail.json` fixture now round-trips through +`parse(print(M))` with canonical-equal JSON, including the +`test_22b1_*`, `test_22b2_*`, `test_22b3_*` typeclass fixtures that +the skip-list excluded. + +**Per-task subjects (mirror commit messages):** + +- 22b.4a.1: form-a parser arm for ClassDef +- 22b.4a.2: form-a parser arm for InstanceDef +- 22b.4a.3: form-a printer arm for ClassDef +- 22b.4a.4: form-a printer arm for InstanceDef +- 22b.4a.5: retire round-trip skip-list for class/instance fixtures +- 22b.4a.6: spec + DESIGN amendments — 22b.4 split, terminology fix, '__' separator + +**S-expression form locked:** + +ClassDef: +``` +(class + (param ) + [(superclass (class ) (type ))] + [(doc "...")] + (method (type ) [(default )])*) +``` + +InstanceDef: +``` +(instance + (class ) + (type ) + [(doc "...")] + (method (body ))*) +``` + +**Iteration split rationale:** the brainstorm-original 22b.4 bundled +surface arms with the Prelude module + `int_to_str` C-runtime +primitive + end-to-end `show 42` fixture. Surface arms touch one +crate with no runtime risk; the Prelude work touches `runtime/`, +`ailang-codegen`, `ailang-check::builtins`, plus codegen wiring for +the new primitive. Different review surface, different risk profile, +different bench-gate exposure. The split (22b.4a / 22b.4b) is +recorded in the spec amendments section and is the substantive +follow-up for 22b.4b. + +**Terminology fix:** the original spec called the surface arms +"Form-B parser/printer arms". `crates/ailang-surface` is in fact +**Form-A** (parseable s-expression); `crates/ailang-prose` is Form-B +(one-way prose, no parser by design). The 22b.1 round-trip-skip-list +comment carried the same conflation. Both corrected in the spec +amendment. + +**Known debt (deferred to 22b.4b):** + +- `Form-B` (prose) printer arm for ClassDef / InstanceDef. One-way, + not gating; queued for the audit cycle or 22b.4b cleanup. +- DESIGN.md mono-symbol-naming paragraph: if Decision 11 acquires + one in a future revision, the `__` rationale lands there. For now + the spec amendment is the canonical record. + +**Bench gates:** all three (`bench/check.py`, `bench/compile_check.py`, +`bench/cross_lang.py`) exited 0/0/0 (matches 22b.3 close). + +**Next iter (queued):** 22b.4b — Prelude module with `class Show +a where show : (a borrow) -> Str`, `instance Show Int`, the +`int_to_str` C-runtime primitive, codegen wiring, and an end-to-end +`show 42` fixture printing `42` through the mono pass. +``` + +- [ ] **Step 7.2: Commit** + +``` +git add docs/JOURNAL.md +git commit -m "iter 22b.4a: journal entry" +``` + +--- + +## Acceptance gate (run after Task 7) + +``` +cargo test --workspace +python3 bench/check.py +python3 bench/compile_check.py +python3 bench/cross_lang.py +``` + +All four green is the iteration close. If any fail, the most +likely culprit is round-trip on a fixture that uses an unusual +Term shape inside a class default or instance body — diagnose via +`debug` skill, NOT by re-introducing the skip-list.