diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index 16216a1..fd9bd96 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -844,6 +844,10 @@ fn walk_term( scope.remove(&p); } } + Term::Seq { lhs, rhs } => { + walk_term(lhs, out, builtins, scope); + walk_term(rhs, out, builtins, scope); + } } } diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index a4d027e..57e81d9 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -797,6 +797,14 @@ fn synth( Ok(result_ty.expect("checked arms is non-empty")) } + Term::Seq { lhs, rhs } => { + // Iter 10: `lhs ; rhs`. lhs must be Unit (its value is + // discarded). Effects from both sides accumulate. The + // expression's type is rhs's type. + let lty = synth(lhs, env, locals, effects, in_def)?; + expect_eq(&Type::unit(), <y)?; + synth(rhs, env, locals, effects, in_def) + } Term::Lam { params, param_tys, ret_ty, effects: lam_effects, body } => { // Iter 8b: a lambda's type is the declared `Type::Fn`. Push // params as locals, check the body's type matches `ret_ty`, @@ -1203,4 +1211,36 @@ mod tests { let err = check(&m).unwrap_err(); assert!(format!("{err}").contains("type mismatch")); } + + /// Iter 10: `seq` requires lhs to be Unit. A non-Unit lhs is a + /// type error — the value of lhs gets discarded so a useful (non- + /// Unit) value would silently vanish. + #[test] + fn seq_lhs_must_be_unit() { + let m = Module { + schema: SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![fn_def( + "f", + Type::Fn { + params: vec![], + ret: Box::new(Type::int()), + effects: vec![], + }, + vec![], + Term::Seq { + lhs: Box::new(Term::Lit { + lit: Literal::Int { value: 7 }, + }), + rhs: Box::new(Term::Lit { + lit: Literal::Int { value: 1 }, + }), + }, + )], + }; + let err = check(&m).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("type mismatch"), "got: {msg}"); + } } diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index b6a87e9..094a0a8 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -634,6 +634,12 @@ impl<'a> Emitter<'a> { Term::Lam { params, param_tys, ret_ty, effects: _, body } => { self.lower_lambda(params, param_tys, ret_ty, body) } + Term::Seq { lhs, rhs } => { + // Iter 10: lower lhs for its effects, discard the SSA; + // lower rhs and return its value as the whole expression. + let _ = self.lower_term(lhs)?; + self.lower_term(rhs) + } } } @@ -1326,6 +1332,10 @@ impl<'a> Emitter<'a> { bound.remove(&n); } } + Term::Seq { lhs, rhs } => { + Self::collect_captures(lhs, bound, captures, captures_set, builtins, top_level, import_map); + Self::collect_captures(rhs, bound, captures, captures_set, builtins, top_level, import_map); + } } } diff --git a/crates/ailang-core/src/ast.rs b/crates/ailang-core/src/ast.rs index fd84b85..0017ad6 100644 --- a/crates/ailang-core/src/ast.rs +++ b/crates/ailang-core/src/ast.rs @@ -142,6 +142,14 @@ pub enum Term { effects: Vec, body: Box, }, + /// Sequencing (Iter 10). `lhs` is evaluated for its effects and its + /// result discarded; `rhs` is the value of the whole expression. + /// Equivalent to `let _ = lhs in rhs`, but with a dedicated node so + /// pretty-print and diagnostics read cleanly. + Seq { + lhs: Box, + rhs: Box, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/ailang-core/src/pretty.rs b/crates/ailang-core/src/pretty.rs index 27a9428..ed7ad77 100644 --- a/crates/ailang-core/src/pretty.rs +++ b/crates/ailang-core/src/pretty.rs @@ -220,6 +220,14 @@ fn term_block(t: &Term, indent: usize) -> String { s.push(')'); s } + Term::Seq { lhs, rhs } => { + let mut s = format!("{pad}(seq\n"); + s.push_str(&term_block(lhs, indent + 2)); + s.push('\n'); + s.push_str(&term_block(rhs, indent + 2)); + s.push(')'); + s + } } } @@ -298,6 +306,9 @@ fn term_inline(t: &Term) -> String { Term::Lam { params, .. } => { format!("(\\ {} ...)", params.join(" ")) } + Term::Seq { lhs, rhs } => { + format!("(seq {} {})", term_inline(lhs), term_inline(rhs)) + } } } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b7f3506..bc3a115 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -186,12 +186,15 @@ hashes stay bit-identical. "retType": Type, "effects": [""...], "body": Term } +{ "t": "seq", "lhs": Term, "rhs": Term } ``` In the MVP, `do` is only a direct call to a built-in effect op (no handler). A `lam` term constructs an anonymous function value; free variables of its body are captured from the enclosing scope (see Iter 8 closure -conversion in JOURNAL). +conversion in JOURNAL). A `seq` term evaluates `lhs` for its effects +(its result must be Unit) and yields `rhs`'s value — equivalent to +`let _ = lhs in rhs`. ### Type diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 1c2bfd0..abe504d 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -759,3 +759,54 @@ big architectural gap. Candidates, ranked: Tentative pick: (2) for the next sprint as a satisfying small polish, then (1) as Iter 11. (3) bides its time until a real program needs it. + +## 2026-05-07 — Iter 10 done: Term::Seq sequencing + +Followed the Iter 9 plan and shipped (2). New AST node +`Term::Seq { lhs, rhs }` with serde tag "seq". Semantics: evaluate +lhs (which must be Unit), discard the value, return rhs. Effects +from both sides accumulate. + +This is sugar for `let _ = lhs in rhs`, but it's a first-class node +because: +- The pretty-print renders cleanly (`(seq lhs rhs)` instead of + borrowing the `let` form with a discard binding). +- Diagnostics are sharper: a non-Unit lhs gets a "type mismatch" + error pointing at the seq site, not "binding `_` had type X" at + a let site. +- Future tooling (effect inference visualisation, dataflow) can + treat sequencing as a structural concept instead of a special- + cased let. + +Codegen is trivial: lower lhs (drop SSA), lower rhs (return). + +Refactored `examples/list_map.ail.json`'s `print_list` to use seq +instead of `let _ = ...`. Output unchanged (`2\\n4\\n6\\n`); the +JSON shed a few lines and reads more honestly. + +**Architecture self-check:** + +- *Would I use this language now?* Same answer as Iter 9 (yes for + small but real programs), but the seq node makes IO-heavy + recursion read better — closer to "call this effect, then this + one" instead of "bind this effect to nothing, then this one". +- *Did I break anything?* Hash stability check: existing examples + without `Term::Seq` serialise identically; their fn hashes are + unchanged. `list_map.ail.json`'s hashes shifted as expected since + its body changed. +- *KISS:* +30 LOC across AST/pretty/check/codegen/walker. One + unit test for the lhs-must-be-Unit rule. The dogfood example + proves the e2e path. + +**Tests:** 51 green (was 50). New `seq_lhs_must_be_unit` unit test +in ailang-check. Existing list_map e2e still passes after the +refactor. + +**Plan iteration 11:** + +Polymorphism, as queued in the Iter 9 plan. Concretely: HM-style +unification + let-generalisation in the typechecker, monomorph- +isation at codegen time. Touches the typechecker→codegen pipeline. +Bigger commit than the recent stretch, will probably need to be +phased (typechecker substitution machinery, then codegen +specialisation, then docs). diff --git a/examples/list_map.ail.json b/examples/list_map.ail.json index 003c5bf..08901f6 100644 --- a/examples/list_map.ail.json +++ b/examples/list_map.ail.json @@ -112,14 +112,13 @@ ] }, "body": { - "t": "let", - "name": "_print_h", - "value": { + "t": "seq", + "lhs": { "t": "do", "op": "io/print_int", "args": [{ "t": "var", "name": "h" }] }, - "body": { + "rhs": { "t": "app", "fn": { "t": "var", "name": "print_list" }, "args": [{ "t": "var", "name": "t" }]