Iter 18d.1: Term::ReuseAs schema + linearity check

Schema floor for explicit reuse hints. Adds Term::ReuseAs
{ source: Box<Term>, body: Box<Term> } with serde tag "reuse-as",
form-A (reuse-as <source> <body>). Schema choice (wrapper, not
modifier-on-Ctor) and rationale recorded in DESIGN.md.

Codegen is identity under all --alloc strategies — lower body,
drop source on the floor. Iter 18d.2 will lower this as the
in-place rewrite under --alloc=rc.

User-facing diagnostics:
- typecheck reuse-as-non-allocating-body: body must be a
  Term::Ctor or Term::Lam, the two AST shapes that allocate.
  Other shapes (literal, var, app, ...) emit this with a
  suggested_rewrite that drops the wrapper.
- linearity reuse-as-source-not-bare-var: source must be a
  Term::Var referring to an in-scope binder. Anything else
  (literal, nested expression) is rejected here. Suggested
  rewrite drops the wrapper.
- linearity use-after-consume at a reuse-as site: source must
  not have been consumed earlier in the body. Suggested rewrite
  drops the wrapper.
- Visiting reuse-as marks source consumed for the rest of the
  body, so a subsequent use of the same binder flags
  use-after-consume against it.

Uniqueness inference treats source as Consume — the consume
shows up in the side table for the codegen consumer.

Tests:
- 4 surface parse-tests (round-trip, no-args, one-arg, full
  parse_term/term_to_form_a round-trip).
- 1 typecheck unit test (non-allocating body).
- 2 linearity unit tests (non-var source, after-consume).
- 1 integration test (happy-path map_inc in all-explicit mode
  is linearity-clean).
- 1 E2E test running the canonical map_inc-via-reuse-as fixture
  under --alloc=gc, asserting stdout 9.
- New fixture examples/reuse_as_demo.{ailx,ail.json}.

Test deltas: E2E 55 -> 56, ailang-check unit 43 -> 46,
ailang-check workspace 8 -> 9, surface 14 -> 18. cargo test
--workspace green. No existing fixture's canonical JSON changed.
This commit is contained in:
2026-05-08 10:55:31 +02:00
parent 8f40e8c31f
commit 74379b29e5
15 changed files with 785 additions and 1 deletions
+6
View File
@@ -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);
}
}
}
+27
View File
@@ -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 <var>
/// <ctor>)` 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
+124
View File
@@ -426,6 +426,18 @@ pub enum CheckError {
ctor: String,
candidates: Vec<String>,
},
/// 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": "<term-tag>"}`. 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<T> = std::result::Result<T, CheckError>;
@@ -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})"));
}
}
+7
View File
@@ -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 {
+166
View File
@@ -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 <var> <body>)`
/// site whose `<var>` 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"}));
}
}
+10
View File
@@ -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);
}
}
}
+120
View File
@@ -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:
+23
View File
@@ -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<String>, 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<String>, 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);
}
}
}
+27
View File
@@ -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<String, Type>) -> 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)),
},
}
}
+17
View File
@@ -300,6 +300,23 @@ pub enum Term {
Clone {
value: Box<Term>,
},
/// 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<Term>,
body: Box<Term>,
},
}
/// One arm of a [`Term::Match`].
+33
View File
@@ -324,6 +324,11 @@ fn collect_used_in_term(t: &Term, used: &mut BTreeSet<String>) {
// 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<String>, 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<Term> {
.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),
}
}
+149 -1
View File
@@ -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<Term, ParseError> {
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<Pattern, ParseError> {
@@ -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.
+11
View File
@@ -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 <source> <body>)`. 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(')');
}
}
}
+1
View File
@@ -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"}
+64
View File
@@ -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 <var> <ctor>)` 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)))))))