Iter 10: Term::Seq sequencing operator
Adds `Term::Seq { lhs, rhs }` (serde tag "seq") as a first-class
AST node for sequencing effectful expressions. Equivalent in
behaviour to `let _ = lhs in rhs`, but the dedicated node gives the
pretty-printer and diagnostics a cleaner shape and surfaces the
intent ("run for effect, then yield rhs") to future tooling.
Typecheck: lhs must be Unit; rhs's type is the result; effects
accumulate.
Codegen: lower lhs (drop SSA), lower rhs (return).
Capture / deps walkers: recurse into both sides.
Refactored examples/list_map.ail.json's print_list to use seq
instead of `let _ = ...`. Output unchanged (2/4/6).
Hash stability: existing examples without Term::Seq serialise
bit-identical; only list_map.ail.json's hashes changed (deliberate
refactor).
Tests: 51 green (was 50). New unit test
`ailang_check::tests::seq_lhs_must_be_unit` covers the type-error
path; existing list_map e2e covers the happy path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,14 @@ pub enum Term {
|
||||
effects: Vec<String>,
|
||||
body: Box<Term>,
|
||||
},
|
||||
/// 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<Term>,
|
||||
rhs: Box<Term>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -186,12 +186,15 @@ hashes stay bit-identical.
|
||||
"retType": Type,
|
||||
"effects": ["<id>"...],
|
||||
"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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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" }]
|
||||
|
||||
Reference in New Issue
Block a user