//! Linearity check for fns. Every parameter mode is explicit //! (`Borrow` or `Own`) — ownership has no default (spec 0062). //! //! ## Scope //! //! - **Active only** on `Def::Fn` whose `Type::Fn.param_modes` is //! non-empty. A nullary fn has no binders to track, so the check //! skips it. //! - **Pure diagnostic.** Emits [`Diagnostic`]s with codes //! `use-after-consume` / `consume-while-borrowed`. No IR change, no //! codegen change, no runtime change. The check runs after //! [`crate::check_fn`] type-checking succeeded for the def. //! - **No uniqueness inference.** That is Iter 18c.3. The check only //! tracks what is *spelled* in the source: a binder is consumed when //! it appears in a non-borrow position; cloned via [`Term::Clone`]; //! borrowed via fn-call into a `Borrow` parameter. //! //! ## Position model //! //! Each [`Term::Var`] occurrence is in some "position", determined by //! its parent term: //! //! - **Consume** — default. The use takes ownership. //! - **Borrow** — the use lends a reference; ownership stays with the //! binder. //! //! Position propagation: //! - `Term::App.callee` — Borrow (applying a function value reads it; //! the value stays live for further applications and is dropped at //! scope close). A global fn-ref callee is untracked, so this is a //! no-op there; a tracked function-typed binder is not consumed by //! application. //! - `Term::App.args[i]` — Borrow if the callee is a `Var` whose //! resolved type is a `Type::Fn` with `param_modes[i] == Borrow`; //! otherwise Consume (Own / unknown default to Consume). //! - `Term::Clone.value` — Borrow (clone reads + bumps RC, doesn't //! move). //! - `Term::Match.scrutinee` — Borrow (matching is a read; for an //! `Own` param this leaves the binder uneconsumed, which is a known //! false-negative for "param declared own but never moved" — out of //! scope for 18c.2 per the assignment). //! - `Term::Let.value`, `Term::Seq.lhs`, `Term::If.cond` — Consume. //! - `Term::Ctor.args[*]` — Consume (the new value owns them). //! - `Term::Do.args[*]` — Borrow (effect-op observes; ownership //! stays with the caller). //! - `Term::Lam` — captures are conservatively Consume. //! //! ## Within-call borrow lifetime (consume-while-borrowed) //! //! For `App { callee, args, .. }` whose callee is a `Var` resolving to //! a fn-typed binding with explicit `param_modes`: when the i-th arg is //! a bare `Term::Var { name }` consumed in Borrow position, we //! **increment** `borrow_count[name]` *before* evaluating //! `args[i+1..]`. After all args are evaluated and the call has run, //! we **decrement** them again. So a sibling subterm that consumes the //! same binder while it is still borrowed by an earlier sibling //! triggers the `consume-while-borrowed` diagnostic. //! //! For a `Borrow` *parameter* of the enclosing fn, the binder starts //! the body with `borrow_count = 1` (the caller's outer borrow). Any //! attempt to consume it inside the body therefore also triggers //! `consume-while-borrowed`. //! //! ## Suggested rewrites //! //! Each diagnostic carries a non-empty //! [`crate::diagnostic::Diagnostic::suggested_rewrites`]. The form-A //! replacement is parseable by [`ailang_surface::parse_term`] — that's //! the contract guarded by the round-trip test in `tests/`. //! //! - `use-after-consume` on `Var x`: replacement `(clone )` — //! the author should clone an *earlier* use so this later use stays //! the unique consume. //! - `consume-while-borrowed` on `Var x`: replacement `(clone )` //! — the author should clone here so the original keeps the borrow. //! //! ## Iter 19a: `over-strict-mode` lint //! //! After the linearity walk has finished for a fn whose every param //! mode is explicit, we re-inspect each param annotated `(own T)`: //! //! - The uniqueness pass ([`crate::uniqueness::infer_module`]) gives //! us the param's `consume_count` and, importantly, the //! `consume_count` of every pattern-binder introduced when the //! param is matched on. //! - If `consume_count(p) == 0` AND, for every `match p ...` in the //! body, every pattern-binder bound by an arm of that match has //! `consume_count == 0`, then the fn could have been written with //! `(borrow T)` instead and would be cheaper to call (no inc/dec //! pair, no rec-cascade at fn return). Emit `over-strict-mode` as //! a `Severity::Warning` with a suggested fn-type rewrite. //! //! ### Iter 19a.1: precise sub-binder analysis //! //! 19a originally shipped a conservative cut ("any `match` whose //! scrutinee is `p` silences the lint") because the precise check //! seemed to require a new side-table. In practice the side-table //! is already there: the uniqueness pass populates `consume_count` //! for every binder including pattern-binders. 19a.1 replaces the //! cut with a precise per-arm walk: for each `(match p ...)`, we //! ask whether any **heap-typed** pattern-binder of any arm has //! `consume_count > 0`. If yes, `(own T)` is genuinely needed and //! the lint stays silent. If no, the param is purely read by the //! match and the lint fires — exactly the shape of fixtures like //! `rc_own_param_drop.head_or_zero` (which only returns the head //! and never moves the tail). //! //! ### Why "heap-typed" matters //! //! A pattern-binder of primitive type (`Int` / `Bool` / `Str` / //! `Unit`) being consumed does not force the scrutinee to be `Own` //! — primitives are copied out by value, no RC operation is //! involved. The uniqueness pass bumps `consume_count` for every //! Consume-position read regardless of type, so the lint must //! filter primitive-typed binders out via the pattern's parent //! ctor field types. `head_or_zero`'s body //! `match xs { Cons(h, t) => h }` returns `h: Int`; without the //! filter, `h.consume_count == 1` would silence the lint. //! //! ### Bugfix `over-strict-mode-ctor-rebuild-consume` //! //! The heap-typed-sub-binder check above does not see the consume //! when an arm destructures `p`'s ctor into *primitive* fields and //! then feeds them into a `Term::Ctor` that rebuilds an allocation — //! e.g. `match p { State(acc, n) => State(acc, n) }`, or the nested //! `match st { State(acc, n) => match tick { Tick(px) => State(..) } }` //! body of `embed_backtest_step_tick`. The rebuild dismantles `p`'s //! box and re-packages its payload into a fresh allocation; with //! `(borrow ...)` the scrutinee's box could not be taken apart, so //! `(own ...)` is genuinely required and the lint must stay silent. //! `pattern_has_consumed_heap_binder` filters the primitive binders //! out, so it could not catch this. The fix adds a second //! recognition path (`collect_pattern_binders` + //! `ctor_uses_any_binder`): //! if a destructured binder of the matched arm — primitive or not — //! is referenced by any `Term::Ctor` in the arm body, the param is //! consumed. An earlier draft of this note mis-attributed the //! false-positive to deeply-nested `match`-on-sub-binder; that was a //! misdiagnosis. The defect is the unrecognised ctor-rebuild //! consume, independent of match nesting, and it caused a *spurious* //! warning (over-strict), never a missed one. use crate::diagnostic::Diagnostic; use crate::uniqueness::{infer_module as infer_uniqueness, UniquenessTable}; use ailang_core::ast::{Arm, Ctor, Def, FnDef, Module, NewArg, ParamMode, Pattern, Term, Type}; use crate::{collect_pattern_binders, strip_forall}; use ailang_surface::{term_to_form_a, type_to_form_a}; use std::collections::HashMap; /// Per-fn inferred type of each `Term::Let` binder, teed out of the /// typecheck pass (`synth`'s `Let` arm) so the linearity walk — which /// runs after typechecking and does not re-infer — can read a /// `let`-binder's type where the source pins no annotation. Keyed /// `(def_name, binder_name)`. pub(crate) type LetBinderTypes = HashMap<(String, String), Type>; /// a `Type::Con` whose name is `Int`/`Bool`/`Str`/`Unit` /// is a primitive value type — no RC, no heap. Reading a primitive /// pattern-binder bumps `consume_count` in the uniqueness table but /// does not force the scrutinee param to be `Own`. The lint filters /// those out so a fn like `match xs { Cons(h, t) => h }` (where `h` /// is `Int`) can still fire `over-strict-mode` when `t.consume_count /// == 0`. fn is_heap_type(t: &Type) -> bool { match t { Type::Con { name, .. } => !ailang_core::primitives::is_primitive_name(name), // Type::Var (a polymorphic param) is conservatively heap — // it could instantiate to a heap type at the call site. Type::Var { .. } => true, Type::Fn { .. } => true, Type::Forall { .. } => true, } } /// `true` iff `t` is an unboxed value type (`Int`/`Bool`/`Float`/`Unit`). /// The `Str`-excluding counterpart of `is_heap_type`'s primitive check — /// see `ailang_core::primitives::is_value_type`. fn type_is_value(t: &Type) -> bool { matches!(t, Type::Con { name, .. } if ailang_core::primitives::is_value_type(name)) } /// Position in which a [`Term::Var`] is being used. See module-level /// docs for the propagation rules. #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum Position { /// The use takes ownership (default position). Consume, /// The use lends a non-owning reference for the duration of the /// enclosing call (or, for `Term::Clone` / `Term::Match`, for the /// duration of the read). Borrow, } /// Mutable per-binder linearity state, keyed by binder name. Names /// shadow lexically — see [`Checker::with_binder`] for the /// save/restore pattern. #[derive(Debug, Default, Clone)] struct BinderState { /// `true` once the binder has been used in a Consume position. /// Subsequent uses (in any position) trigger `use-after-consume`. consumed: bool, /// Number of currently-live borrows of this binder. Incremented /// when a Borrow-position use starts (in a fn-call arg slot or as /// the initial state of a `Borrow` parameter); decremented when /// the borrow ends. Consume while `> 0` triggers /// `consume-while-borrowed`. borrow_count: u32, /// `true` if this binder has an unboxed value type /// (`Int`/`Bool`/`Float`/`Unit`). A value type has no refcount and /// is never consumed, so `use_var` skips all consume bookkeeping /// for it. Invariant per binder; set at the introduction site /// (param / pattern / lam) where the type is locally available. is_value: bool, /// For a function-typed binder (a HOF predicate param, or a /// `let`/`lam`-bound function value), its declared parameter modes /// — so `callee_arg_modes` can resolve `(app p h)` where `p` is a /// local `(borrow …)` predicate and walk `h` as a borrow, not a /// consume. `None` for a non-function binder. fn_param_modes: Option>, } /// top-level entry. Walks every fn in `m` and emits /// linearity diagnostics for the all-explicit-mode fns. Other fns are /// skipped without traversal. /// /// Output ordering: defs in declaration order; within a def, diagnostics /// in source-order discovery (depth-first left-to-right walk). /// Test-only convenience entry: linearity check on a single Module /// with no extra-visible siblings. Production code (`check_workspace`) /// uses [`check_module_with_visible`] directly to give the walker /// visibility into other workspace modules — in particular, the /// auto-injected prelude's polymorphic free fns and class methods. #[cfg(test)] pub(crate) fn check_module(m: &Module) -> Vec { check_module_with_visible(m, &[], &LetBinderTypes::new()) } /// workspace-aware entry. `visible_extra` is a slice of /// modules whose top-level fns and class methods should be visible /// to `callee_arg_modes` (so a Borrow-mode user fn that forwards its /// borrow params into an imported polymorphic free fn — e.g. the /// prelude's `gt` / `ne` / `lt` — sees the imported fn's /// `param_modes` and does not false-fire `consume-while-borrowed`). /// /// Symmetric to the iter 23.4-prep extension that made class methods /// visible. Bodies of the visible-extra modules are NOT walked here /// — they are checked when their own module is processed. pub(crate) fn check_module_with_visible( m: &Module, visible_extra: &[&Module], let_binder_types: &LetBinderTypes, ) -> Vec { let mut globals: HashMap = HashMap::new(); // ctor name → field types, used by the // `over-strict-mode` lint to filter out primitive-typed // pattern-binders (Int / Bool / Str / Unit) — their consume // does not force ownership of the scrutinee. let mut ctors: HashMap> = HashMap::new(); // register builtin signatures so `callee_arg_modes` // resolves them when a user fn forwards a Borrow-mode param // into a builtin like `str_clone : (Str borrow) -> Str`. The // previously-registered class-method types (below, in the // `Def::Class` arm) cover one half of the same problem; this // registration covers the builtin half. Symmetric to the // uniqueness pass's iter 24.1 builtin registration. let mut builtins_env = crate::Env::default(); crate::builtins::install(&mut builtins_env); for (name, ty) in &builtins_env.globals { globals.insert(name.clone(), strip_forall(ty).clone()); } // register fns + class methods from visible-extra // modules first, so a name-clash inside `m` overwrites the // imported entry (matching the synth-side scope rule that // local-defs shadow implicit imports). for em in visible_extra { for def in &em.defs { match def { Def::Fn(f) => { globals.insert(f.name.clone(), strip_forall(&f.ty).clone()); } Def::Class(c) => { for cm in &c.methods { globals.insert(cm.name.clone(), strip_forall(&cm.ty).clone()); } } // Const / Type / Instance: not relevant for callee-arg-mode // resolution (Const can't be a callee; Type's ctors are // construction sites not fn calls; Instance bodies are // walked separately as Def::Fn post-mono). _ => {} } } } for def in &m.defs { match def { Def::Fn(f) => { let ty = strip_forall(&f.ty); globals.insert(f.name.clone(), ty.clone()); } Def::Const(c) => { globals.insert(c.name.clone(), strip_forall(&c.ty).clone()); } Def::Type(td) => { for c in &td.ctors { let Ctor { name, fields } = c; ctors.insert(name.clone(), fields.clone()); } } // class methods register their types into // the globals map so `callee_arg_modes` can see their // `param_modes`. Without this, a borrow-mode fn that // forwards its borrow params into a class-method call // fires `consume-while-borrowed` false positives. // Instance defs stay a no-op: instance method bodies are // walked separately via `Def::Fn` after monomorphisation. Def::Class(c) => { for m in &c.methods { globals.insert(m.name.clone(), strip_forall(&m.ty).clone()); } } Def::Instance(_) => {} } } // the over-strict-mode lint reads `consume_count` from // the uniqueness side table. Build it once for the whole module; // reusing the existing pass keeps the "what counts as a consume" // definition in a single place. let uniq = infer_uniqueness(m); let mut diags = Vec::new(); for def in &m.defs { if let Def::Fn(f) = def { check_fn(f, &globals, &ctors, &uniq, let_binder_types, &mut diags); } } diags } /// Pull a function-typed value's declared parameter modes out of its /// type, unwrapping a leading `forall`. Returns `None` for any /// non-function type and for a fn-type with no explicit modes (legacy /// all-implicit), so a caller that gets `None` falls back to the /// global symbol table exactly as before. fn fn_modes_of(t: &Type) -> Option> { match strip_forall(t) { Type::Fn { param_modes, .. } if !param_modes.is_empty() => Some(param_modes.clone()), _ => None, } } /// Per-fn check. Skips nullary fns (no binders to track) — see /// module-level scope rules. fn check_fn( f: &FnDef, globals: &HashMap, ctors: &HashMap>, uniq: &UniquenessTable, let_binder_types: &LetBinderTypes, diags: &mut Vec, ) { let inner = strip_forall(&f.ty); let (param_tys, param_modes) = match inner { Type::Fn { params, param_modes, .. } => (params.as_slice(), param_modes.as_slice()), _ => return, }; // Activation gate: no params at all disables the check — there are // no binders to track in the param-list, so the check has nothing // to enforce. Every mode is now explicitly `Own`/`Borrow` // (spec 0062), so there is no unmoded disjunct left. if param_modes.is_empty() { return; } // Sanity: param-count has been verified by `check_fn` upstream, but // double-defend in case the type vec is shorter than the binder // list, which would silently mis-skip the check otherwise. if param_modes.len() != f.params.len() || param_tys.len() != f.params.len() { return; } let mut checker = Checker { globals, diags, def_name: &f.name, ctors, let_binder_types, binders: HashMap::new(), aliases: HashMap::new(), }; // Install fn parameters as binders, with initial `borrow_count = 1` // for `Borrow` params (the caller's outer borrow stays live for the // body's whole duration) and `0` for `Own` params. A value-type // param starts `is_value = true` and is exempt from consume-tracking. for (i, (name, mode)) in f.params.iter().zip(param_modes.iter()).enumerate() { let initial = BinderState { consumed: false, borrow_count: match mode { ParamMode::Borrow => 1, ParamMode::Own => 0, }, is_value: param_tys.get(i).map(type_is_value).unwrap_or(false), fn_param_modes: param_tys.get(i).and_then(fn_modes_of), }; checker.binders.insert(name.clone(), initial); } checker.walk(&f.body, Position::Consume); // `over-strict-mode` lint. After the linearity // walk has completed (and any errors have been recorded), inspect // each `Own`-annotated param: if `consume_count(p) == 0` AND no // pattern-binder of any `(match p ...)` arm in the body has // `consume_count > 0`, the param could be re-annotated `Borrow`. // Emit a Warning with a form-A rewrite. // // The precise sub-binder check (19a.1) replaces 19a's // conservative "any match on `p` silences" cut: matching on `p` // is fine as long as none of the arms moves a sub-binder out. // The uniqueness pass already populates `consume_count` for // pattern-binders, so the check is a deterministic walk. // // Two guards keep the lint coherent post-cutover: // - An `(intrinsic)`-bodied fn is skipped wholesale: the walk does // nothing for `Term::Intrinsic`, so `consume_count == 0` is a // guaranteed false positive. An intrinsic's param modes are // hand-authored contracts, not lint-derivable from a body. // - A value-typed param (`Int`/`Bool`/`Float`/`Unit`) is skipped: // `(borrow V)` is rejected by `borrow-over-value` (lib.rs), so // `(own V)` is the only legal mode and a relax-to-borrow // suggestion would be incoherent. if matches!(f.body, Term::Intrinsic) { return; } for (i, mode) in param_modes.iter().enumerate() { if !matches!(mode, ParamMode::Own) { continue; } if param_tys.get(i).map(type_is_value).unwrap_or(false) { continue; } let pname = &f.params[i]; let consume_count = uniq .get(&(f.name.clone(), pname.clone())) .map(|info| info.consume_count) .unwrap_or(0); if consume_count != 0 { continue; } if any_sub_binder_consumed_for(&f.body, pname, uniq, &f.name, ctors) { // A pattern-binder of some `(match p ...)` arm is // consumed → `(own T)` is genuinely needed. Stay silent. continue; } diags.push(make_over_strict_mode(&f.name, pname, inner, i)); } } /// Per-fn linearity walker. struct Checker<'a> { /// Top-level symbol → type. Used to look up the param_modes of an /// `App` callee that resolves to a global fn def. globals: &'a HashMap, /// Diagnostic accumulator (shared with the module-level walk). diags: &'a mut Vec, /// Name of the fn currently being checked (becomes /// [`Diagnostic::def`]). def_name: &'a str, /// ctor name → field types. Used to type pattern binders so a /// value-typed sub-binder (`Cons(h, t)` with `h: Int`) is exempted /// from consume-tracking. Mirrors the `ctors` map built in /// `check_module_with_visible`. ctors: &'a HashMap>, /// `(def, let-binder) → inferred type`, teed from the typecheck /// pass. Read at the `Term::Let` arm to seed `is_value` for a /// value-typed `let`-binder (the source pins no annotation there). let_binder_types: &'a LetBinderTypes, /// Live binder state, keyed by name. Modified in place; lexical /// scoping is restored by [`Checker::with_binder`]. binders: HashMap, /// `let`-alias → root binder name. A `(let a t …)` whose value is /// a bare `Var t` resolving to a tracked binder records `a → /// root(t)` here for the body scope; every binder-state lookup /// resolves a name through this map first, so the alias shares the /// root's consume/borrow state. Scoped: removed when the `let` /// body ends. aliases: HashMap, } impl<'a> Checker<'a> { /// The core position-aware walk. Recurses through `t` and updates /// per-binder state according to the rules in the module-level /// docs. fn walk(&mut self, t: &Term, pos: Position) { match t { Term::Lit { .. } => {} Term::Var { name } => self.use_var(name, pos), Term::App { callee, args, .. } => { // Applying a function value READS it (a borrow): the value // stays live for further applications and is dropped at // scope close like any other binder. For a global fn-ref // callee this is a no-op (globals are untracked, so // `use_var` returns early either way); for a tracked // function-typed binder (a HOF param) it stops application // from consuming the binder. The `consumed` check still // runs in Borrow position, so applying an already-consumed // function value still fires use-after-consume. self.walk(callee, Position::Borrow); // Determine arg modes from the callee's resolved type. let arg_modes = self.callee_arg_modes(callee, args.len()); // Track which binders we lent so we can release them // after the call. let mut lent: Vec = Vec::new(); for (i, arg) in args.iter().enumerate() { let arg_pos = match arg_modes.get(i) { Some(ParamMode::Borrow) => Position::Borrow, // Own / unknown → Consume. _ => Position::Consume, }; self.walk(arg, arg_pos); // If we lent a *bare* binder reference at this arg // slot, the borrow stays live for the rest of the // call (i.e. while subsequent args evaluate, and // until the callee returns). Bump `borrow_count` // now so a sibling Consume of the same binder // triggers `consume-while-borrowed`. if matches!(arg_pos, Position::Borrow) { if let Term::Var { name } = arg { let root = self.resolve_alias(name); if let Some(s) = self.binders.get_mut(&root) { s.borrow_count = s.borrow_count.saturating_add(1); lent.push(root); } } } } // Call returns: all lent borrows end. for name in lent { if let Some(s) = self.binders.get_mut(&name) { s.borrow_count = s.borrow_count.saturating_sub(1); } } } Term::Let { name, value, body } => { // Class 2: a bare-Var value aliasing a tracked binder is // a borrow-preserving rename, not a consume. Record // `name → root` and skip the consume walk of the value; // all binder-state lookups resolve through `aliases`. let alias_root = match value.as_ref() { Term::Var { name: vname } => { let root = self.resolve_alias(vname); if self.binders.contains_key(&root) { Some(root) } else { None } } _ => None, }; if let Some(root) = alias_root { // Shadow any outer binder / alias of `name` for the // body, install the alias, walk, then restore both. let prev_binder = self.binders.remove(name); let prev_alias = self.aliases.insert(name.clone(), root); self.walk(body, pos); self.aliases.remove(name); if let Some(a) = prev_alias { self.aliases.insert(name.clone(), a); } if let Some(b) = prev_binder { self.binders.insert(name.clone(), b); } } else { self.walk(value, Position::Consume); let teed = self .let_binder_types .get(&(self.def_name.to_string(), name.clone())); let is_value = teed.map(type_is_value).unwrap_or(false); let fn_param_modes = teed.and_then(fn_modes_of); self.with_binder( name, BinderState { is_value, fn_param_modes, ..BinderState::default() }, |this| { this.walk(body, pos); }, ); } } Term::LetRec { name, body, in_term, .. } => { // The body is the recursive fn's own body; treating it as // an opaque closure is sufficient for 18c.2 — its // captures are conservatively traversed in `Consume` // mode but with the recursive `name` masked (so the // recursive self-reference does not flag as a // use-after-consume of the in_term's view). self.with_binder(name, BinderState::default(), |this| { this.walk(body, Position::Consume); this.walk(in_term, pos); }); } Term::If { cond, then, else_ } => { self.walk(cond, Position::Consume); // Branch independence: each arm starts from the pre-branch // state. If any arm consumes/borrows a binder, that // outcome is visible to the post-`If` continuation // (conservative merge: union of consumed flags, max of // borrow_count). let saved = self.binders.clone(); self.walk(then, pos); let after_then = std::mem::replace(&mut self.binders, saved); self.walk(else_, pos); merge_states(&mut self.binders, &after_then); } Term::Match { scrutinee, arms } => { // Scrutinee is read (Borrow), not moved. self.walk(scrutinee, Position::Borrow); // Determine whether the scrutinee is a borrowed binder: // a bare `Var` resolving (through `let`-aliases) to a // tracked binder whose `borrow_count > 0`. A heap-typed // sub-binder extracted from a borrowed scrutinee aliases // the caller-owned interior, so it must inherit the // borrow (mirroring the `Borrow`-param seeding in // `check_fn`). Any non-`Var` scrutinee is owned/fresh. let scrutinee_borrowed = match scrutinee.as_ref() { Term::Var { name } => { let root = self.resolve_alias(name); self.binders .get(&root) .map(|s| s.borrow_count > 0) .unwrap_or(false) } _ => false, }; let saved = self.binders.clone(); let mut acc: Option> = None; for arm in arms { self.binders = saved.clone(); self.walk_arm(arm, pos, scrutinee_borrowed); match acc.as_mut() { None => acc = Some(self.binders.clone()), Some(m) => merge_states(m, &self.binders), } } if let Some(merged) = acc { self.binders = merged; } else { self.binders = saved; } } Term::Seq { lhs, rhs } => { self.walk(lhs, Position::Consume); self.walk(rhs, pos); } Term::Ctor { args, .. } => { // Ctor args are consumed (the new value owns them). for a in args { self.walk(a, Position::Consume); } } Term::Do { args, .. } => { // Effect-op args are borrowed: the op observes the value // but ownership stays with the caller. Symmetric to the // doc-comment bullet at :42-43. for a in args { self.walk(a, Position::Borrow); } } Term::Lam { params, param_tys, body, .. } => { // Lam captures cross the linearity boundary: any free // var of the body is implicitly consumed by closure // construction (we don't yet model captured-borrow // discipline; that is 18c.3 territory). For 18c.2 we // walk the body with the lam's own params as fresh // binders; a value-typed lam param is exempt from // consume-tracking. let mut saved_for_params: HashMap> = HashMap::new(); for (i, p) in params.iter().enumerate() { saved_for_params.insert(p.clone(), self.binders.remove(p)); let st = BinderState { is_value: param_tys.get(i).map(type_is_value).unwrap_or(false), fn_param_modes: param_tys.get(i).and_then(fn_modes_of), ..BinderState::default() }; self.binders.insert(p.clone(), st); } self.walk(body, Position::Consume); for p in params { self.binders.remove(p); if let Some(prev) = saved_for_params.remove(p).flatten() { self.binders.insert(p.clone(), prev); } } } Term::Clone { value } => { // (clone X) reads X (Borrow) regardless of outer pos: // the resulting *value* is owned, but the inner term is // only borrowed. So `(clone xs)` does NOT consume `xs`. self.walk(value, Position::Borrow); } Term::ReuseAs { source, body } => { // 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. let resolved = match source.as_ref() { Term::Var { name } => Some(self.resolve_alias(name)), _ => None, }; match resolved { Some(root) if self.binders.contains_key(&root) => { let already_consumed = self .binders .get(&root) .map(|s| s.consumed) .unwrap_or(false); if already_consumed { self.diags .push(make_use_after_consume_at_reuse_as(self.def_name, &root, body)); } else if let Some(s) = self.binders.get_mut(&root) { // Mark the source consumed at the reuse-as site. s.consumed = true; } } _ => { self.diags.push(make_reuse_as_source_not_bare_var( self.def_name, source.as_ref(), body, )); } } // Walk the body normally; its sub-uses go through the // standard position rules. self.walk(body, pos); } Term::Loop { binders, body } => { for b in binders { self.walk(&b.init, Position::Consume); } self.walk(body, pos); } Term::Recur { args } => { for a in args { self.walk(a, Position::Consume); } } // prep.2 (kernel-extension-mechanics): `(new T args...)` is // a Ctor-like construction call — each value-arg is // consumed by the new value. Type-args carry no runtime // term and contribute no use. Term::New { args, .. } => { for arg in args { if let NewArg::Value(v) = arg { self.walk(v, Position::Consume); } } } Term::Intrinsic => {} } } /// Walk one match arm: introduce the pattern's bindings as fresh /// `Live` binders, walk the body, then drop them. Pattern bindings /// are linearity-fresh in 18c.2; we do not yet track ownership /// transfer from the scrutinee (that requires modelling pattern /// borrow vs. consume, which is 18c.3 work). fn walk_arm(&mut self, arm: &Arm, pos: Position, scrutinee_borrowed: bool) { let names = collect_pattern_binders(&arm.pat); let mut value_names: Vec = Vec::new(); collect_value_pattern_binders(&arm.pat, None, self.ctors, &mut value_names); let mut saved: HashMap> = HashMap::new(); for n in &names { saved.insert(n.clone(), self.binders.remove(n)); let is_value = value_names.contains(n); let st = BinderState { is_value, // A heap-typed sub-binder of a borrowed scrutinee aliases // the caller-owned interior; seed `borrow_count = 1` so // consuming it fires `consume-while-borrowed`, mirroring // the `Borrow`-param seeding in `check_fn`. Value-typed // sub-binders (e.g. `Int h`) are copied out — no heap // alias — so they stay freely consumable at `0`. borrow_count: if scrutinee_borrowed && !is_value { 1 } else { 0 }, ..BinderState::default() }; self.binders.insert(n.clone(), st); } self.walk(&arm.body, pos); for n in &names { self.binders.remove(n); if let Some(prev) = saved.remove(n).flatten() { self.binders.insert(n.clone(), prev); } } } /// Record a use of `name` in `pos`. If `name` is not a tracked /// binder (i.e. it's a global / built-in), we ignore the use. /// /// Note: Borrow-position uses do **not** increment `borrow_count` /// here. The counter tracks borrows whose lifetime extends across /// sibling subterms — only the [`Term::App`] walker manages those /// (incrementing before the next sibling, decrementing when the /// call returns). A scrutinee or `Term::Clone` borrow does not /// span siblings, so it just checks `consumed` and returns. /// Resolve a name to the binder it ultimately denotes, following /// `let`-alias links. A real binder shadows an alias: at each step, /// if the current name is a tracked binder we stop there, so an /// inner `let`/pattern/`lam` binder of the same name takes /// precedence over an outer alias without any change to the /// binder-introduction sites. Returns an owned `String` so the /// immutable borrow of `self.aliases` ends before any /// `self.binders` mutation by the caller. fn resolve_alias(&self, name: &str) -> String { let mut cur = name.to_string(); loop { if self.binders.contains_key(&cur) { return cur; } match self.aliases.get(&cur) { Some(next) => cur = next.clone(), None => return cur, } } } fn use_var(&mut self, name: &str, pos: Position) { let root = self.resolve_alias(name); let state = match self.binders.get_mut(&root) { Some(s) => s, None => return, }; // Already-consumed: any further use is a use-after-consume, // regardless of position. if state.consumed { self.diags .push(make_use_after_consume(self.def_name, &root)); return; } match pos { Position::Borrow => { // No state change; the App walker is responsible for // bumping `borrow_count` when a Borrow arg lives across // sibling args, and for the per-fn-param initial value. } Position::Consume => { // A value type has no refcount and is never consumed; // multi-use in any position is legal. if state.is_value { return; } if state.borrow_count > 0 { self.diags .push(make_consume_while_borrowed(self.def_name, &root)); return; } state.consumed = true; } } } /// Resolve the param-mode vector of a fn-call's callee. /// /// For `callee = Term::Var { name }`, a local function-typed binder /// (a HOF predicate param, or a `let`/`lam`-bound function value) /// carrying `fn_param_modes` is resolved first, then a global fn /// type (with explicit `param_modes`). All other callee shapes /// return an empty vec → all args default to Consume. This is the /// conservative, "no false negatives on the borrow-arg case" /// behaviour: if we can't see the modes, we assume Consume and may /// miss a borrow. fn callee_arg_modes(&self, callee: &Term, n_args: usize) -> Vec { let name = match callee { Term::Var { name } => name, _ => return vec![], }; let root = self.resolve_alias(name); // A local function-typed binder (HOF predicate param, or a // `let`/`lam`-bound function value) carries its declared arg // modes on its `BinderState`. Prefer those over the global // table so `(app p h)` with `p` a local `(borrow …)` predicate // borrows `h` instead of consuming it. A local binder shadows a // global of the same name, which is the correct lexical scope. if let Some(s) = self.binders.get(&root) { if let Some(modes) = &s.fn_param_modes { return modes.clone(); } } let ty = match self.globals.get(name) { Some(t) => t, None => { // Type-scoped kernel ops (e.g. `RawBuf.get`) are // registered under their BARE def name (`get`) by // `check_module_with_visible`. When the direct lookup // misses and the name is a class/type-shaped qualifier, // retry under the bare method name so the borrow-receiver // modes resolve — same treatment the rest of the checker // gives type-scoped polymorphic ops. A lowercase // cross-module-fn qualifier (`std_list.length`) is NOT // class-shaped and resolves by its own path, so it is // gated out here. let (method_name, qualifier_opt) = crate::parse_method_qualifier(name); if qualifier_opt.is_some() && crate::qualifier_is_class_shape(&qualifier_opt) { match self.globals.get(method_name) { Some(t) => t, None => return vec![], } } else { return vec![]; } } }; let inner = strip_forall(ty); match inner { Type::Fn { param_modes, .. } => { if param_modes.is_empty() { // All-implicit case (legacy): no mode info → all // Consume, by the rule above. vec![ParamMode::Own; n_args] } else { param_modes.clone() } } _ => vec![], } } /// Save the current state of `name`, install `fresh` for the /// duration of `body`, then restore. Used by `Let` / `LetRec` / /// `Lam` / pattern-arm to introduce a fresh binder while /// preserving lexical scope. fn with_binder(&mut self, name: &str, fresh: BinderState, body: F) { let prev = self.binders.remove(name); self.binders.insert(name.to_string(), fresh); body(self); self.binders.remove(name); if let Some(p) = prev { self.binders.insert(name.to_string(), p); } } } /// Collect the names of pattern binders that have an unboxed value /// type, by walking the pattern alongside the ctor field types. /// Mirrors `pattern_has_consumed_heap_binder_at`'s descent: a /// `Pattern::Var` directly under a ctor field of value type is added; /// a top-level `Pattern::Var` (whole-scrutinee binder, `declared_ty == /// None`) is conservatively treated as heap and never added. fn collect_value_pattern_binders( pat: &Pattern, declared_ty: Option<&Type>, ctors: &HashMap>, out: &mut Vec, ) { match pat { Pattern::Wild | Pattern::Lit { .. } => {} Pattern::Var { name } => { if let Some(t) = declared_ty { if type_is_value(t) { out.push(name.clone()); } } } Pattern::Ctor { ctor, fields } => { let field_tys: &[Type] = ctors.get(ctor).map(|v| v.as_slice()).unwrap_or(&[]); for (i, f) in fields.iter().enumerate() { collect_value_pattern_binders(f, field_tys.get(i), ctors, out); } } } } /// Conservative merge of two branch states: a binder is consumed in /// the merged state iff it was consumed in either branch; borrow_count /// becomes the max of the two. Names present only in one map are /// carried through. fn merge_states(into: &mut HashMap, other: &HashMap) { for (name, s) in other { let entry = into.entry(name.clone()).or_default(); entry.consumed = entry.consumed || s.consumed; entry.borrow_count = entry.borrow_count.max(s.borrow_count); // is_value is invariant per binder; the OR recovers it when the // binder reached `into` via a fresh `or_default()`. entry.is_value = entry.is_value || s.is_value; } } /// Build a `use-after-consume` diagnostic for `binder` in `def`. /// Suggested rewrite: clone the offending use (the LATER use is the /// flagged one; turning IT into `(clone )` doesn't help, but /// the suggested rewrite is the intent — "this use should have been /// `(clone X)`, or an earlier use should have been"). The form-A /// snippet is `(clone )`, parseable by /// [`ailang_surface::parse_term`]. fn make_use_after_consume(def: &str, binder: &str) -> Diagnostic { let replacement = term_to_form_a(&Term::Clone { value: Box::new(Term::Var { name: binder.to_string() }), }); 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!( "wrap an earlier use of `{binder}` in `(clone)` so this later use stays the unique consume" ), replacement, ) } /// 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", Term::Loop { .. } => "loop", Term::Recur { .. } => "recur", Term::New { .. } => "new", Term::Intrinsic => "intrinsic", }; 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, ) } /// `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, ) } /// precise sub-binder check for the over-strict-mode /// lint. Returns `true` iff some `(match p ...)` somewhere in `t`, /// where `p` is the param `pname`, has an arm whose pattern-binders /// include at least one binder with `consume_count > 0` in the /// uniqueness side table. /// /// "Match on `p`" includes `(match p ...)` and /// `(match (clone p) ...)` — both expose `p`'s sub-binders to the /// arms. /// /// Walks every other Term variant looking for further matches; a /// `match p ...` may sit arbitrarily deep inside `If` / `Let` / /// `App` / etc. /// /// **Ctor-rebuild consume (bugfix /// `over-strict-mode-ctor-rebuild-consume`):** besides a heap-typed /// sub-binder being moved out, the param is also genuinely consumed /// when an arm destructures `p`'s ctor and feeds the extracted /// fields — *including purely primitive ones* — into a `Term::Ctor` /// that builds a fresh allocation (e.g. /// `match p { State(acc, n) => State(acc, n) }`, or the nested /// `match st { State(acc, n) => match tick { Tick(px) => State(..) } }` /// shape of `embed_backtest_step_tick`). The rebuild dismantles /// `p`'s box and re-packages its payload, which `(borrow ...)` could /// not do, so `(own ...)` is required. `pattern_has_consumed_heap_binder` /// misses this because the destructured fields are primitive; the /// `collect_pattern_binders` + `ctor_uses_any_binder` pair below /// recognises it. The earlier draft of this comment mis-attributed /// the defect /// to a nested `match` on a sub-binder of `p`; the real mechanism is /// the unrecognised ctor-rebuild consume, independent of match /// nesting. fn any_sub_binder_consumed_for( t: &Term, pname: &str, uniq: &UniquenessTable, def_name: &str, ctors: &HashMap>, ) -> bool { match t { Term::Lit { .. } | Term::Var { .. } => false, Term::Match { scrutinee, arms } => { if scrutinee_matches_param(scrutinee, pname) { for arm in arms { if pattern_has_consumed_heap_binder(&arm.pat, uniq, def_name, ctors) { return true; } // Ctor-rebuild consume: destructuring `p`'s ctor // and feeding the extracted fields (incl. // primitive ones) into a `Term::Ctor` builds a // fresh allocation out of `p`'s dismantled // payload. That genuinely consumes the `(own)` // scrutinee — `(borrow)` could not dismantle it. // `pattern_has_consumed_heap_binder` misses this // because the destructured fields are primitive. let destructured = collect_pattern_binders(&arm.pat); if !destructured.is_empty() && ctor_uses_any_binder(&arm.body, &destructured) { return true; } // Recurse into the arm body: a deeper // `match p ...` could also count. if any_sub_binder_consumed_for(&arm.body, pname, uniq, def_name, ctors) { return true; } } false } else { // The scrutinee isn't `p`, but `p` could still be // matched-on inside the scrutinee or inside any arm. if any_sub_binder_consumed_for(scrutinee, pname, uniq, def_name, ctors) { return true; } arms.iter() .any(|a| any_sub_binder_consumed_for(&a.body, pname, uniq, def_name, ctors)) } } Term::App { callee, args, .. } => { any_sub_binder_consumed_for(callee, pname, uniq, def_name, ctors) || args .iter() .any(|a| any_sub_binder_consumed_for(a, pname, uniq, def_name, ctors)) } Term::Let { value, body, .. } => { any_sub_binder_consumed_for(value, pname, uniq, def_name, ctors) || any_sub_binder_consumed_for(body, pname, uniq, def_name, ctors) } Term::LetRec { body, in_term, .. } => { any_sub_binder_consumed_for(body, pname, uniq, def_name, ctors) || any_sub_binder_consumed_for(in_term, pname, uniq, def_name, ctors) } Term::If { cond, then, else_ } => { any_sub_binder_consumed_for(cond, pname, uniq, def_name, ctors) || any_sub_binder_consumed_for(then, pname, uniq, def_name, ctors) || any_sub_binder_consumed_for(else_, pname, uniq, def_name, ctors) } Term::Seq { lhs, rhs } => { any_sub_binder_consumed_for(lhs, pname, uniq, def_name, ctors) || any_sub_binder_consumed_for(rhs, pname, uniq, def_name, ctors) } Term::Ctor { args, .. } | Term::Do { args, .. } => args .iter() .any(|a| any_sub_binder_consumed_for(a, pname, uniq, def_name, ctors)), Term::Lam { body, .. } => any_sub_binder_consumed_for(body, pname, uniq, def_name, ctors), Term::Clone { value } => any_sub_binder_consumed_for(value, pname, uniq, def_name, ctors), Term::ReuseAs { source, body } => { any_sub_binder_consumed_for(source, pname, uniq, def_name, ctors) || any_sub_binder_consumed_for(body, pname, uniq, def_name, ctors) } Term::Loop { binders, body } => { binders.iter().any(|b| { any_sub_binder_consumed_for(&b.init, pname, uniq, def_name, ctors) }) || any_sub_binder_consumed_for(body, pname, uniq, def_name, ctors) } Term::Recur { args } => args.iter().any(|a| { any_sub_binder_consumed_for(a, pname, uniq, def_name, ctors) }), // prep.2 (kernel-extension-mechanics): recurse through // NewArg::Value subterms; NewArg::Type carries no runtime term. Term::New { args, .. } => args.iter().any(|arg| match arg { NewArg::Value(v) => any_sub_binder_consumed_for(v, pname, uniq, def_name, ctors), NewArg::Type(_) => false, }), Term::Intrinsic => false, } } /// returns `true` if `pat` introduces any **heap-typed** /// binder whose recorded `consume_count` is `> 0`. Primitive-typed /// pattern-binders (`Int` / `Bool` / `Str` / `Unit`) never count /// because reading a primitive does not force the scrutinee to be /// `Own` — the value is copied, not moved out of the scrutinee's /// allocation. This is the key reason `match xs { Cons(h, t) => h }` /// (head_or_zero) fires the lint: `h: Int` is filtered out, `t: /// IntList` has `consume_count == 0`. /// /// Walks `Pattern::Ctor` recursively. After 16a's desugar pass, /// top-level patterns under a Ctor field are flattened into separate /// matches — but the recursion is defensive (cheap and correct under /// any pattern shape; nested binders are looked up by their own name /// in `uniq`, regardless of their pattern depth). fn pattern_has_consumed_heap_binder( pat: &Pattern, uniq: &UniquenessTable, def_name: &str, ctors: &HashMap>, ) -> bool { pattern_has_consumed_heap_binder_at(pat, None, uniq, def_name, ctors) } /// helper carrying the optional declared type of `pat`. /// At the top of an arm pattern, `declared_ty` is `None` (we don't /// track the scrutinee's type in the lint); the type becomes known /// when we descend into a `Pattern::Ctor`'s fields, where the /// ctor's `Ctor::fields[i]` gives `fields[i]`'s type. /// /// For a `Pattern::Var { name }` with `declared_ty == None`, we /// conservatively treat the binder as heap (we don't know its /// type from the pattern alone). For `declared_ty == Some(t)`, we /// only count the binder if `is_heap_type(t)`. fn pattern_has_consumed_heap_binder_at( pat: &Pattern, declared_ty: Option<&Type>, uniq: &UniquenessTable, def_name: &str, ctors: &HashMap>, ) -> bool { match pat { Pattern::Wild | Pattern::Lit { .. } => false, Pattern::Var { name } => { // Conservative when type unknown: assume heap. let counts = match declared_ty { Some(t) => is_heap_type(t), None => true, }; if !counts { return false; } uniq.get(&(def_name.to_string(), name.clone())) .map(|info| info.consume_count > 0) .unwrap_or(false) } Pattern::Ctor { ctor, fields } => { let field_tys: &[Type] = ctors.get(ctor).map(|v| v.as_slice()).unwrap_or(&[]); fields.iter().enumerate().any(|(i, f)| { let ty = field_tys.get(i); pattern_has_consumed_heap_binder_at(f, ty, uniq, def_name, ctors) }) } } } /// Bugfix `over-strict-mode-ctor-rebuild-consume`: `true` iff some /// `Term::Ctor` reachable in `t` builds a fresh allocation whose /// argument subtrees mention at least one of `binders` (the names /// the matched arm destructured out of `p`'s ctor). That means the /// dismantled payload of the `(own)` scrutinee — possibly via an /// intervening expression such as `(+ acc px)` — flows into a newly /// allocated ctor, which `(borrow ...)` could not do (a borrowed /// value's box may not be taken apart and re-packaged). So `(own)` /// is genuinely required and the lint must stay silent. /// /// Conservative toward *not* suppressing: the ctor's args must /// actually reference a destructured binder. A ctor that ignores /// `p`'s payload (e.g. `match p { _ => Other(42) }`, or /// `match p { P(x) => Other(42) }`) does **not** trip this and the /// lint still warns. The binder scan is by name; a destructured /// primitive binder shadowed and rebound before reaching the ctor /// is not a corpus shape, and any resulting imprecision is in the /// over-strict (extra-silence) direction the cause explicitly /// permits, never under-strict. fn ctor_uses_any_binder(t: &Term, binders: &[String]) -> bool { match t { Term::Ctor { args, .. } => { // A freshly-built allocation: does its payload draw from // the destructured binders (at any depth)? Also recurse // — a ctor may be nested inside another ctor's args. args.iter().any(|a| term_mentions_any_binder(a, binders)) || args.iter().any(|a| ctor_uses_any_binder(a, binders)) } Term::Lit { .. } | Term::Var { .. } => false, Term::Match { scrutinee, arms } => { ctor_uses_any_binder(scrutinee, binders) || arms.iter().any(|a| ctor_uses_any_binder(&a.body, binders)) } Term::App { callee, args, .. } => { ctor_uses_any_binder(callee, binders) || args.iter().any(|a| ctor_uses_any_binder(a, binders)) } Term::Let { value, body, .. } => { ctor_uses_any_binder(value, binders) || ctor_uses_any_binder(body, binders) } Term::LetRec { body, in_term, .. } => { ctor_uses_any_binder(body, binders) || ctor_uses_any_binder(in_term, binders) } Term::If { cond, then, else_ } => { ctor_uses_any_binder(cond, binders) || ctor_uses_any_binder(then, binders) || ctor_uses_any_binder(else_, binders) } Term::Seq { lhs, rhs } => { ctor_uses_any_binder(lhs, binders) || ctor_uses_any_binder(rhs, binders) } Term::Do { args, .. } => args.iter().any(|a| ctor_uses_any_binder(a, binders)), Term::Lam { body, .. } => ctor_uses_any_binder(body, binders), Term::Clone { value } => ctor_uses_any_binder(value, binders), Term::ReuseAs { source, body } => { ctor_uses_any_binder(source, binders) || ctor_uses_any_binder(body, binders) } Term::Loop { binders: lb, body } => { lb.iter().any(|b| ctor_uses_any_binder(&b.init, binders)) || ctor_uses_any_binder(body, binders) } Term::Recur { args } => args.iter().any(|a| ctor_uses_any_binder(a, binders)), // prep.2 (kernel-extension-mechanics): recurse through // NewArg::Value subterms. NewArg::Type carries no runtime // ctor invocation. Term::New { args, .. } => args.iter().any(|arg| match arg { NewArg::Value(v) => ctor_uses_any_binder(v, binders), NewArg::Type(_) => false, }), Term::Intrinsic => false, } } /// Bugfix `over-strict-mode-ctor-rebuild-consume`: deep free-variable /// scan — does `t` mention any name in `binders` anywhere in its /// subtree (as a `Var`, possibly under `Clone` / `App` / arithmetic / /// nested ctor / …)? Used to test whether a `Term::Ctor`'s argument /// draws, directly or through an intervening expression, from the /// destructured payload of the matched `(own)` param. fn term_mentions_any_binder(t: &Term, binders: &[String]) -> bool { match t { Term::Var { name } => binders.iter().any(|b| b == name), Term::Lit { .. } => false, Term::Ctor { args, .. } | Term::Do { args, .. } | Term::Recur { args } => { args.iter().any(|a| term_mentions_any_binder(a, binders)) } Term::App { callee, args, .. } => { term_mentions_any_binder(callee, binders) || args.iter().any(|a| term_mentions_any_binder(a, binders)) } Term::Match { scrutinee, arms } => { term_mentions_any_binder(scrutinee, binders) || arms.iter().any(|a| term_mentions_any_binder(&a.body, binders)) } Term::Let { value, body, .. } => { term_mentions_any_binder(value, binders) || term_mentions_any_binder(body, binders) } Term::LetRec { body, in_term, .. } => { term_mentions_any_binder(body, binders) || term_mentions_any_binder(in_term, binders) } Term::If { cond, then, else_ } => { term_mentions_any_binder(cond, binders) || term_mentions_any_binder(then, binders) || term_mentions_any_binder(else_, binders) } Term::Seq { lhs, rhs } => { term_mentions_any_binder(lhs, binders) || term_mentions_any_binder(rhs, binders) } Term::Lam { body, .. } => term_mentions_any_binder(body, binders), Term::Clone { value } => term_mentions_any_binder(value, binders), Term::ReuseAs { source, body } => { term_mentions_any_binder(source, binders) || term_mentions_any_binder(body, binders) } Term::Loop { binders: lb, body } => { lb.iter().any(|b| term_mentions_any_binder(&b.init, binders)) || term_mentions_any_binder(body, binders) } // prep.2 (kernel-extension-mechanics): NewArg::Value subterms // may mention any of the destructured binders; NewArg::Type // carries no runtime term. Term::New { args, .. } => args.iter().any(|arg| match arg { NewArg::Value(v) => term_mentions_any_binder(v, binders), NewArg::Type(_) => false, }), Term::Intrinsic => false, } } /// a scrutinee "is" `pname` if it's a bare `Var { pname }` /// or `Clone(Var { pname })`. Anything else (a fresh App result, a /// let-binder, …) does not put `pname` directly in scrutinee /// position. fn scrutinee_matches_param(scrutinee: &Term, pname: &str) -> bool { match scrutinee { Term::Var { name } => name == pname, Term::Clone { value } => scrutinee_matches_param(value, pname), _ => false, } } /// build the `over-strict-mode` warning. `inner` is the /// fn-type with the original mode; we synthesise a relaxed copy with /// `param_modes[i] = Borrow` and render it through /// [`type_to_form_a`] to produce the suggested rewrite. fn make_over_strict_mode(def: &str, binder: &str, inner: &Type, i: usize) -> Diagnostic { let relaxed = relax_param_to_borrow(inner, i); let replacement = type_to_form_a(&relaxed); Diagnostic::warning( "over-strict-mode", format!( "param `{binder}` is annotated `(own ...)` but the body does not consume it; `(borrow ...)` would suffice" ), ) .with_def(def) .with_ctx(serde_json::json!({ "binder": binder, "current_mode": "own", "suggested_mode": "borrow", })) .with_suggested_rewrite( format!("relax param `{binder}` mode from `(own ...)` to `(borrow ...)`"), replacement, ) } /// clone of `inner` (assumed `Type::Fn`) with /// `param_modes[i]` rewritten to `ParamMode::Borrow`. Other slots /// are preserved bit-for-bit. Falls back to returning `inner` /// unchanged if `inner` is not a `Type::Fn` (defensive — caller /// should have screened this). fn relax_param_to_borrow(inner: &Type, i: usize) -> Type { match inner { Type::Fn { params, param_modes, ret, ret_mode, effects, } => { // `param_modes.len() == params.len()` (spec 0062: every // slot is moded), so a direct clone places the relaxed // mode correctly. let mut new_modes: Vec = param_modes.clone(); if i < new_modes.len() { new_modes[i] = ParamMode::Borrow; } Type::Fn { params: params.clone(), param_modes: new_modes, ret: ret.clone(), ret_mode: *ret_mode, effects: effects.clone(), } } other => other.clone(), } } /// 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 { let replacement = term_to_form_a(&Term::Clone { value: Box::new(Term::Var { name: binder.to_string() }), }); Diagnostic::error( "consume-while-borrowed", format!("`{binder}` is consumed while a borrow of it is still live"), ) .with_def(def) .with_ctx(serde_json::json!({"binder": binder})) .with_suggested_rewrite( format!( "consume `(clone {binder})` here so the original keeps its outstanding borrow" ), replacement, ) } #[cfg(test)] mod tests { use super::*; use ailang_core::ast::{FnDef, Literal}; use std::collections::BTreeMap; fn fn_with_modes(name: &str, modes: Vec, body: Term) -> Def { Def::Fn(FnDef { name: name.into(), ty: Type::Fn { params: modes .iter() .map(|_| Type::Con { name: "List".into(), args: vec![] }) .collect(), param_modes: modes.clone(), ret: Box::new(Type::int()), ret_mode: ParamMode::Own, effects: vec![], }, params: (0..modes.len()).map(|i| format!("p{i}")).collect(), body, suppress: vec![], doc: None, export: None, }) } /// A fn with one function-typed param `p0 : (mode (Int -> Int))`, /// returning Int. Used to exercise HOF application linearity. fn fn_with_fn_param(name: &str, mode: ParamMode, body: Term) -> Def { Def::Fn(FnDef { name: name.into(), ty: Type::Fn { params: vec![Type::Fn { params: vec![Type::int()], param_modes: vec![ParamMode::Own], ret: Box::new(Type::int()), ret_mode: ParamMode::Own, effects: vec![], }], param_modes: vec![mode], ret: Box::new(Type::int()), ret_mode: ParamMode::Own, effects: vec![], }, params: vec!["p0".into()], body, suppress: vec![], doc: None, export: None, }) } /// Applying a function param more than once is a borrow each time, /// not a consume: `(app p0 (app p0 0))` must NOT fire /// use-after-consume. (Today the App callee is walked Consume, so /// the first application consumes `p0` and the second fires /// use-after-consume — this test is RED until Fix 2.) #[test] fn own_fn_param_applied_twice_is_clean() { let body = Term::App { callee: Box::new(Term::Var { name: "p0".into() }), args: vec![Term::App { callee: Box::new(Term::Var { name: "p0".into() }), args: vec![Term::Lit { lit: Literal::Int { value: 0 } }], tail: false, }], tail: false, }; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![fn_with_fn_param("f", ParamMode::Own, body)], }; let diags = check_module(&m); assert!( !diags.iter().any(|d| d.code == "use-after-consume"), "applying a function param is a borrow, not a consume; got {diags:?}" ); } /// fn f(p0: (borrow (List -> Bool, inner arg Borrow)), h: (own List)). /// The inner predicate's arg is `Borrow`, so applying `p0` to `h` /// borrows `h` (it stays reusable). Used by the class-1 tests. fn fn_with_borrow_pred_and_heap(name: &str, body: Term) -> Def { let list = Type::Con { name: "List".into(), args: vec![] }; Def::Fn(FnDef { name: name.into(), ty: Type::Fn { params: vec![ Type::Fn { params: vec![list.clone()], param_modes: vec![ParamMode::Borrow], ret: Box::new(Type::bool_()), ret_mode: ParamMode::Own, effects: vec![], }, list.clone(), ], param_modes: vec![ParamMode::Borrow, ParamMode::Own], ret: Box::new(Type::int()), ret_mode: ParamMode::Own, effects: vec![], }, params: vec!["p0".into(), "h".into()], body, suppress: vec![], doc: None, export: None, }) } /// Class 1 (param path): a local borrow-mode predicate param `p0` /// applied to a heap binder `h` (`(seq (app p0 h) h)`) does NOT /// consume `h` — `p0`'s declared `(borrow …)` arg mode makes the /// application a borrow, so `h` is reusable. RED until the /// local-fn-param-mode resolution lands. #[test] fn borrow_fn_param_applied_does_not_consume_heap_arg() { let body = Term::Seq { lhs: Box::new(Term::App { callee: Box::new(Term::Var { name: "p0".into() }), args: vec![Term::Var { name: "h".into() }], tail: false, }), rhs: Box::new(Term::Var { name: "h".into() }), }; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![fn_with_borrow_pred_and_heap("f", body)], }; let diags = check_module(&m); assert!( !diags.iter().any(|d| d.code == "use-after-consume"), "applying a borrow-mode predicate param borrows its arg; got {diags:?}" ); } /// Class 1 (let path): a `let`-bound function value whose type is /// teed as a `(borrow …)` predicate resolves its modes from the /// LetBinderTypes table — applying it to heap binder `h` borrows /// `h`. The let-value term is a trivial literal; the hand-built /// table is the unit under test. #[test] fn borrow_fn_let_binder_applied_does_not_consume_heap_arg() { let body = Term::Let { name: "g".into(), value: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), body: Box::new(Term::Seq { lhs: Box::new(Term::App { callee: Box::new(Term::Var { name: "g".into() }), args: vec![Term::Var { name: "p0".into() }], tail: false, }), rhs: Box::new(Term::Var { name: "p0".into() }), }), }; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![fn_with_modes("f", vec![ParamMode::Own], body)], }; let list = Type::Con { name: "List".into(), args: vec![] }; let mut lbt = LetBinderTypes::new(); lbt.insert( ("f".into(), "g".into()), Type::Fn { params: vec![list.clone()], param_modes: vec![ParamMode::Borrow], ret: Box::new(Type::bool_()), ret_mode: ParamMode::Own, effects: vec![], }, ); let diags = check_module_with_visible(&m, &[], &lbt); assert!( !diags.iter().any(|d| d.code == "use-after-consume"), "a let-bound borrow predicate borrows its arg via the teed modes; got {diags:?}" ); } /// A fn with one `(own (con Int))` value-type param `p0`, returning /// Int. Used to exercise the value-type exemption. fn fn_with_int_own(name: &str, body: Term) -> Def { Def::Fn(FnDef { name: name.into(), ty: Type::Fn { params: vec![Type::Con { name: "Int".into(), args: vec![] }], param_modes: vec![ParamMode::Own], ret: Box::new(Type::int()), ret_mode: ParamMode::Own, effects: vec![], }, params: vec!["p0".into()], body, suppress: vec![], doc: None, export: None, }) } /// A value-type param read in two consume positions /// (`(seq p0 p0)`) must NOT fire use-after-consume: an `Int` has no /// refcount and is never consumed. RED until Fix 1. #[test] fn value_param_multi_read_is_clean() { let body = Term::Seq { lhs: Box::new(Term::Var { name: "p0".into() }), rhs: Box::new(Term::Var { name: "p0".into() }), }; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![fn_with_int_own("f", body)], }; let diags = check_module(&m); assert!( !diags.iter().any(|d| d.code == "use-after-consume"), "a value-type param is never consumed; multi-read is legal; got {diags:?}" ); } /// A value-typed `let`-binder (type teed as `Float`) read twice /// (`(let x 0 (seq x x))`) must NOT fire use-after-consume — the /// `is_value` flag is seeded from the let-binder type table. RED /// until the class-3 fix. #[test] fn value_let_binder_multi_read_is_clean() { let body = Term::Let { name: "x".into(), value: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), body: Box::new(Term::Seq { lhs: Box::new(Term::Var { name: "x".into() }), rhs: Box::new(Term::Var { name: "x".into() }), }), }; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![fn_with_int_own("f", body)], }; let mut lbt = LetBinderTypes::new(); lbt.insert(("f".into(), "x".into()), Type::Con { name: "Float".into(), args: vec![] }); let diags = check_module_with_visible(&m, &[], &lbt); assert!( !diags.iter().any(|d| d.code == "use-after-consume"), "a value-typed let-binder is never consumed; multi-read is legal; got {diags:?}" ); } /// Type-gating guard: a HEAP-typed `let`-binder (teed as `List`) /// read twice MUST still fire use-after-consume — the exemption is /// value-type-only, not blanket. (Regression guard, green after fix.) #[test] fn heap_let_binder_multi_read_still_errors() { let body = Term::Let { name: "x".into(), value: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), body: Box::new(Term::Seq { lhs: Box::new(Term::Var { name: "x".into() }), rhs: Box::new(Term::Var { name: "x".into() }), }), }; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![fn_with_int_own("f", body)], }; let mut lbt = LetBinderTypes::new(); lbt.insert(("f".into(), "x".into()), Type::Con { name: "List".into(), args: vec![] }); let diags = check_module_with_visible(&m, &[], &lbt); assert!( diags.iter().any(|d| d.code == "use-after-consume"), "a heap-typed let-binder consumed twice must still fire; got {diags:?}" ); } /// Alias soundness: `(let a p0 (seq a p0))` over an OWN heap binder /// `p0` — consuming the alias `a` then the root `p0` is a real /// double-consume and MUST still fire use-after-consume. The /// redirect shares one root state; it does not mask the second /// consume (the clone alternative would). Empty table → the alias /// path triggers structurally on the bare-Var value. #[test] fn let_alias_of_owned_double_consume_still_errors() { let body = Term::Let { name: "a".into(), value: Box::new(Term::Var { name: "p0".into() }), body: Box::new(Term::Seq { lhs: Box::new(Term::Var { name: "a".into() }), rhs: Box::new(Term::Var { name: "p0".into() }), }), }; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![fn_with_modes("f", vec![ParamMode::Own], body)], }; let diags = check_module(&m); assert!( diags.iter().any(|d| d.code == "use-after-consume"), "consuming an alias then its root is a real double-consume; got {diags:?}" ); } /// Type-gating guard: a HEAP param (`List`) consumed twice in a ctor /// (`(term-ctor Pair Pair p0 p0)`) MUST still fire use-after-consume /// after the fix — the exemption is value-type-only, not blanket. /// (Green today and after the fix; a regression guard, not RED-first.) #[test] fn heap_param_multi_consume_still_errors() { let body = Term::Ctor { type_name: "Pair".into(), ctor: "Pair".into(), args: vec![ Term::Var { name: "p0".into() }, Term::Var { name: "p0".into() }, ], }; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![fn_with_modes("f", vec![ParamMode::Own], body)], }; let diags = check_module(&m); assert!( diags.iter().any(|d| d.code == "use-after-consume"), "a heap param consumed twice must still error; got {diags:?}" ); } /// A borrow function param applied AND passed (the recursive-HOF /// shape `map_int`) must be clean: application is a borrow, and the /// param starts borrowed, so neither use-after-consume nor /// consume-while-borrowed should fire. Covered by Fix 2 (App /// borrow); this asserts the borrow-param variant. RED until Fix 2. #[test] fn borrow_fn_param_applied_is_clean() { let body = Term::App { callee: Box::new(Term::Var { name: "p0".into() }), args: vec![Term::Lit { lit: Literal::Int { value: 0 } }], tail: false, }; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![fn_with_fn_param("f", ParamMode::Borrow, body)], }; let diags = check_module(&m); assert!( !diags.iter().any(|d| { d.code == "use-after-consume" || d.code == "consume-while-borrowed" }), "applying a borrow function param is a clean read; got {diags:?}" ); } /// A single `Own` param consumed exactly once (the body returns it) /// is clean — no `over-strict-mode`, no use-after-consume. (Pre-0062 /// this was the `Implicit`-exemption case; post-cutover there is no /// `Implicit`, so it pins the ordinary single-consume path.) #[test] fn own_param_consumed_once_is_clean() { let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![fn_with_modes( "f", vec![ParamMode::Own], Term::Var { name: "p0".into() }, )], }; assert!(check_module(&m).is_empty()); } /// Post-cutover (spec 0062): every param is explicitly `Own`/`Borrow`, /// so a mixed `[Borrow, Own]` fn is checked (no Implicit exemption /// remains). The unused `Own` param trips `over-strict-mode`. #[test] fn mixed_borrow_own_unused_own_fires_over_strict() { let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![fn_with_modes( "f", vec![ParamMode::Borrow, ParamMode::Own], Term::Lit { lit: Literal::Int { value: 0 } }, )], }; let diags = check_module(&m); assert!( diags.iter().any(|d| d.code == "over-strict-mode"), "the unused `Own` param must fire over-strict-mode; got: {diags:#?}" ); } /// All-explicit fn that NEVER uses its params, annotated `Borrow`: /// clean. (Pre-19a this test exercised the same shape with `Own` /// and asserted clean; 19a's `over-strict-mode` lint now fires on /// `Own` + zero-consume — moved into a dedicated positive test /// below. With `Borrow`, the lint does not apply.) #[test] fn explicit_fn_with_no_uses_is_clean() { let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![fn_with_modes( "f", vec![ParamMode::Borrow], Term::Lit { lit: Literal::Int { value: 0 } }, )], }; assert!(check_module(&m).is_empty()); } /// 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(), kernel: false, 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})")); } /// 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(), kernel: false, 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"})); } // ---- Iter 19a: over-strict-mode ----------------------------------- use crate::diagnostic::Severity; /// a fn with `(own T)` whose body merely /// borrows the param via a `Borrow`-mode call → fires /// `over-strict-mode` (Warning) with binder=p0, current=own, /// suggested=borrow, and a non-empty rewrite. #[test] fn over_strict_mode_fires_when_param_only_borrowed() { // Module shape: // (fn read (params (borrow (con List))) ... ) ; helper, body irrelevant // (fn f (params (own (con List))) ; over-strict // body = (app read p0)) let read_def = Def::Fn(FnDef { name: "read".into(), ty: Type::Fn { params: vec![Type::Con { name: "List".into(), args: vec![] }], param_modes: vec![ParamMode::Borrow], ret: Box::new(Type::int()), ret_mode: ParamMode::Own, effects: vec![], }, params: vec!["xs".into()], body: Term::Lit { lit: Literal::Int { value: 0 } }, suppress: vec![], doc: None, export: None, }); let f_body = Term::App { callee: Box::new(Term::Var { name: "read".into() }), args: vec![Term::Var { name: "p0".into() }], tail: false, }; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![read_def, fn_with_modes("f", vec![ParamMode::Own], f_body)], }; let diags = check_module(&m); // Filter to over-strict-mode in case the helper triggers anything // (it shouldn't — `read` is `Borrow` and param is unused). let osm: Vec<_> = diags.iter().filter(|d| d.code == "over-strict-mode").collect(); assert_eq!(osm.len(), 1, "expected one over-strict-mode, got {diags:?}"); let d = osm[0]; assert_eq!(d.severity, Severity::Warning); assert_eq!(d.def.as_deref(), Some("f")); assert_eq!( d.ctx, serde_json::json!({ "binder": "p0", "current_mode": "own", "suggested_mode": "borrow", }) ); assert!( !d.suggested_rewrites.is_empty(), "diagnostic should carry a suggested rewrite" ); // The replacement is a fn-type. Post-cutover every slot is moded, // so the rewrite legitimately carries `(own ...)` on the return // slot; the property under test is that the over-strict *param* // slot is relaxed to `(borrow ...)`. let rep = &d.suggested_rewrites[0].replacement; assert!( rep.contains("(params (borrow (con List)))"), "rewrite should relax the param to `(borrow (con List))`; got {rep}" ); } /// a fn with `(own T)` whose body consumes /// the param directly does not fire `over-strict-mode`. #[test] fn over_strict_mode_silent_when_body_consumes_param() { // Body: (seq p0 0) — `p0` is consumed in seq.lhs. let body = Term::Seq { lhs: Box::new(Term::Var { name: "p0".into() }), rhs: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), }; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![fn_with_modes("f", vec![ParamMode::Own], body)], }; let diags = check_module(&m); assert!( !diags.iter().any(|d| d.code == "over-strict-mode"), "no over-strict-mode expected; got {diags:?}" ); } /// a fn with `(borrow T)` and a borrowing /// body never fires `over-strict-mode` (the lint is gated on /// `Own`). #[test] fn over_strict_mode_silent_when_param_is_borrow() { // Body shape: literal — no use of the param at all. let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![fn_with_modes( "f", vec![ParamMode::Borrow], Term::Lit { lit: Literal::Int { value: 0 } }, )], }; let diags = check_module(&m); assert!( !diags.iter().any(|d| d.code == "over-strict-mode"), "no over-strict-mode expected for borrow-mode param; got {diags:?}" ); } /// a fn with `(own T)` whose body is /// `(match p0 ...)` and never consumes `p0` directly nor any of /// `p0`'s sub-binders DOES fire `over-strict-mode` under the /// precise sub-binder analysis. This is the test that 19a left /// pinned as a TODO marker; 19a.1 flipped it from a negative to /// a positive assertion. #[test] fn over_strict_mode_fires_when_match_arm_uses_no_sub_binder() { // Body: (match p0 ((pat-wild) 0)) // — no sub-binders are bound; the param is only read. let body = Term::Match { scrutinee: Box::new(Term::Var { name: "p0".into() }), arms: vec![Arm { pat: Pattern::Wild, body: Term::Lit { lit: Literal::Int { value: 0 } }, }], }; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![fn_with_modes("f", vec![ParamMode::Own], body)], }; let diags = check_module(&m); let osm: Vec<_> = diags.iter().filter(|d| d.code == "over-strict-mode").collect(); assert_eq!( osm.len(), 1, "precise rule: expected one over-strict-mode warning when the body matches on `p0` and binds no sub-consume; got {diags:?}" ); assert_eq!(osm[0].severity, Severity::Warning); assert_eq!(osm[0].def.as_deref(), Some("f")); } /// helper to build an `IntList = Nil | Cons(Int, /// IntList)` ADT def. Used by the head_or_zero-shape tests /// (positive + negative variants below) so the lint can look up /// each ctor field's type and filter out primitives. fn intlist_typedef() -> Def { Def::Type(ailang_core::ast::TypeDef { name: "IntList".into(), vars: vec![], ctors: vec![ Ctor { name: "Nil".into(), fields: vec![] }, Ctor { name: "Cons".into(), fields: vec![ Type::int(), Type::Con { name: "IntList".into(), args: vec![] }, ], }, ], doc: None, drop_iterative: false, param_in: BTreeMap::new(), }) } /// helper to build a fn whose single param is /// `(own IntList)` and whose body is `body`. Distinct from /// `fn_with_modes` (which uses a generic `List` type for params) /// so the test exercises a real ctor-field-types lookup. fn fn_own_intlist(name: &str, body: Term) -> Def { Def::Fn(FnDef { name: name.into(), ty: Type::Fn { params: vec![Type::Con { name: "IntList".into(), args: vec![] }], param_modes: vec![ParamMode::Own], ret: Box::new(Type::int()), ret_mode: ParamMode::Own, effects: vec![], }, params: vec!["xs".into()], body, suppress: vec![], doc: None, export: None, }) } /// a fn with `(own IntList)` whose body /// is `match xs { Cons(h, t) => t }` consumes the *heap-typed* /// pattern-binder `t`. The lint must NOT fire — the /// sub-consume forces ownership. #[test] fn over_strict_mode_silent_when_match_arm_consumes_sub_binder() { // Body: (match xs ((pat-ctor Cons (pat-var h) (pat-var t)) t)) let body = Term::Match { scrutinee: Box::new(Term::Var { name: "xs".into() }), arms: vec![Arm { pat: Pattern::Ctor { ctor: "Cons".into(), fields: vec![ Pattern::Var { name: "h".into() }, Pattern::Var { name: "t".into() }, ], }, body: Term::Var { name: "t".into() }, }], }; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![intlist_typedef(), fn_own_intlist("f", body)], }; let diags = check_module(&m); assert!( !diags.iter().any(|d| d.code == "over-strict-mode"), "no over-strict-mode expected when a heap-typed arm-binder is consumed; got {diags:?}" ); } /// helper: a fn `sink : (own IntList) -> Int` whose only role is /// to give an `(own)` arg slot for a consume. Its body matches and /// moves out the heap-typed tail (`match ys { Cons(_, t) => sink(t), /// Nil => 0 }`) so the sink genuinely consumes its `(own)` param — /// no spurious `over-strict-mode` warning on `sink` itself to /// confound the test. fn fn_sink_own_intlist(name: &str) -> Def { let body = Term::Match { scrutinee: Box::new(Term::Var { name: "ys".into() }), arms: vec![ Arm { pat: Pattern::Ctor { ctor: "Nil".into(), fields: vec![] }, body: Term::Lit { lit: Literal::Int { value: 0 } }, }, Arm { pat: Pattern::Ctor { ctor: "Cons".into(), fields: vec![ Pattern::Wild, Pattern::Var { name: "rest".into() }, ], }, body: Term::App { callee: Box::new(Term::Var { name: name.to_string() }), args: vec![Term::Var { name: "rest".into() }], tail: false, }, }, ], }; Def::Fn(FnDef { name: name.into(), ty: Type::Fn { params: vec![Type::Con { name: "IntList".into(), args: vec![] }], param_modes: vec![ParamMode::Own], ret: Box::new(Type::int()), ret_mode: ParamMode::Own, effects: vec![], }, params: vec!["ys".into()], body, suppress: vec![], doc: None, export: None, }) } /// helper: a fn whose single param is `(borrow IntList)` and whose /// body is `body`. The borrow param starts the walk with /// `borrow_count = 1`, so any in-body consume of `xs` — or of a /// heap sub-binder extracted from `xs` — must fire /// `consume-while-borrowed`. fn fn_borrow_intlist(name: &str, body: Term) -> Def { Def::Fn(FnDef { name: name.into(), ty: Type::Fn { params: vec![Type::Con { name: "IntList".into(), args: vec![] }], param_modes: vec![ParamMode::Borrow], ret: Box::new(Type::int()), ret_mode: ParamMode::Own, effects: vec![], }, params: vec!["xs".into()], body, suppress: vec![], doc: None, export: None, }) } /// SOUNDNESS PROPERTY (#58): a heap-typed pattern sub-binder /// extracted from a `(borrow)`-mode match scrutinee inherits the /// scrutinee's borrowed status, so consuming it must fire /// `consume-while-borrowed`. /// /// `leak : (borrow IntList)` matches `xs` and, in the `Cons(h, t)` /// arm, passes the heap-typed tail `t` to `sink`, an /// `(own IntList)` sink. `t` aliases the interior of the /// caller-owned, only-borrowed `xs`; moving it into an `(own)` /// param double-frees at runtime (SIGSEGV). The linearity pass /// already rejects consuming the *whole* borrowed param (sibling /// fixture `cut55_2d`), but here the sub-binder `t` is seeded as a /// fresh `BinderState` with `borrow_count = 0`, so the consume goes /// unflagged. /// /// RED today: `check_module` returns no `consume-while-borrowed` /// diagnostic. The primitive sub-binder `h` (`Int`) is correctly /// exempt (copied out, no heap alias); the bug is specific to the /// heap-typed `t`. #[test] fn consume_heap_sub_binder_of_borrow_scrutinee_is_rejected() { // leak body: (match xs // ((pat-ctor Nil) 0) // ((pat-ctor Cons (pat-var h) (pat-var t)) (app sink t))) let body = Term::Match { scrutinee: Box::new(Term::Var { name: "xs".into() }), arms: vec![ Arm { pat: Pattern::Ctor { ctor: "Nil".into(), fields: vec![] }, body: Term::Lit { lit: Literal::Int { value: 0 } }, }, Arm { pat: Pattern::Ctor { ctor: "Cons".into(), fields: vec![ Pattern::Var { name: "h".into() }, Pattern::Var { name: "t".into() }, ], }, body: Term::App { callee: Box::new(Term::Var { name: "sink".into() }), args: vec![Term::Var { name: "t".into() }], tail: false, }, }, ], }; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![ intlist_typedef(), fn_sink_own_intlist("sink"), fn_borrow_intlist("leak", body), ], }; let diags = check_module(&m); let cwb: Vec<_> = diags .iter() .filter(|d| d.code == "consume-while-borrowed" && d.def.as_deref() == Some("leak")) .collect(); assert_eq!( cwb.len(), 1, "consuming the heap-typed tail `t` of a borrowed scrutinee must fire \ consume-while-borrowed (it aliases caller-owned heap → double-free); got {diags:?}" ); assert_eq!( cwb[0].ctx, serde_json::json!({ "binder": "t" }), "the diagnostic must name the consumed sub-binder `t`; got {diags:?}" ); } /// RED (over-strict-mode FP on ctor-rebuild): a `State(Float, /// Int)` ADT def. Both fields are primitive — the lint's /// `pattern_has_consumed_heap_binder` filters them out — yet a /// body that destructures `State` and rebuilds it still consumes /// the scrutinee's allocation. fn state_typedef() -> Def { Def::Type(ailang_core::ast::TypeDef { name: "State".into(), vars: vec![], ctors: vec![Ctor { name: "State".into(), fields: vec![Type::float(), Type::int()], }], doc: None, drop_iterative: false, param_in: BTreeMap::new(), }) } /// RED helper: a fn whose single param is `(own State)` and whose /// body is `body`. Mirrors `fn_own_intlist` but over `State` so /// the ctor-field-type lookup resolves to `(Float, Int)`. fn fn_own_state(name: &str, body: Term) -> Def { Def::Fn(FnDef { name: name.into(), ty: Type::Fn { params: vec![Type::Con { name: "State".into(), args: vec![] }], param_modes: vec![ParamMode::Own], ret: Box::new(Type::Con { name: "State".into(), args: vec![] }), ret_mode: ParamMode::Own, effects: vec![], }, params: vec!["st".into()], body, suppress: vec![], doc: None, export: None, }) } /// RED, negative property: a fn with `(own State)` whose body is /// `match st { State(acc, n) => State(acc, n) }` — it /// destructures the owned scrutinee and **rebuilds the same /// ctor** from the extracted fields. The old `State` allocation /// is dismantled by the rebuild, so `(own State)` is genuinely /// required; `(borrow State)` would NOT suffice. The lint must /// therefore stay silent. /// /// This is the minimal isolating shape behind the /// `embed_backtest_step_tick` over-strict-mode false positive: /// `any_sub_binder_consumed_for` only recognises a consume when a /// *heap-typed* pattern-binder is moved out. When every /// destructured field is primitive (`acc: Float`, `n: Int`) but /// they feed a `term-ctor` rebuilding the scrutinee's own ctor, /// the rebuild's re-consumption of the payload is invisible to /// the check and the lint spuriously fires. No nested `match` and /// no second param are needed to trigger it — the defect is the /// unrecognised ctor-rebuild consume, not match nesting. #[test] fn over_strict_mode_silent_when_ctor_rebuilt_from_primitive_fields() { // Body: (match st // ((pat-ctor State (pat-var acc) (pat-var n)) // (term-ctor State (var acc) (var n)))) let body = Term::Match { scrutinee: Box::new(Term::Var { name: "st".into() }), arms: vec![Arm { pat: Pattern::Ctor { ctor: "State".into(), fields: vec![ Pattern::Var { name: "acc".into() }, Pattern::Var { name: "n".into() }, ], }, body: Term::Ctor { type_name: "State".into(), ctor: "State".into(), args: vec![ Term::Var { name: "acc".into() }, Term::Var { name: "n".into() }, ], }, }], }; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![state_typedef(), fn_own_state("f", body)], }; let diags = check_module(&m); assert!( !diags.iter().any(|d| d.code == "over-strict-mode"), "no over-strict-mode expected: the `term-ctor State` rebuild \ re-consumes the destructured payload of the `(own State)` \ scrutinee, so `(own)` is genuinely required; got {diags:?}" ); } /// a fn with `(own IntList)` whose body /// is `match xs { Nil => 0, Cons(h, t) => h }` returns the /// **primitive-typed** `h` (`Int`) and never moves the /// heap-typed `t`. The lint MUST fire — `(borrow IntList)` /// would have sufficed. This is exactly /// `rc_own_param_drop.head_or_zero`'s shape. #[test] fn over_strict_mode_fires_when_match_arm_binders_unused() { // Body: (match xs // ((pat-ctor Nil) 0) // ((pat-ctor Cons (pat-var h) (pat-var t)) h)) let body = Term::Match { scrutinee: Box::new(Term::Var { name: "xs".into() }), arms: vec![ Arm { pat: Pattern::Ctor { ctor: "Nil".into(), fields: vec![], }, body: Term::Lit { lit: Literal::Int { value: 0 } }, }, Arm { pat: Pattern::Ctor { ctor: "Cons".into(), fields: vec![ Pattern::Var { name: "h".into() }, Pattern::Var { name: "t".into() }, ], }, body: Term::Var { name: "h".into() }, }, ], }; let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![intlist_typedef(), fn_own_intlist("f", body)], }; let diags = check_module(&m); let osm: Vec<_> = diags.iter().filter(|d| d.code == "over-strict-mode").collect(); assert_eq!( osm.len(), 1, "expected one over-strict-mode warning (head_or_zero shape); got {diags:?}" ); assert_eq!(osm[0].severity, Severity::Warning); assert_eq!(osm[0].def.as_deref(), Some("f")); assert_eq!( osm[0].ctx, serde_json::json!({ "binder": "xs", "current_mode": "own", "suggested_mode": "borrow", }) ); } /// `over-strict-mode` does NOT fire on a never-consumed `(own V)` /// param whose type is value-typed (`Int`/`Bool`/`Float`/`Unit`). /// Property: for a value-typed param the lint's own suggested /// rewrite — `(borrow V)` — is itself a `borrow-over-value` error /// (lib.rs), so `(own V)` is the only legal mode and a suggestion /// to relax it is incoherent. The fn body is real (a literal), so /// the only reason the param looks "over-strict" is its value type; /// the lint must stay silent regardless. #[test] fn over_strict_mode_silent_when_param_is_value_typed() { // Body: (lit 0) — `p0 : (own (con Int))` is never used at all. let body = Term::Lit { lit: Literal::Int { value: 0 } }; let f = Def::Fn(FnDef { name: "f".into(), ty: Type::Fn { params: vec![Type::int()], param_modes: vec![ParamMode::Own], ret: Box::new(Type::int()), ret_mode: ParamMode::Own, effects: vec![], }, params: vec!["p0".into()], body, suppress: vec![], doc: None, export: None, }); let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![f], }; let diags = check_module(&m); assert!( !diags.iter().any(|d| d.code == "over-strict-mode"), "no over-strict-mode expected for a value-typed `(own Int)` \ param: `(borrow Int)` is a borrow-over-value error, so `own` \ is the only legal mode; got {diags:?}" ); } /// `over-strict-mode` does NOT fire on a fn whose body is /// `Term::Intrinsic`, even when an `(own HeapType)` param looks /// never-consumed. Property: the linearity walk does nothing for an /// intrinsic body (`Term::Intrinsic => {}`), so `consume_count == 0` /// is a guaranteed false positive — the param modes of an intrinsic /// are hand-authored contracts, not lint-derivable from a walkable /// body. #[test] fn over_strict_mode_silent_for_intrinsic_body() { // `(own IntList)` param, body = (intrinsic). IntList is heap-typed, // so the only reason the lint would stay silent post-fix is the // intrinsic-body guard, not the value-type guard. let f = Def::Fn(FnDef { name: "f".into(), ty: Type::Fn { params: vec![Type::Con { name: "IntList".into(), args: vec![] }], param_modes: vec![ParamMode::Own], ret: Box::new(Type::int()), ret_mode: ParamMode::Own, effects: vec![], }, params: vec!["p0".into()], body: Term::Intrinsic, suppress: vec![], doc: None, export: None, }); let m = Module { schema: ailang_core::SCHEMA.into(), name: "t".into(), kernel: false, imports: vec![], defs: vec![intlist_typedef(), f], }; let diags = check_module(&m); assert!( !diags.iter().any(|d| d.code == "over-strict-mode"), "no over-strict-mode expected for an intrinsic-bodied fn: the \ walk cannot observe consumption, so consume_count==0 is a \ false positive; got {diags:?}" ); } /// a borrow-mode fn that forwards its borrow /// params into a class-method call must not fire /// `consume-while-borrowed` false positives. The pre-fix behaviour /// is two diagnostics (one per param) because /// `linearity::check_module`'s globals-construction loop skips /// `Def::Class`, so `callee_arg_modes` cannot see the method's /// `param_modes` and defaults each call argument to `Consume`. #[test] fn class_method_borrow_call_does_not_fire_consume_while_borrowed() { use ailang_core::ast::{ClassDef, ClassMethod}; let class_eq = Def::Class(ClassDef { name: "TestEq".into(), param: "a".into(), superclass: None, methods: vec![ClassMethod { name: "teq".into(), ty: Type::Fn { params: vec![ Type::Var { name: "a".into() }, Type::Var { name: "a".into() }, ], param_modes: vec![ParamMode::Borrow, ParamMode::Borrow], ret: Box::new(Type::bool_()), ret_mode: ParamMode::Own, effects: vec![], }, default: None, }], doc: None, }); let ne = Def::Fn(FnDef { name: "tne".into(), ty: Type::Fn { params: vec![ Type::Var { name: "a".into() }, Type::Var { name: "a".into() }, ], param_modes: vec![ParamMode::Borrow, ParamMode::Borrow], ret: Box::new(Type::bool_()), ret_mode: ParamMode::Own, effects: vec![], }, params: vec!["x".into(), "y".into()], body: Term::App { callee: Box::new(Term::Var { name: "teq".into() }), args: vec![ Term::Var { name: "x".into() }, Term::Var { name: "y".into() }, ], tail: false, }, suppress: vec![], doc: None, export: None, }); let m = Module { schema: ailang_core::SCHEMA.into(), name: "m".into(), kernel: false, imports: vec![], defs: vec![class_eq, ne], }; let diags = check_module(&m); assert!( diags.is_empty(), "expected no diagnostics but got {:#?}", diags ); } }