diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index 4e72145..f705cd5 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -974,6 +974,12 @@ fn walk_term( // purposes. The wrapper introduces no new symbols. walk_term(value, out, builtins, scope); } + Term::ReuseAs { source, body } => { + // Iter 18d.1: walk both children; the wrapper introduces no + // new bindings of its own. + walk_term(source, out, builtins, scope); + walk_term(body, out, builtins, scope); + } } } diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index b7f05c7..f9fe1cd 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -1331,6 +1331,33 @@ fn clone_demo_is_identity_in_18c1() { assert_eq!(stdout.trim(), "42"); } +/// Iter 18d.1: `Term::ReuseAs` is a schema-floor addition. In 18d.1 the +/// wrapper is identity at codegen — the body is lowered, the source is +/// dropped on the floor — so a program containing `(reuse-as +/// )` produces the same stdout under `--alloc=gc` it would have +/// produced without the wrapper. Iter 18d.2 will replace the codegen +/// passthrough with an in-place rewrite under `--alloc=rc`. +/// +/// Properties guarded: +/// (1) The fixture's canonical JSON contains `"t":"reuse-as"` — the +/// schema for the new variant did not regress. +/// (2) `--alloc=gc` produces `9` (1+1 + 2+1 + 3+1) — the typechecker, +/// parser/printer round-trip, and codegen identity all agree. +#[test] +fn reuse_as_demo_is_identity_in_18d1() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); + let json_path = workspace.join("examples").join("reuse_as_demo.ail.json"); + let json = std::fs::read_to_string(&json_path).expect("read reuse_as_demo.ail.json"); + assert!( + json.contains("\"t\":\"reuse-as\""), + "expected `\"t\":\"reuse-as\"` in canonical JSON; the schema for `(reuse-as ...)` regressed" + ); + + let stdout = build_and_run_with_alloc("reuse_as_demo.ail.json", "gc"); + assert_eq!(stdout.trim(), "9"); +} + /// Iter 18b: extends `alloc_rc_produces_same_stdout_as_gc` to a larger /// fixture (`std_list_demo`) so more allocation sites — folds, maps, /// cross-module ctors — are exercised under `--alloc=rc`. Same diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index e08ca1f..f17967e 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -426,6 +426,18 @@ pub enum CheckError { ctor: String, candidates: Vec, }, + + /// Iter 18d.1: a `Term::ReuseAs { body, .. }` was found whose `body` + /// is not an allocating Term variant (i.e. not `Term::Ctor` and not + /// `Term::Lam`). `(reuse-as ...)` only makes sense when the body + /// produces a fresh allocation that can take over the source's + /// memory slot; otherwise the wrapper is meaningless and is + /// rejected at typecheck. Code: `reuse-as-non-allocating-body`. + /// `ctx`: `{"got": ""}`. The `body_form_a` field carries + /// the form-A spelling of the inner body so [`Self::to_diagnostic`] + /// can attach a `SuggestedRewrite` that drops the wrapper. + #[error("reuse-as body must be an allocating term (Term::Ctor or Term::Lam), got {got}")] + ReuseAsNonAllocatingBody { got: String, body_form_a: String }, } pub(crate) type Result = std::result::Result; @@ -463,6 +475,7 @@ impl CheckError { CheckError::InvalidDefName { .. } => "invalid-def-name", CheckError::TailCallNotInTailPosition => "tail-call-not-in-tail-position", CheckError::AmbiguousCtor { .. } => "ambiguous-ctor", + CheckError::ReuseAsNonAllocatingBody { .. } => "reuse-as-non-allocating-body", } } @@ -500,6 +513,9 @@ impl CheckError { CheckError::AmbiguousCtor { ctor, candidates } => { serde_json::json!({"ctor": ctor, "candidates": candidates}) } + CheckError::ReuseAsNonAllocatingBody { got, .. } => { + serde_json::json!({"got": got}) + } _ => serde_json::Value::Object(serde_json::Map::new()), } } @@ -535,6 +551,16 @@ impl CheckError { if let Some(name) = self.def() { d = d.with_def(name); } + // Iter 18d.1: typecheck-side suggested_rewrites for the + // reuse-as-non-allocating-body diagnostic. The replacement is + // simply the body without the `(reuse-as ...)` wrapper — the + // hint is meaningless here, so drop it. + if let CheckError::ReuseAsNonAllocatingBody { body_form_a, .. } = self.inner() { + d = d.with_suggested_rewrite( + "drop the meaningless reuse-as wrapper around the non-allocating body", + body_form_a.clone(), + ); + } d } } @@ -1145,6 +1171,14 @@ pub fn verify_tail_positions(t: &Term, is_tail: bool) -> Result<()> { // through unchanged — `(clone tail-call)` is a tail call. verify_tail_positions(value, is_tail) } + Term::ReuseAs { source, body } => { + // Iter 18d.1: reuse-as is identity at codegen. The `source` + // is a side-effect-free Var in shipping fixtures, so it is + // not a tail call; the `body` is the value of the whole + // expression and inherits the enclosing tail position. + verify_tail_positions(source, false)?; + verify_tail_positions(body, is_tail) + } } } @@ -1653,6 +1687,46 @@ pub(crate) fn synth( // emission pass (18c.3); typing is pure passthrough. synth(value, env, locals, effects, in_def, subst, counter) } + Term::ReuseAs { source, body } => { + // Iter 18d.1: `(reuse-as SRC NEW-CTOR)` requires `body` to + // be an allocating term — `Term::Ctor` or `Term::Lam`. Any + // other shape is a structural error: the wrapper has + // nothing to rewrite. The `source` term has no body-shape + // constraint here — linearity (which runs only on + // all-explicit-mode fns) enforces "source must be a bare + // Var referring to an in-scope owned binder". Source-type + // and body-type need not match — that's a future + // shape-compatibility check (18d.2 will add a + // `reuse-as-shape-mismatch` diagnostic when codegen has + // the actual size info). + let _ = synth(source, env, locals, effects, in_def, subst, counter)?; + match body.as_ref() { + Term::Ctor { .. } | Term::Lam { .. } => {} + other => { + let tag = match other { + Term::Lit { .. } => "lit", + Term::Var { .. } => "var", + Term::App { .. } => "app", + Term::Let { .. } => "let", + Term::LetRec { .. } => "let-rec", + Term::If { .. } => "if", + Term::Do { .. } => "do", + Term::Match { .. } => "match", + Term::Seq { .. } => "seq", + Term::Clone { .. } => "clone", + Term::ReuseAs { .. } => "reuse-as", + Term::Ctor { .. } | Term::Lam { .. } => unreachable!(), + }; + return Err(CheckError::ReuseAsNonAllocatingBody { + got: tag.to_string(), + body_form_a: ailang_surface::print::term_to_form_a(other), + }); + } + } + // Body's type is the result type of the whole reuse-as + // expression. + synth(body, env, locals, effects, in_def, subst, counter) + } } } @@ -3767,4 +3841,54 @@ mod tests { "(== 1 true) must fail to typecheck; got no diagnostics" ); } + + /// Iter 18d.1: a `(reuse-as xs 42)` where the body is a literal + /// (not a `Term::Ctor` and not `Term::Lam`) must emit + /// `reuse-as-non-allocating-body` with `ctx.got = "lit"`. The + /// suggested rewrite drops the wrapper; the replacement parses + /// via `parse_term`. + #[test] + fn reuse_as_with_non_allocating_body_is_reported() { + // Body: (reuse-as x 42) — `x` is the param, body is a lit. + let body = Term::ReuseAs { + source: Box::new(Term::Var { name: "x".into() }), + body: Box::new(Term::Lit { lit: Literal::Int { value: 42 } }), + }; + let m = Module { + schema: SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![fn_def( + "f", + Type::Fn { + params: vec![Type::int()], + ret: Box::new(Type::int()), + effects: vec![], + param_modes: vec![], + ret_mode: ParamMode::Implicit, + }, + vec!["x"], + body, + )], + }; + let diags = check_module(&m); + let bad: Vec<&Diagnostic> = diags + .iter() + .filter(|d| d.code == "reuse-as-non-allocating-body") + .collect(); + assert_eq!( + bad.len(), + 1, + "want exactly one reuse-as-non-allocating-body; got: {diags:#?}" + ); + let d = bad[0]; + assert_eq!(d.severity, Severity::Error); + assert_eq!(d.def.as_deref(), Some("f")); + assert_eq!(d.ctx.get("got").and_then(|v| v.as_str()), Some("lit")); + assert_eq!(d.suggested_rewrites.len(), 1); + // Replacement is the body without the wrapper — must parse. + let rep = &d.suggested_rewrites[0].replacement; + ailang_surface::parse_term(rep) + .unwrap_or_else(|e| panic!("suggested rewrite must parse: {rep} ({e})")); + } } diff --git a/crates/ailang-check/src/lift.rs b/crates/ailang-check/src/lift.rs index c8e3dbe..60be9c8 100644 --- a/crates/ailang-check/src/lift.rs +++ b/crates/ailang-check/src/lift.rs @@ -361,6 +361,11 @@ impl<'a> Lifter<'a> { // Iter 18c.1: structural recursion through the wrapper. value: Box::new(self.lift_in_term(value, locals, in_def)?), }), + Term::ReuseAs { source, body } => Ok(Term::ReuseAs { + // Iter 18d.1: structural recursion through both children. + source: Box::new(self.lift_in_term(source, locals, in_def)?), + body: Box::new(self.lift_in_term(body, locals, in_def)?), + }), Term::LetRec { name, ty, params, body, in_term } => { // Iter 16b.3: post-order traversal — lift any inner // LetRecs first. Within the body's scope, `name` and @@ -697,6 +702,8 @@ fn contains_any_letrec(m: &Module) -> bool { Term::LetRec { .. } => true, // Iter 18c.1: identity passthrough for the letrec-detection scan. Term::Clone { value } => term_has_letrec(value), + // Iter 18d.1: structural recursion through both children. + Term::ReuseAs { source, body } => term_has_letrec(source) || term_has_letrec(body), } } for def in &m.defs { diff --git a/crates/ailang-check/src/linearity.rs b/crates/ailang-check/src/linearity.rs index 55ceb10..cb1baff 100644 --- a/crates/ailang-check/src/linearity.rs +++ b/crates/ailang-check/src/linearity.rs @@ -349,6 +349,46 @@ impl<'a> Checker<'a> { // only borrowed. So `(clone xs)` does NOT consume `xs`. self.walk(value, Position::Borrow); } + Term::ReuseAs { source, body } => { + // Iter 18d.1: linearity for `(reuse-as SRC BODY)`: + // - `SRC` must be a bare `Var { name }` of an + // in-scope binder (else `reuse-as-source-not-bare-var`). + // - The binder must currently have `consumed == false` + // (else `use-after-consume` at this site). + // - Visiting reuse-as marks the binder consumed (the + // reuse semantics is "this is where the source is + // freed/overwritten"). + // - `BODY` walks normally — its sub-uses are subject + // to the usual position rules; e.g. a Consume of + // the same binder inside `BODY` will fire + // use-after-consume because reuse-as already ate it. + match source.as_ref() { + Term::Var { name } if self.binders.contains_key(name) => { + let already_consumed = self + .binders + .get(name) + .map(|s| s.consumed) + .unwrap_or(false); + if already_consumed { + self.diags + .push(make_use_after_consume_at_reuse_as(self.def_name, name, body)); + } else if let Some(s) = self.binders.get_mut(name) { + // Mark the source consumed at the reuse-as site. + s.consumed = true; + } + } + other => { + self.diags.push(make_reuse_as_source_not_bare_var( + self.def_name, + other, + body, + )); + } + } + // Walk the body normally; its sub-uses go through the + // standard position rules. + self.walk(body, pos); + } } } @@ -513,6 +553,63 @@ fn make_use_after_consume(def: &str, binder: &str) -> Diagnostic { ) } +/// Iter 18d.1: build a `reuse-as-source-not-bare-var` diagnostic. +/// Fires when a `Term::ReuseAs.source` is anything other than a bare +/// `Term::Var { name }` referring to an in-scope binder. The +/// suggested rewrite drops the meaningless wrapper and keeps the +/// `body` alone. +fn make_reuse_as_source_not_bare_var(def: &str, source: &Term, body: &Term) -> Diagnostic { + let got = match source { + Term::Lit { .. } => "lit", + Term::Var { .. } => "var", + Term::App { .. } => "app", + Term::Let { .. } => "let", + Term::LetRec { .. } => "let-rec", + Term::If { .. } => "if", + Term::Do { .. } => "do", + Term::Ctor { .. } => "ctor", + Term::Match { .. } => "match", + Term::Lam { .. } => "lam", + Term::Seq { .. } => "seq", + Term::Clone { .. } => "clone", + Term::ReuseAs { .. } => "reuse-as", + }; + let replacement = term_to_form_a(body); + Diagnostic::error( + "reuse-as-source-not-bare-var", + format!( + "reuse-as source must be a bare variable referring to an in-scope binder, got {got}" + ), + ) + .with_def(def) + .with_ctx(serde_json::json!({"got": got})) + .with_suggested_rewrite( + "drop the malformed reuse-as wrapper; keep the body alone", + replacement, + ) +} + +/// Iter 18d.1: `use-after-consume` raised at a `(reuse-as )` +/// site whose `` binder has already been consumed elsewhere. Same +/// code as the regular use-after-consume; the suggested rewrite is to +/// drop the (now unsatisfiable) reuse hint and keep the body — the +/// allocation will happen via the normal allocator. +fn make_use_after_consume_at_reuse_as(def: &str, binder: &str, body: &Term) -> Diagnostic { + let replacement = term_to_form_a(body); + Diagnostic::error( + "use-after-consume", + format!("`{binder}` is used after it was already consumed"), + ) + .with_def(def) + .with_ctx(serde_json::json!({"binder": binder})) + .with_suggested_rewrite( + format!( + "drop the reuse-as hint on `{binder}`; the binder is already consumed so reuse cannot fire" + ), + replacement, + ) +} + /// Build a `consume-while-borrowed` diagnostic for `binder` in `def`. /// Suggested rewrite: clone here so the original retains the borrow. fn make_consume_while_borrowed(def: &str, binder: &str) -> Diagnostic { @@ -606,4 +703,73 @@ mod tests { }; assert!(check_module(&m).is_empty()); } + + /// Iter 18d.1: a `(reuse-as 42 (Cons ...))` whose source is a literal + /// (not a bare `Var`) is flagged with `reuse-as-source-not-bare-var`. + /// The suggested rewrite drops the wrapper and keeps the body. + #[test] + fn reuse_as_with_non_var_source_is_reported() { + // Body shape: (reuse-as 42 (term-ctor Wrap Wrap p0)) + // The source `42` is a literal — invalid for reuse-as. + let body = Term::ReuseAs { + source: Box::new(Term::Lit { lit: Literal::Int { value: 42 } }), + body: Box::new(Term::Ctor { + type_name: "Wrap".into(), + ctor: "Wrap".into(), + args: vec![Term::Var { name: "p0".into() }], + }), + }; + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![fn_with_modes("f", vec![ParamMode::Own], body)], + }; + let diags = check_module(&m); + assert_eq!(diags.len(), 1, "expected exactly one diagnostic, got {diags:?}"); + let d = &diags[0]; + assert_eq!(d.code, "reuse-as-source-not-bare-var"); + assert_eq!(d.def.as_deref(), Some("f")); + assert_eq!(d.ctx, serde_json::json!({"got": "lit"})); + assert!( + !d.suggested_rewrites.is_empty(), + "diagnostic should carry a suggested rewrite" + ); + // The replacement must round-trip through parse_term. + let rep = &d.suggested_rewrites[0].replacement; + ailang_surface::parse_term(rep) + .unwrap_or_else(|e| panic!("suggested rewrite must parse: {rep} ({e})")); + } + + /// Iter 18d.1: a `(reuse-as p0 ...)` where `p0` was already + /// consumed by an earlier sibling fires `use-after-consume` at the + /// reuse-as site (not `reuse-as-source-not-bare-var`). + #[test] + fn reuse_as_after_consume_is_use_after_consume() { + // Body shape: (seq p0 (reuse-as p0 (term-ctor Wrap Wrap p0))) + // The first `p0` (in `seq.lhs`) consumes the binder; the + // reuse-as in the rhs then sees an already-consumed binder. + let body = Term::Seq { + lhs: Box::new(Term::Var { name: "p0".into() }), + rhs: Box::new(Term::ReuseAs { + source: Box::new(Term::Var { name: "p0".into() }), + body: Box::new(Term::Ctor { + type_name: "Wrap".into(), + ctor: "Wrap".into(), + args: vec![Term::Lit { lit: Literal::Int { value: 0 } }], + }), + }), + }; + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![fn_with_modes("f", vec![ParamMode::Own], body)], + }; + let diags = check_module(&m); + assert_eq!(diags.len(), 1, "expected exactly one diagnostic, got {diags:?}"); + let d = &diags[0]; + assert_eq!(d.code, "use-after-consume"); + assert_eq!(d.ctx, serde_json::json!({"binder": "p0"})); + } } diff --git a/crates/ailang-check/src/uniqueness.rs b/crates/ailang-check/src/uniqueness.rs index b6fedb4..003d09a 100644 --- a/crates/ailang-check/src/uniqueness.rs +++ b/crates/ailang-check/src/uniqueness.rs @@ -304,6 +304,16 @@ impl<'a> Walker<'a> { Term::Clone { value } => { self.walk(value, Position::Borrow); } + Term::ReuseAs { source, body } => { + // Iter 18d.1: `(reuse-as SRC BODY)` is a Consume of + // `SRC` (the binder's slot is being taken over) and + // a normal walk of `BODY`. So a bare-Var SRC bumps + // its `consume_count` by one. Linearity is the + // user-visible enforcement; this pass only feeds the + // RC codegen its bookkeeping. + self.walk(source, Position::Consume); + self.walk(body, pos); + } } } diff --git a/crates/ailang-check/tests/workspace.rs b/crates/ailang-check/tests/workspace.rs index b90c6bd..62e2d4f 100644 --- a/crates/ailang-check/tests/workspace.rs +++ b/crates/ailang-check/tests/workspace.rs @@ -406,6 +406,126 @@ fn consume_while_borrowed_in_sibling_arg_is_reported() { assert_suggested_rewrites_well_formed(d); } +/// Iter 18d.1: happy-path `(reuse-as xs (term-ctor List Cons ...))` +/// inside an all-explicit-mode `map_inc`-style fn must produce NO +/// diagnostics — neither `reuse-as-non-allocating-body` (body is a +/// Ctor) nor `reuse-as-source-not-bare-var` (source is `xs`) nor +/// `use-after-consume` (xs is matched then consumed once via +/// reuse-as in the Cons arm; the merge across arms is consistent). +/// +/// Body: +/// `(match xs +/// (case Nil (term-ctor List Nil)) +/// (case (Cons h t) +/// (reuse-as xs (term-ctor List Cons (+ h 1) (app map_inc t)))))` +#[test] +fn reuse_as_happy_path_in_map_inc_is_linearity_clean() { + use ailang_core::ast::*; + use std::collections::BTreeMap; + + let list_adt = Def::Type(TypeDef { + name: "List".into(), + vars: vec![], + ctors: vec![ + Ctor { name: "Nil".into(), fields: vec![] }, + Ctor { + name: "Cons".into(), + fields: vec![ + Type::Con { name: "Int".into(), args: vec![] }, + Type::Con { name: "List".into(), args: vec![] }, + ], + }, + ], + doc: None, + }); + + let map_inc_ty = Type::Fn { + params: vec![Type::Con { name: "List".into(), args: vec![] }], + param_modes: vec![ParamMode::Own], + ret: Box::new(Type::Con { name: "List".into(), args: vec![] }), + ret_mode: ParamMode::Own, + effects: vec![], + }; + + // Cons arm body: + // (reuse-as xs + // (term-ctor List Cons (app + h 1) (app map_inc t))) + let cons_arm_body = Term::ReuseAs { + source: Box::new(Term::Var { name: "xs".into() }), + body: Box::new(Term::Ctor { + type_name: "List".into(), + ctor: "Cons".into(), + args: vec![ + Term::App { + callee: Box::new(Term::Var { name: "+".into() }), + args: vec![ + Term::Var { name: "h".into() }, + Term::Lit { lit: Literal::Int { value: 1 } }, + ], + tail: false, + }, + Term::App { + callee: Box::new(Term::Var { name: "map_inc".into() }), + args: vec![Term::Var { name: "t".into() }], + tail: false, + }, + ], + }), + }; + + let map_inc_body = Term::Match { + scrutinee: Box::new(Term::Var { name: "xs".into() }), + arms: vec![ + Arm { + pat: Pattern::Ctor { ctor: "Nil".into(), fields: vec![] }, + body: Term::Ctor { + type_name: "List".into(), + ctor: "Nil".into(), + args: vec![], + }, + }, + Arm { + pat: Pattern::Ctor { + ctor: "Cons".into(), + fields: vec![ + Pattern::Var { name: "h".into() }, + Pattern::Var { name: "t".into() }, + ], + }, + body: cons_arm_body, + }, + ], + }; + + let map_inc = Def::Fn(FnDef { + name: "map_inc".into(), + ty: map_inc_ty, + params: vec!["xs".into()], + body: map_inc_body, + doc: None, + }); + + let m = Module { + schema: ailang_core::SCHEMA.into(), + name: "reuse_as_happy".into(), + imports: vec![], + defs: vec![list_adt, map_inc], + }; + let mut modules = BTreeMap::new(); + modules.insert(m.name.clone(), m.clone()); + let ws = ailang_core::Workspace { + entry: m.name.clone(), + modules, + root_dir: std::path::PathBuf::from("."), + }; + let diags = check_workspace(&ws); + assert!( + diags.is_empty(), + "happy-path reuse-as must check clean; got: {:#?}", + diags + ); +} + /// Iter 18c.2: positive control. The ON-DISK `borrow_own_demo` fixture /// (the only currently-shipping all-explicit-mode program) must remain /// linearity-clean. If this regresses, the check has become incorrect: diff --git a/crates/ailang-codegen/src/escape.rs b/crates/ailang-codegen/src/escape.rs index 278c060..cfdd1bd 100644 --- a/crates/ailang-codegen/src/escape.rs +++ b/crates/ailang-codegen/src/escape.rs @@ -184,6 +184,12 @@ fn walk(t: &Term, out: &mut NonEscapeSet) { // sub-terms looking for Let-allocation candidates. walk(value, out); } + Term::ReuseAs { source, body } => { + // Iter 18d.1: identity at IR level (codegen lowers `body`, + // ignores `source`). Walk both children for completeness. + walk(source, out); + walk(body, out); + } Term::Lit { .. } | Term::Var { .. } => {} } } @@ -349,6 +355,18 @@ fn escapes(t: &Term, tainted: &BTreeSet, in_tail: bool) -> bool { // through with the same `in_tail` context. escapes(value, tainted, in_tail) } + Term::ReuseAs { source, body } => { + // Iter 18d.1: identity at IR level. The whole expression's + // value is `body`, so its escape verdict is the body's + // (threaded through `in_tail`). The `source` is evaluated + // but its value is discarded; it cannot escape via the + // tail position, but a tainted name reachable inside it + // could still escape — walk it in non-tail mode. + if escapes(source, tainted, false) { + return true; + } + escapes(body, tainted, in_tail) + } } } @@ -448,6 +466,11 @@ fn collect_free_vars(t: &Term, bound: &mut BTreeSet, out: &mut BTreeSet< // Iter 18c.1: clone is identity for free-var collection. collect_free_vars(value, bound, out); } + Term::ReuseAs { source, body } => { + // Iter 18d.1: free vars are the union of source and body. + collect_free_vars(source, bound, out); + collect_free_vars(body, bound, out); + } } } diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index c7257ed..ea181c4 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -1512,6 +1512,18 @@ impl<'a> Emitter<'a> { } Ok((val_ssa, val_ty)) } + Term::ReuseAs { source: _, body } => { + // Iter 18d.1: identity. Iter 18d.2 will lower this as + // in-place rewrite under --alloc=rc. For now we simply + // lower `body` and return its `(ssa, ty)`. The `source` + // is dropped on the floor — in shipping fixtures it is + // always a `Term::Var` (no IR side effects), so the + // identity lowering is observably equivalent to lowering + // `body` alone. The linearity check (18c.2) and + // typecheck (18d.1) reject ill-formed shapes before we + // get here. + self.lower_term(body) + } } } @@ -2774,6 +2786,11 @@ impl<'a> Emitter<'a> { // free vars of `(clone X)` are exactly the free vars of `X`. Self::collect_captures(value, bound, captures, captures_set, builtins, top_level); } + Term::ReuseAs { source, body } => { + // Iter 18d.1: free vars are the union of source and body. + Self::collect_captures(source, bound, captures, captures_set, builtins, top_level); + Self::collect_captures(body, bound, captures, captures_set, builtins, top_level); + } } } @@ -3284,6 +3301,11 @@ impl<'a> Emitter<'a> { // Iter 18c.1: clone is identity — same type as inner. self.synth_with_extras(value, extras) } + Term::ReuseAs { body, .. } => { + // Iter 18d.1: identity — the result type is the body's + // type. The source is dropped at codegen. + self.synth_with_extras(body, extras) + } } } } @@ -3677,6 +3699,11 @@ fn apply_subst_to_term(t: &Term, subst: &BTreeMap) -> Term { // Iter 18c.1: structural recursion through the wrapper. value: Box::new(apply_subst_to_term(value, subst)), }, + Term::ReuseAs { source, body } => Term::ReuseAs { + // Iter 18d.1: structural recursion through both children. + source: Box::new(apply_subst_to_term(source, subst)), + body: Box::new(apply_subst_to_term(body, subst)), + }, } } diff --git a/crates/ailang-core/src/ast.rs b/crates/ailang-core/src/ast.rs index 36feacf..c109f47 100644 --- a/crates/ailang-core/src/ast.rs +++ b/crates/ailang-core/src/ast.rs @@ -300,6 +300,23 @@ pub enum Term { Clone { value: Box, }, + /// Iter 18d.1: explicit reuse-as hint. Wraps an allocating `body` + /// (typically `Term::Ctor`, also `Term::Lam`) and names a `source` + /// term whose memory slot the body's allocation should reuse. + /// Conventionally `source` is a `Term::Var { name }` — the + /// linearity check rejects anything else with + /// `reuse-as-source-not-bare-var`. The body must be allocating; + /// non-allocating bodies are rejected at typecheck with + /// `reuse-as-non-allocating-body`. Lowers as identity in 18d.1 + /// (returns `body`'s `(ssa, ty)`, ignores `source`); 18d.2 will + /// lower this as in-place rewrite under `--alloc=rc`. The variant + /// is additive — pre-18d fixtures keep their canonical-JSON hash + /// because none of them use the new tag. + #[serde(rename = "reuse-as")] + ReuseAs { + source: Box, + body: Box, + }, } /// One arm of a [`Term::Match`]. diff --git a/crates/ailang-core/src/desugar.rs b/crates/ailang-core/src/desugar.rs index db7c00b..f3ac719 100644 --- a/crates/ailang-core/src/desugar.rs +++ b/crates/ailang-core/src/desugar.rs @@ -324,6 +324,11 @@ fn collect_used_in_term(t: &Term, used: &mut BTreeSet) { // Iter 18c.1: identity for the used-name walk. collect_used_in_term(value, used); } + Term::ReuseAs { source, body } => { + // Iter 18d.1: structural recursion through both children. + collect_used_in_term(source, used); + collect_used_in_term(body, used); + } } } @@ -511,6 +516,12 @@ impl Desugarer { // wrapper. Same pattern as `Term::Let`'s value branch. value: Box::new(self.desugar_term(value, scope)), }, + Term::ReuseAs { source, body } => Term::ReuseAs { + // Iter 18d.1: pure structural recursion through both + // children. Same pattern as `Term::Clone`. + source: Box::new(self.desugar_term(source, scope)), + body: Box::new(self.desugar_term(body, scope)), + }, Term::LetRec { name, ty, params, body, in_term } => { // Iter 16b.1: lift to a synthetic top-level fn (no-capture). // Iter 16b.2: extend the lift to the path-1 safe subset — @@ -1122,6 +1133,11 @@ pub fn free_vars_in_term(t: &Term, bound: &BTreeSet, out: &mut BTreeSet< // Iter 18c.1: `(clone X)` has the same free vars as `X`. free_vars_in_term(value, bound, out); } + Term::ReuseAs { source, body } => { + // Iter 18d.1: free vars are the union of source and body. + free_vars_in_term(source, bound, out); + free_vars_in_term(body, bound, out); + } } } @@ -1261,6 +1277,11 @@ pub fn subst_var(t: &Term, from: &str, to: &str) -> Term { // Iter 18c.1: structural recursion through the wrapper. value: Box::new(subst_var(value, from, to)), }, + Term::ReuseAs { source, body } => Term::ReuseAs { + // Iter 18d.1: structural recursion through both children. + source: Box::new(subst_var(source, from, to)), + body: Box::new(subst_var(body, from, to)), + }, } } @@ -1377,6 +1398,11 @@ pub fn subst_call_with_extras(t: &Term, name: &str, lifted: &str, extras: &[Stri // Iter 18c.1: structural recursion through the wrapper. value: Box::new(subst_call_with_extras(value, name, lifted, extras)), }, + Term::ReuseAs { source, body } => Term::ReuseAs { + // Iter 18d.1: structural recursion through both children. + source: Box::new(subst_call_with_extras(source, name, lifted, extras)), + body: Box::new(subst_call_with_extras(body, name, lifted, extras)), + }, } } @@ -1427,6 +1453,10 @@ pub fn find_non_callee_use(t: &Term, name: &str) -> Option { .or_else(|| find_non_callee_use(in_term, name)), // Iter 18c.1: clone is identity for the non-callee scan. Term::Clone { value } => find_non_callee_use(value, name), + // Iter 18d.1: scan both source and body. + Term::ReuseAs { source, body } => { + find_non_callee_use(source, name).or_else(|| find_non_callee_use(body, name)) + } } } @@ -1468,6 +1498,7 @@ mod tests { any_nested_ctor(body) || any_nested_ctor(in_term) } Term::Clone { value } => any_nested_ctor(value), + Term::ReuseAs { source, body } => any_nested_ctor(source) || any_nested_ctor(body), } } @@ -1492,6 +1523,7 @@ mod tests { Term::Seq { lhs, rhs } => any_let_rec(lhs) || any_let_rec(rhs), Term::LetRec { .. } => true, Term::Clone { value } => any_let_rec(value), + Term::ReuseAs { source, body } => any_let_rec(source) || any_let_rec(body), } } @@ -2592,6 +2624,7 @@ mod tests { any_lit_pattern(body) || any_lit_pattern(in_term) } Term::Clone { value } => any_lit_pattern(value), + Term::ReuseAs { source, body } => any_lit_pattern(source) || any_lit_pattern(body), } } diff --git a/crates/ailang-surface/src/parse.rs b/crates/ailang-surface/src/parse.rs index 4a5543c..f3f53b7 100644 --- a/crates/ailang-surface/src/parse.rs +++ b/crates/ailang-surface/src/parse.rs @@ -63,6 +63,7 @@ //! body-attr //! "(" "in" term ")" ")" //! clone-term ::= "(" "clone" term ")" ; Iter 18c.1 +//! reuse-as-term ::= "(" "reuse-as" term term ")" ; Iter 18d.1 //! //! pattern ::= pat-var | pat-ctor | pat-lit | pat-wild //! pat-var ::= ident @@ -777,6 +778,7 @@ impl<'a> Parser<'a> { "let" => self.parse_let(), "let-rec" => self.parse_let_rec(), "clone" => self.parse_clone(), + "reuse-as" => self.parse_reuse_as(), other => { let pos = self.peek().map(|t| t.span.start).unwrap_or(0); Err(ParseError::Production { @@ -784,7 +786,7 @@ impl<'a> Parser<'a> { message: format!( "unknown term head `{other}`; expected one of \ `app`, `tail-app`, `lam`, `let`, `let-rec`, `if`, `match`, `do`, \ - `tail-do`, `seq`, `term-ctor`, `clone`, `lit-unit`" + `tail-do`, `seq`, `term-ctor`, `clone`, `reuse-as`, `lit-unit`" ), pos, }) @@ -1089,6 +1091,49 @@ impl<'a> Parser<'a> { }) } + /// Iter 18d.1: `(reuse-as SOURCE BODY)` — explicit reuse-as wrapper. + /// In 18d.1 the wrapper is identity for codegen (lowers `body` and + /// drops `source`); 18d.2 will lower this as in-place rewrite under + /// `--alloc=rc`. The parser accepts any term in either slot — the + /// "source must be a bare Var" rule and the "body must be allocating" + /// rule are enforced at typecheck/linearity time, not the parser. + fn parse_reuse_as(&mut self) -> Result { + self.expect_lparen("reuse-as-term")?; + self.expect_keyword("reuse-as")?; + // Exactly two inner terms, then `)`. + if matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "reuse-as-term", + message: "reuse-as expects exactly two term arguments".into(), + pos, + }); + } + let source = self.parse_term()?; + if matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "reuse-as-term", + message: "reuse-as expects exactly two term arguments".into(), + pos, + }); + } + let body = self.parse_term()?; + if !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) { + let pos = self.peek().map(|t| t.span.start).unwrap_or(0); + return Err(ParseError::Production { + production: "reuse-as-term", + message: "reuse-as expects exactly two term arguments".into(), + pos, + }); + } + self.expect_rparen("reuse-as-term")?; + Ok(Term::ReuseAs { + source: Box::new(source), + body: Box::new(body), + }) + } + // ---- patterns ------------------------------------------------------- fn parse_pattern(&mut self) -> Result { @@ -1335,6 +1380,109 @@ mod tests { ); } + /// Iter 18d.1: `(reuse-as xs (term-ctor List Cons (...)))` parses to + /// `Term::ReuseAs { source: Var "xs", body: Ctor "Cons" ... }`. + #[test] + fn parses_reuse_as_wraps_source_and_body() { + let m = parse( + r#" + (module m + (data List (vars a) + (ctor Nil) + (ctor Cons a (con List a))) + (fn f + (type (fn-type (params (own (con List (con Int)))) (ret (own (con List (con Int)))))) + (params xs) + (body (reuse-as xs (term-ctor List Cons 1 xs))))) + "#, + ) + .unwrap(); + let body = match &m.defs[1] { + Def::Fn(fd) => &fd.body, + _ => panic!("expected fn"), + }; + match body { + Term::ReuseAs { source, body } => { + assert!(matches!(source.as_ref(), Term::Var { name } if name == "xs")); + match body.as_ref() { + Term::Ctor { type_name, ctor, args } => { + assert_eq!(type_name, "List"); + assert_eq!(ctor, "Cons"); + assert_eq!(args.len(), 2); + } + other => panic!("expected Ctor inside ReuseAs body, got {other:?}"), + } + } + other => panic!("expected ReuseAs, got {other:?}"), + } + } + + /// Iter 18d.1: `(reuse-as)` with no args is rejected. + #[test] + fn rejects_reuse_as_with_no_arguments() { + let err = parse( + r#" + (module m + (fn f + (type (fn-type (params (con Int)) (ret (con Int)))) + (params x) + (body (reuse-as)))) + "#, + ) + .err() + .expect("parse should fail"); + let msg = format!("{err}"); + assert!( + msg.contains("reuse-as expects exactly two term arguments"), + "diagnostic should explain reuse-as's arity, got: {msg}" + ); + } + + /// Iter 18d.1: `(reuse-as x)` with a single arg is rejected. + #[test] + fn rejects_reuse_as_with_one_argument() { + let err = parse( + r#" + (module m + (fn f + (type (fn-type (params (con Int)) (ret (con Int)))) + (params x) + (body (reuse-as x)))) + "#, + ) + .err() + .expect("parse should fail"); + let msg = format!("{err}"); + assert!( + msg.contains("reuse-as expects exactly two term arguments"), + "diagnostic should explain reuse-as's arity, got: {msg}" + ); + } + + /// Iter 18d.1: round-trip via `parse_term` / `term_to_form_a` — + /// `(reuse-as xs (term-ctor List Cons 1 xs))` survives a print/parse + /// cycle as the same `Term::ReuseAs`. + #[test] + fn parse_term_round_trip_reuse_as() { + use crate::print::term_to_form_a; + let original = Term::ReuseAs { + source: Box::new(Term::Var { name: "xs".into() }), + body: Box::new(Term::Ctor { + type_name: "List".into(), + ctor: "Cons".into(), + args: vec![ + Term::Lit { lit: Literal::Int { value: 1 } }, + Term::Var { name: "xs".into() }, + ], + }), + }; + let printed = term_to_form_a(&original); + let parsed = parse_term(&printed).expect("parse_term should succeed"); + // Compare by canonical bytes (Term has no PartialEq) — easiest + // structural equality is via the printer. + assert_eq!(term_to_form_a(&parsed), printed); + } + /// Iter 16b.1: minimal `(let-rec ...)` round-trips through the /// parser into a `Term::LetRec` whose `name`, `params`, `body` and /// `in_term` line up with the source. diff --git a/crates/ailang-surface/src/print.rs b/crates/ailang-surface/src/print.rs index 55dc97e..1e4fa1b 100644 --- a/crates/ailang-surface/src/print.rs +++ b/crates/ailang-surface/src/print.rs @@ -382,6 +382,17 @@ fn write_term(out: &mut String, t: &Term, level: usize) { write_term(out, value, level); out.push(')'); } + Term::ReuseAs { source, body } => { + // Iter 18d.1: print as `(reuse-as )`. The + // wrapper is identity at codegen in 18d.1 (the `body` is + // lowered, the `source` is dropped); 18d.2 will lower this + // as in-place rewrite under `--alloc=rc`. + out.push_str("(reuse-as "); + write_term(out, source, level); + out.push(' '); + write_term(out, body, level); + out.push(')'); + } } } diff --git a/examples/reuse_as_demo.ail.json b/examples/reuse_as_demo.ail.json new file mode 100644 index 0000000..71194fd --- /dev/null +++ b/examples/reuse_as_demo.ail.json @@ -0,0 +1 @@ +{"defs":[{"ctors":[{"fields":[],"name":"Nil"},{"fields":[{"k":"con","name":"Int"},{"k":"con","name":"List"}],"name":"Cons"}],"doc":"Monomorphic singly-linked Int list — boxed, recursive.","kind":"type","name":"List"},{"body":{"arms":[{"body":{"args":[],"ctor":"Nil","t":"ctor","type":"List"},"pat":{"ctor":"Nil","fields":[],"p":"ctor"}},{"body":{"body":{"args":[{"args":[{"name":"h","t":"var"},{"lit":{"kind":"int","value":1},"t":"lit"}],"fn":{"name":"+","t":"var"},"t":"app"},{"args":[{"name":"t","t":"var"}],"fn":{"name":"map_inc","t":"var"},"t":"app"}],"ctor":"Cons","t":"ctor","type":"List"},"source":{"name":"xs","t":"var"},"t":"reuse-as"},"pat":{"ctor":"Cons","fields":[{"name":"h","p":"var"},{"name":"t","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"xs","t":"var"},"t":"match"},"doc":"Increment each Int in xs. The Cons arm uses (reuse-as xs ...) — author asserts that the freshly-allocated Cons cell should reuse xs's slot. In 18d.1 codegen treats this as identity.","kind":"fn","name":"map_inc","params":["xs"],"type":{"effects":[],"k":"fn","param_modes":["own"],"params":[{"k":"con","name":"List"}],"ret":{"k":"con","name":"List"},"ret_mode":"own"}},{"body":{"arms":[{"body":{"lit":{"kind":"int","value":0},"t":"lit"},"pat":{"ctor":"Nil","fields":[],"p":"ctor"}},{"body":{"args":[{"name":"h","t":"var"},{"args":[{"name":"t","t":"var"}],"fn":{"name":"sum_list","t":"var"},"t":"app"}],"fn":{"name":"+","t":"var"},"t":"app"},"pat":{"ctor":"Cons","fields":[{"name":"h","p":"var"},{"name":"t","p":"var"}],"p":"ctor"}}],"scrutinee":{"name":"xs","t":"var"},"t":"match"},"doc":"Consume xs, sum its elements.","kind":"fn","name":"sum_list","params":["xs"],"type":{"effects":[],"k":"fn","param_modes":["own"],"params":[{"k":"con","name":"List"}],"ret":{"k":"con","name":"Int"}}},{"body":{"body":{"args":[{"args":[{"args":[{"name":"xs","t":"var"}],"fn":{"name":"map_inc","t":"var"},"t":"app"}],"fn":{"name":"sum_list","t":"var"},"t":"app"}],"op":"io/print_int","t":"do"},"name":"xs","t":"let","value":{"args":[{"lit":{"kind":"int","value":1},"t":"lit"},{"args":[{"lit":{"kind":"int","value":2},"t":"lit"},{"args":[{"lit":{"kind":"int","value":3},"t":"lit"},{"args":[],"ctor":"Nil","t":"ctor","type":"List"}],"ctor":"Cons","t":"ctor","type":"List"}],"ctor":"Cons","t":"ctor","type":"List"}],"ctor":"Cons","t":"ctor","type":"List"}},"doc":"Build [1,2,3]; map_inc → [2,3,4]; sum_list → 9. Print 9.","kind":"fn","name":"main","params":[],"type":{"effects":["IO"],"k":"fn","params":[],"ret":{"k":"con","name":"Unit"}}}],"imports":[],"name":"reuse_as_demo","schema":"ailang/v0"} \ No newline at end of file diff --git a/examples/reuse_as_demo.ailx b/examples/reuse_as_demo.ailx new file mode 100644 index 0000000..09f32a3 --- /dev/null +++ b/examples/reuse_as_demo.ailx @@ -0,0 +1,64 @@ +; Iter 18d.1 — `(reuse-as xs (term-ctor List Cons ...))` schema floor +; demo. The `(reuse-as ...)` wrapper is identity at codegen in 18d.1 +; (the `body` is lowered, the `source` is dropped); 18d.2 will lower +; this as in-place rewrite under `--alloc=rc`. +; +; The fixture exercises: +; - parser + printer round-trip for `Term::ReuseAs` +; - typecheck of an all-explicit-mode fn whose body returns a +; `(reuse-as )` form (body is allocating: Term::Ctor) +; - linearity check: `xs` is consumed exactly once via reuse-as in +; the Cons arm; the Nil arm doesn't consume `xs`. Merge across +; arms is consistent. +; - codegen identity under --alloc=gc: program prints `9` +; (1+1 + 2+1 + 3+1 = 9), the same value the non-reuse-as +; `map_inc` would print. +; +; Expected stdout under --alloc=gc: +; 9 + +(module reuse_as_demo + + (data List + (doc "Monomorphic singly-linked Int list — boxed, recursive.") + (ctor Nil) + (ctor Cons (con Int) (con List))) + + (fn map_inc + (doc "Increment each Int in xs. The Cons arm uses (reuse-as xs ...) — author asserts that the freshly-allocated Cons cell should reuse xs's slot. In 18d.1 codegen treats this as identity.") + (type + (fn-type + (params (own (con List))) + (ret (own (con List))))) + (params xs) + (body + (match xs + (case (pat-ctor Nil) (term-ctor List Nil)) + (case (pat-ctor Cons h t) + (reuse-as xs + (term-ctor List Cons (app + h 1) (app map_inc t))))))) + + (fn sum_list + (doc "Consume xs, sum its elements.") + (type + (fn-type + (params (own (con List))) + (ret (con Int)))) + (params xs) + (body + (match xs + (case (pat-ctor Nil) 0) + (case (pat-ctor Cons h t) + (app + h (app sum_list t)))))) + + (fn main + (doc "Build [1,2,3]; map_inc → [2,3,4]; sum_list → 9. Print 9.") + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body + (let xs + (term-ctor List Cons 1 + (term-ctor List Cons 2 + (term-ctor List Cons 3 + (term-ctor List Nil)))) + (do io/print_int (app sum_list (app map_inc xs)))))))