Iter 18c.1: Term::Clone schema (no inc emission yet)

Adds (clone X) as a new Term variant. Form-A:
  (let p (expensive_fn x)
    (do consume_a (clone p))    ; explicit RC inc — needs 18c.3
    (do consume_b p))

Schema floor for the LLM-aware RC design (Decision 10): explicit
clone is the author-visible alternative to implicit sharing. In
18c.1 the variant is purely additive — typechecker treats it as
identity (same type as inner), codegen lowers it to the inner
term's IR with no extra calls. Iter 18c.3 will replace the
codegen identity-arm with a single call:

  Term::Clone { value } =>
      let v = self.lower_term(value, ...)?;
      self.emit("call void @ailang_rc_inc(ptr {v})");
      Ok(v)

Match-arm count: ~10 sites across 6 files, all mechanical
structural recursion through `value`. The codegen `lower_term`
arm carries an explicit comment marking the 18c.3 emission seam.

Hash invariant: `(clone X)` uses a new serde tag "clone" that no
pre-18c.1 fixture contains. Every existing fixture's canonical
JSON is bit-identical (git diff examples/ empty).

New fixture examples/clone_demo.{ailx,ail.json}:
  (let x 42 (do io/print_int (clone x)))
Stdout 42 under both --alloc=gc and --alloc=rc.

Tail-position propagates through clone (verify_tail_positions
treats `(clone tail-call)` as itself a tail call), per the
identity-of-clone semantics in 18c.1.

Verified:
- cargo build --workspace clean
- cargo test --workspace green: 52 e2e (+1), 14 surface parse
  (+2 for clone tests), all other buckets unchanged
- ail render round-trip exact
- clone_demo --alloc=gc → 42; --alloc=rc → 42
This commit is contained in:
2026-05-08 02:26:12 +02:00
parent 5cd0a3400a
commit 7dfc88a4f3
12 changed files with 221 additions and 2 deletions
+83 -2
View File
@@ -37,7 +37,7 @@
//! term ::= var-ref | int-lit | str-lit | bool-lit | unit-lit
//! | app-term | tail-app-term | match-term | ctor-term
//! | do-term | tail-do-term | seq-term | lam-term | if-term
//! | let-term | let-rec-term
//! | let-term | let-rec-term | clone-term
//! var-ref ::= ident ; reserved: true/false → bool-lit
//! int-lit ::= integer ; numeric atom
//! str-lit ::= string ; string atom
@@ -62,6 +62,7 @@
//! type-attr
//! body-attr
//! "(" "in" term ")" ")"
//! clone-term ::= "(" "clone" term ")" ; Iter 18c.1
//!
//! pattern ::= pat-var | pat-ctor | pat-lit | pat-wild
//! pat-var ::= ident
@@ -752,6 +753,7 @@ impl<'a> Parser<'a> {
"if" => self.parse_if(),
"let" => self.parse_let(),
"let-rec" => self.parse_let_rec(),
"clone" => self.parse_clone(),
other => {
let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
Err(ParseError::Production {
@@ -759,7 +761,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`, `lit-unit`"
`tail-do`, `seq`, `term-ctor`, `clone`, `lit-unit`"
),
pos,
})
@@ -1033,6 +1035,37 @@ impl<'a> Parser<'a> {
})
}
/// Iter 18c.1: `(clone TERM)` — explicit RC clone wrapper. In 18c.1
/// the wrapper is identity for typechecker and codegen; in 18c.3
/// the codegen will emit `call void @ailang_rc_inc` before yielding
/// the inner value's SSA reg under `--alloc=rc`.
fn parse_clone(&mut self) -> Result<Term, ParseError> {
self.expect_lparen("clone-term")?;
self.expect_keyword("clone")?;
// Exactly one inner term, 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: "clone-term",
message: "clone expects exactly one term argument".into(),
pos,
});
}
let inner = 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: "clone-term",
message: "clone expects exactly one term argument".into(),
pos,
});
}
self.expect_rparen("clone-term")?;
Ok(Term::Clone {
value: Box::new(inner),
})
}
// ---- patterns -------------------------------------------------------
fn parse_pattern(&mut self) -> Result<Pattern, ParseError> {
@@ -1231,6 +1264,54 @@ mod tests {
);
}
/// Iter 18c.1: `(clone X)` parses to `Term::Clone { value: Var "x" }`.
#[test]
fn parses_clone_wraps_inner_term() {
let m = parse(
r#"
(module m
(fn id
(type (fn-type (params (con Int)) (ret (con Int))))
(params x)
(body (clone x))))
"#,
)
.unwrap();
let body = match &m.defs[0] {
Def::Fn(fd) => &fd.body,
_ => panic!("expected fn"),
};
match body {
Term::Clone { value } => match value.as_ref() {
Term::Var { name } => assert_eq!(name, "x"),
other => panic!("expected Var inside Clone, got {other:?}"),
},
other => panic!("expected Clone, got {other:?}"),
}
}
/// Iter 18c.1: `(clone)` with no inner term is rejected with a
/// clear message.
#[test]
fn rejects_clone_without_argument() {
let err = parse(
r#"
(module m
(fn id
(type (fn-type (params (con Int)) (ret (con Int))))
(params x)
(body (clone))))
"#,
)
.err()
.expect("parse should fail");
let msg = format!("{err}");
assert!(
msg.contains("clone expects exactly one term argument"),
"diagnostic should explain clone's arity, got: {msg}"
);
}
/// 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.