Skip to main content

lex_ast/
transforms.rs

1//! Typed, valid-by-construction AST transforms (#280).
2//!
3//! The op log historically records body-shaping edits as opaque
4//! `ModifyBody { from_stage_id, to_stage_id, … }` — the bytes
5//! changed, but *what* changed is recoverable only by diffing the
6//! two stages. For agents writing Lex, that's the wrong primitive:
7//! they want to express "replace the body of arm 2 in this match"
8//! as a single typed operation, get back a valid AST, and have the
9//! op log record the transform's intent, not just its byte effect.
10//!
11//! Each transform in this module is a pure function:
12//!
13//!   `fn transform(stage: &Stage, params…) -> Result<Stage, TransformError>`
14//!
15//! No I/O, no LLM, no type checking — type checking happens *after*
16//! the transform in `lex-store`'s apply path, so a transform that
17//! produces an ill-typed AST surfaces as `StoreError::TypeError`
18//! rather than failing inside the transformer.
19//!
20//! # Shipped transforms
21//!
22//! - [`replace_match_arm`] — replaces the body of one arm in a
23//!   match expression. Pattern is preserved.
24//! - [`rename_local`] — renames a `let`-bound local. Walks the
25//!   binding's body scope and rewrites every unshadowed reference.
26//! - [`inline_let`] — eliminates a `let x := v; body` by
27//!   substituting `v` for every unshadowed `x` in `body`. The let
28//!   value is restricted to capture-free, side-effect-free
29//!   expressions (literals, vars, field access, binops on those).
30//! - [`extract_function`] — extracts a sub-expression from a fn
31//!   body into a new top-level function, replacing the original
32//!   site with a call. The agent provides the new fn's signature
33//!   (params, return type, effects); the transform verifies free
34//!   variables match the params.
35
36use crate::canonical::{
37    CExpr, Effect, FnDecl, Param, Pattern, Stage, TypeExpr,
38};
39use crate::ids::NodeId;
40
41#[derive(Debug, Clone, thiserror::Error, serde::Serialize, serde::Deserialize)]
42#[serde(tag = "kind", rename_all = "snake_case")]
43pub enum TransformError {
44    #[error("unknown node id `{at}`")]
45    UnknownNode { at: String },
46    #[error("expected a Match expression at `{at}` but found `{found_kind}`")]
47    NotAMatch { at: String, found_kind: &'static str },
48    #[error("expected a Let expression at `{at}` but found `{found_kind}`")]
49    NotALet { at: String, found_kind: &'static str },
50    #[error("arm index {requested} out of range (arm count = {arm_count}) at `{at}`")]
51    ArmIndexOutOfRange { at: String, arm_count: usize, requested: usize },
52    #[error("malformed NodeId `{0}`")]
53    BadNodeId(String),
54    #[error("cannot transform inside `{stage_kind}` — only FnDecl bodies are transformable")]
55    NonFnTarget { stage_kind: &'static str },
56    #[error("rename is a no-op: old and new name are both `{name}`")]
57    RenameNoOp { name: String },
58    #[error("inline_let refused: `{reason}`")]
59    InlineLetRefused { reason: String },
60    #[error("extract_function refused: `{reason}`")]
61    ExtractFnRefused { reason: String },
62}
63
64/// Specification of the new function that [`extract_function`]
65/// produces. The agent provides this — slice 4 doesn't infer
66/// types or effects from the surrounding context. The transform
67/// verifies the param set matches the extracted expression's free
68/// variables; the type-checker (downstream) catches mismatches in
69/// types, effects, and return type.
70#[derive(Debug, Clone, PartialEq)]
71pub struct ExtractFnSpec {
72    pub name: String,
73    pub type_params: Vec<String>,
74    pub params: Vec<Param>,
75    pub return_type: TypeExpr,
76    pub effects: Vec<Effect>,
77}
78
79/// Replace `match_node`'s arm at `arm_index` with a new body,
80/// preserving the arm's pattern (#280 slice 1).
81///
82/// `match_node` must address a `CExpr::Match` inside the FnDecl
83/// body of `stage`. The returned `Stage` is a clone with the one
84/// arm body replaced; everything else is untouched, so the
85/// resulting `StageId` differs from the input's only by the
86/// affected sub-tree.
87pub fn replace_match_arm(
88    stage: &Stage,
89    match_node: &NodeId,
90    arm_index: usize,
91    new_body: CExpr,
92) -> Result<Stage, TransformError> {
93    let mut out = stage.clone();
94    let (body, n_params) = match &mut out {
95        Stage::FnDecl(fd) => {
96            let n = fd.params.len();
97            (&mut fd.body, n)
98        }
99        Stage::TypeDecl(_) => return Err(TransformError::NonFnTarget { stage_kind: "TypeDecl" }),
100        Stage::Import(_) => return Err(TransformError::NonFnTarget { stage_kind: "Import" }),
101    };
102    let path = parse_node_id(match_node.as_str())?;
103    // First index identifies a child of the FnDecl: params at
104    // 0..n_params, return_type at n_params, body at n_params + 1.
105    // Only the body slot is reachable for this transform.
106    if path.is_empty() {
107        return Err(TransformError::NotAMatch {
108            at: match_node.as_str().into(),
109            found_kind: "stage_root",
110        });
111    }
112    if path[0] != n_params + 1 {
113        return Err(TransformError::UnknownNode { at: match_node.as_str().into() });
114    }
115    let inner = &path[1..];
116    let target = navigate_to_expr(body, inner, match_node.as_str())?;
117    let CExpr::Match { scrutinee: _, arms } = target else {
118        return Err(TransformError::NotAMatch {
119            at: match_node.as_str().into(),
120            found_kind: cexpr_kind(target),
121        });
122    };
123    if arm_index >= arms.len() {
124        return Err(TransformError::ArmIndexOutOfRange {
125            at: match_node.as_str().into(),
126            arm_count: arms.len(),
127            requested: arm_index,
128        });
129    }
130    arms[arm_index].body = new_body;
131    Ok(out)
132}
133
134/// Rename a `let`-bound local variable. Walks the binding's body
135/// scope and rewrites every unshadowed reference to the old name.
136/// Pure function — produces a new `Stage` with the rename applied.
137///
138/// Scope semantics:
139/// - The let's `value` is evaluated in the outer scope; references
140///   to the old name there belong to whatever bound it before.
141///   We rewrite the `value` only if a renamed outer binding would
142///   reach it — which it can't, since `let` in Lex is
143///   non-recursive. So we leave `value` untouched.
144/// - The let's `body` sees the renamed binding. Shadowing in `body`
145///   (a nested `Let` / `Lambda` param / `Match` pattern that
146///   re-binds the *old* name) cuts off renaming inside the
147///   shadowed sub-tree.
148///
149/// Refuses when `old_name == new_name`. Doesn't otherwise check for
150/// name clashes — if the new name is already bound in scope, the
151/// downstream type-checker surfaces it as a `TypeError`.
152pub fn rename_local(
153    stage: &Stage,
154    let_node: &NodeId,
155    new_name: &str,
156) -> Result<Stage, TransformError> {
157    let mut out = stage.clone();
158    let (body, n_params) = match &mut out {
159        Stage::FnDecl(fd) => {
160            let n = fd.params.len();
161            (&mut fd.body, n)
162        }
163        Stage::TypeDecl(_) => return Err(TransformError::NonFnTarget { stage_kind: "TypeDecl" }),
164        Stage::Import(_) => return Err(TransformError::NonFnTarget { stage_kind: "Import" }),
165    };
166    let path = parse_node_id(let_node.as_str())?;
167    if path.is_empty() {
168        return Err(TransformError::NotALet {
169            at: let_node.as_str().into(),
170            found_kind: "stage_root",
171        });
172    }
173    if path[0] != n_params + 1 {
174        return Err(TransformError::UnknownNode { at: let_node.as_str().into() });
175    }
176    let inner = &path[1..];
177    let target = navigate_to_expr(body, inner, let_node.as_str())?;
178    let CExpr::Let { name, body: let_body, .. } = target else {
179        return Err(TransformError::NotALet {
180            at: let_node.as_str().into(),
181            found_kind: cexpr_kind(target),
182        });
183    };
184    if name == new_name {
185        return Err(TransformError::RenameNoOp { name: name.clone() });
186    }
187    let old_name = std::mem::replace(name, new_name.to_string());
188    // Walk the body scope. Stop renaming at shadow points.
189    rewrite_var_in_expr(let_body, &old_name, new_name);
190    Ok(out)
191}
192
193/// Inline a `let x := v; body` by substituting `v` for every
194/// unshadowed occurrence of `x` in `body`, then replacing the
195/// entire `Let` node with the substituted `body`.
196///
197/// Restrictions (slice 3):
198/// - `v` must be capture-free, side-effect-free, and cheap to
199///   duplicate: only `Literal`, `Var`, `FieldAccess`, and `BinOp`/
200///   `UnaryOp` trees over those primitives are accepted. Calls,
201///   lambdas, blocks, lets, matches in `v` are refused with
202///   `InlineLetRefused` so a future slice can lift the restriction
203///   without changing the error contract.
204/// - None of `v`'s free variables may be re-bound (shadowed)
205///   anywhere in `body`. Inlining `let x := y; let y := …; x` is
206///   refused because the inner `let y` would capture the inlined
207///   `y` and silently change semantics.
208///
209/// Refuses no-ops at the API boundary: a `let x := v; x` body
210/// (single direct reference) is fine; a `let x := v; { }` body
211/// with zero references is *not* refused — the let is still
212/// eliminated, which is the user-visible point of inlining.
213pub fn inline_let(
214    stage: &Stage,
215    let_node: &NodeId,
216) -> Result<Stage, TransformError> {
217    let mut out = stage.clone();
218    let (body, n_params) = match &mut out {
219        Stage::FnDecl(fd) => {
220            let n = fd.params.len();
221            (&mut fd.body, n)
222        }
223        Stage::TypeDecl(_) => return Err(TransformError::NonFnTarget { stage_kind: "TypeDecl" }),
224        Stage::Import(_) => return Err(TransformError::NonFnTarget { stage_kind: "Import" }),
225    };
226    let path = parse_node_id(let_node.as_str())?;
227    if path.is_empty() {
228        return Err(TransformError::NotALet {
229            at: let_node.as_str().into(),
230            found_kind: "stage_root",
231        });
232    }
233    if path[0] != n_params + 1 {
234        return Err(TransformError::UnknownNode { at: let_node.as_str().into() });
235    }
236    let inner = &path[1..];
237    // Special case: when the path is `n_<n+1>` exactly (no further
238    // descent), the target is the FnDecl's body root. We swap it
239    // for the inlined body wholesale.
240    if inner.is_empty() {
241        let CExpr::Let { name, value, body: let_body, .. } = body.clone() else {
242            return Err(TransformError::NotALet {
243                at: let_node.as_str().into(),
244                found_kind: cexpr_kind(body),
245            });
246        };
247        check_inlinable(&value)?;
248        let captures = free_vars(&value);
249        check_no_capture(&let_body, &captures)?;
250        let mut replaced = *let_body;
251        substitute_in_expr(&mut replaced, &name, &value);
252        *body = replaced;
253        return Ok(out);
254    }
255    // Otherwise descend to the parent of the let node, capture the
256    // let's owned contents, and splice the inlined body into its
257    // slot. We re-use `navigate_to_expr` to reach the let, clone
258    // its parts, then mutate the slot in place.
259    let target = navigate_to_expr(body, inner, let_node.as_str())?;
260    let CExpr::Let { name, value, body: let_body, .. } = target.clone() else {
261        return Err(TransformError::NotALet {
262            at: let_node.as_str().into(),
263            found_kind: cexpr_kind(target),
264        });
265    };
266    check_inlinable(&value)?;
267    let captures = free_vars(&value);
268    check_no_capture(&let_body, &captures)?;
269    let mut replaced = *let_body;
270    substitute_in_expr(&mut replaced, &name, &value);
271    *target = replaced;
272    Ok(out)
273}
274
275/// True when `v` is restricted to the slice-3 inline-safe shape:
276/// literals, vars, field accesses, binops/unaryops on those. No
277/// calls, lambdas, blocks, lets, matches, constructors, record
278/// literals, or returns.
279fn check_inlinable(v: &CExpr) -> Result<(), TransformError> {
280    match v {
281        CExpr::Literal { .. } | CExpr::Var { .. } => Ok(()),
282        CExpr::FieldAccess { value, .. } => check_inlinable(value),
283        CExpr::BinOp { lhs, rhs, .. } => {
284            check_inlinable(lhs)?;
285            check_inlinable(rhs)
286        }
287        CExpr::UnaryOp { expr, .. } => check_inlinable(expr),
288        CExpr::TupleLit { items } | CExpr::ListLit { items } => {
289            for it in items { check_inlinable(it)?; }
290            Ok(())
291        }
292        other => Err(TransformError::InlineLetRefused {
293            reason: format!(
294                "let value contains a `{}` expression; slice 3 only inlines literal/var/field/binop/unaryop/tuple/list trees",
295                cexpr_kind(other)
296            ),
297        }),
298    }
299}
300
301/// Collect the set of variable names that appear free in `v`.
302/// Order-independent (`BTreeSet` for determinism in tests).
303fn free_vars(v: &CExpr) -> std::collections::BTreeSet<String> {
304    let mut out = std::collections::BTreeSet::new();
305    collect_free_vars(v, &mut out);
306    out
307}
308
309fn collect_free_vars(e: &CExpr, out: &mut std::collections::BTreeSet<String>) {
310    match e {
311        CExpr::Var { name } => { out.insert(name.clone()); }
312        CExpr::Literal { .. } => {}
313        CExpr::Call { callee, args } => {
314            collect_free_vars(callee, out);
315            for a in args { collect_free_vars(a, out); }
316        }
317        CExpr::Let { value, body, name, .. } => {
318            collect_free_vars(value, out);
319            // Body sees the let binding. We approximate: collect
320            // body's free vars, then remove the let's name.
321            let mut inner = std::collections::BTreeSet::new();
322            collect_free_vars(body, &mut inner);
323            inner.remove(name);
324            out.extend(inner);
325        }
326        CExpr::Match { scrutinee, arms } => {
327            collect_free_vars(scrutinee, out);
328            for arm in arms {
329                let mut inner = std::collections::BTreeSet::new();
330                collect_free_vars(&arm.body, &mut inner);
331                let bound = pattern_bindings(&arm.pattern);
332                for b in bound { inner.remove(&b); }
333                out.extend(inner);
334            }
335        }
336        CExpr::Block { statements, result } => {
337            for s in statements { collect_free_vars(s, out); }
338            collect_free_vars(result, out);
339        }
340        CExpr::Constructor { args, .. } => {
341            for a in args { collect_free_vars(a, out); }
342        }
343        CExpr::RecordLit { fields } => {
344            for f in fields { collect_free_vars(&f.value, out); }
345        }
346        CExpr::TupleLit { items } | CExpr::ListLit { items } => {
347            for i in items { collect_free_vars(i, out); }
348        }
349        CExpr::FieldAccess { value, .. } => collect_free_vars(value, out),
350        CExpr::Lambda { params, body, .. } => {
351            let mut inner = std::collections::BTreeSet::new();
352            collect_free_vars(body, &mut inner);
353            for p in params { inner.remove(&p.name); }
354            out.extend(inner);
355        }
356        CExpr::BinOp { lhs, rhs, .. } => {
357            collect_free_vars(lhs, out);
358            collect_free_vars(rhs, out);
359        }
360        CExpr::UnaryOp { expr, .. } => collect_free_vars(expr, out),
361        CExpr::Return { value } => collect_free_vars(value, out),
362    }
363}
364
365fn pattern_bindings(p: &Pattern) -> Vec<String> {
366    let mut out = Vec::new();
367    collect_pattern_bindings(p, &mut out);
368    out
369}
370
371fn collect_pattern_bindings(p: &Pattern, out: &mut Vec<String>) {
372    match p {
373        Pattern::PVar { name } => out.push(name.clone()),
374        Pattern::PLiteral { .. } | Pattern::PWild => {}
375        Pattern::PConstructor { args, .. } => for p in args { collect_pattern_bindings(p, out); }
376        Pattern::PRecord { fields } => for f in fields { collect_pattern_bindings(&f.pattern, out); }
377        Pattern::PTuple { items } => for p in items { collect_pattern_bindings(p, out); }
378    }
379}
380
381/// Walk `body`; if any sub-tree introduces a binder whose name is
382/// in `captures`, refuse the inline. We don't need to track scope
383/// here — any shadow at any depth is a problem, because once we
384/// inline, that shadow would capture a name in the substituted
385/// value.
386fn check_no_capture(
387    body: &CExpr,
388    captures: &std::collections::BTreeSet<String>,
389) -> Result<(), TransformError> {
390    let mut conflict: Option<String> = None;
391    walk_binders(body, &mut |name| {
392        if captures.contains(name) && conflict.is_none() {
393            conflict = Some(name.to_string());
394        }
395    });
396    if let Some(name) = conflict {
397        return Err(TransformError::InlineLetRefused {
398            reason: format!(
399                "value's free var `{name}` is re-bound in the body; inlining would capture"
400            ),
401        });
402    }
403    Ok(())
404}
405
406fn walk_binders(e: &CExpr, on_binder: &mut dyn FnMut(&str)) {
407    match e {
408        CExpr::Let { name, value, body, .. } => {
409            on_binder(name);
410            walk_binders(value, on_binder);
411            walk_binders(body, on_binder);
412        }
413        CExpr::Lambda { params, body, .. } => {
414            for p in params { on_binder(&p.name); }
415            walk_binders(body, on_binder);
416        }
417        CExpr::Match { scrutinee, arms } => {
418            walk_binders(scrutinee, on_binder);
419            for arm in arms {
420                for b in pattern_bindings(&arm.pattern) { on_binder(&b); }
421                walk_binders(&arm.body, on_binder);
422            }
423        }
424        CExpr::Call { callee, args } => {
425            walk_binders(callee, on_binder);
426            for a in args { walk_binders(a, on_binder); }
427        }
428        CExpr::Block { statements, result } => {
429            for s in statements { walk_binders(s, on_binder); }
430            walk_binders(result, on_binder);
431        }
432        CExpr::Constructor { args, .. } => for a in args { walk_binders(a, on_binder); }
433        CExpr::RecordLit { fields } => for f in fields { walk_binders(&f.value, on_binder); }
434        CExpr::TupleLit { items } | CExpr::ListLit { items } => {
435            for i in items { walk_binders(i, on_binder); }
436        }
437        CExpr::FieldAccess { value, .. } => walk_binders(value, on_binder),
438        CExpr::BinOp { lhs, rhs, .. } => {
439            walk_binders(lhs, on_binder); walk_binders(rhs, on_binder);
440        }
441        CExpr::UnaryOp { expr, .. } => walk_binders(expr, on_binder),
442        CExpr::Return { value } => walk_binders(value, on_binder),
443        CExpr::Var { .. } | CExpr::Literal { .. } => {}
444    }
445}
446
447/// Substitute every unshadowed reference to `name` in `e` with a
448/// clone of `replacement`. Shadowing rules match `rewrite_var_in_expr`:
449/// inner `Let` / `Lambda param` / `Match pattern` that re-binds
450/// `name` cuts off descent.
451fn substitute_in_expr(e: &mut CExpr, name: &str, replacement: &CExpr) {
452    match e {
453        CExpr::Var { name: n } if n == name => {
454            *e = replacement.clone();
455        }
456        CExpr::Var { .. } | CExpr::Literal { .. } => {}
457        CExpr::Call { callee, args } => {
458            substitute_in_expr(callee, name, replacement);
459            for a in args { substitute_in_expr(a, name, replacement); }
460        }
461        CExpr::Let { name: binder, value, body, .. } => {
462            substitute_in_expr(value, name, replacement);
463            if binder != name {
464                substitute_in_expr(body, name, replacement);
465            }
466        }
467        CExpr::Match { scrutinee, arms } => {
468            substitute_in_expr(scrutinee, name, replacement);
469            for arm in arms {
470                if !pattern_binds(&arm.pattern, name) {
471                    substitute_in_expr(&mut arm.body, name, replacement);
472                }
473            }
474        }
475        CExpr::Block { statements, result } => {
476            for s in statements { substitute_in_expr(s, name, replacement); }
477            substitute_in_expr(result, name, replacement);
478        }
479        CExpr::Constructor { args, .. } => {
480            for a in args { substitute_in_expr(a, name, replacement); }
481        }
482        CExpr::RecordLit { fields } => {
483            for f in fields { substitute_in_expr(&mut f.value, name, replacement); }
484        }
485        CExpr::TupleLit { items } | CExpr::ListLit { items } => {
486            for i in items { substitute_in_expr(i, name, replacement); }
487        }
488        CExpr::FieldAccess { value, .. } => substitute_in_expr(value, name, replacement),
489        CExpr::Lambda { params, body, .. } => {
490            if !params.iter().any(|p| p.name == name) {
491                substitute_in_expr(body, name, replacement);
492            }
493        }
494        CExpr::BinOp { lhs, rhs, .. } => {
495            substitute_in_expr(lhs, name, replacement);
496            substitute_in_expr(rhs, name, replacement);
497        }
498        CExpr::UnaryOp { expr, .. } => substitute_in_expr(expr, name, replacement),
499        CExpr::Return { value } => substitute_in_expr(value, name, replacement),
500    }
501}
502
503fn rewrite_var_in_expr(e: &mut CExpr, old: &str, new: &str) {
504    match e {
505        CExpr::Var { name } => {
506            if name == old { *name = new.into(); }
507        }
508        CExpr::Literal { .. } => {}
509        CExpr::Call { callee, args } => {
510            rewrite_var_in_expr(callee, old, new);
511            for a in args { rewrite_var_in_expr(a, old, new); }
512        }
513        CExpr::Let { name, value, body, .. } => {
514            // `value` is evaluated in the outer scope — rewrite
515            // there.
516            rewrite_var_in_expr(value, old, new);
517            // `body` sees the new binding. If THIS let re-binds
518            // `old`, it shadows the rename target; stop descending.
519            if name != old {
520                rewrite_var_in_expr(body, old, new);
521            }
522        }
523        CExpr::Match { scrutinee, arms } => {
524            rewrite_var_in_expr(scrutinee, old, new);
525            for arm in arms {
526                if !pattern_binds(&arm.pattern, old) {
527                    rewrite_var_in_expr(&mut arm.body, old, new);
528                }
529            }
530        }
531        CExpr::Block { statements, result } => {
532            for s in statements { rewrite_var_in_expr(s, old, new); }
533            rewrite_var_in_expr(result, old, new);
534        }
535        CExpr::Constructor { args, .. } => {
536            for a in args { rewrite_var_in_expr(a, old, new); }
537        }
538        CExpr::RecordLit { fields } => {
539            for f in fields { rewrite_var_in_expr(&mut f.value, old, new); }
540        }
541        CExpr::TupleLit { items } | CExpr::ListLit { items } => {
542            for i in items { rewrite_var_in_expr(i, old, new); }
543        }
544        CExpr::FieldAccess { value, .. } => rewrite_var_in_expr(value, old, new),
545        CExpr::Lambda { params, body, .. } => {
546            // Lambda params shadow the outer binding.
547            if !params.iter().any(|p| p.name == old) {
548                rewrite_var_in_expr(body, old, new);
549            }
550        }
551        CExpr::BinOp { lhs, rhs, .. } => {
552            rewrite_var_in_expr(lhs, old, new);
553            rewrite_var_in_expr(rhs, old, new);
554        }
555        CExpr::UnaryOp { expr, .. } => rewrite_var_in_expr(expr, old, new),
556        CExpr::Return { value } => rewrite_var_in_expr(value, old, new),
557    }
558}
559
560fn pattern_binds(p: &Pattern, name: &str) -> bool {
561    match p {
562        Pattern::PVar { name: n } => n == name,
563        Pattern::PLiteral { .. } | Pattern::PWild => false,
564        Pattern::PConstructor { args, .. } => args.iter().any(|p| pattern_binds(p, name)),
565        Pattern::PRecord { fields } => fields.iter().any(|f| pattern_binds(&f.pattern, name)),
566        Pattern::PTuple { items } => items.iter().any(|p| pattern_binds(p, name)),
567    }
568}
569
570/// Extract a sub-expression from a fn body into a new top-level
571/// function (#280 slice 4). Returns `(modified_source, new_fn)`:
572/// the original stage with the extracted expression replaced by a
573/// call to the new fn, and the new fn itself as a fresh `Stage`.
574///
575/// `expr_node` is the [`NodeId`] of the expression to extract.
576/// `spec` describes the new fn's full signature; the transform
577/// verifies that `spec.params` covers exactly the free variables
578/// of the extracted expression — names must match. Type checking
579/// of the extracted body against the spec happens *after* the
580/// transform, in the apply path's re-typecheck.
581///
582/// The replacement call site uses the parameter order from
583/// `spec.params`: `f(a, b, c)` for `params = [a, b, c]`.
584pub fn extract_function(
585    stage: &Stage,
586    expr_node: &NodeId,
587    spec: ExtractFnSpec,
588) -> Result<(Stage, Stage), TransformError> {
589    // Locate + clone the expression to extract from a fresh stage
590    // copy; we need a separate mutable clone for the modification.
591    let mut modified = stage.clone();
592    let (body, n_params) = match &mut modified {
593        Stage::FnDecl(fd) => {
594            let n = fd.params.len();
595            (&mut fd.body, n)
596        }
597        Stage::TypeDecl(_) => return Err(TransformError::NonFnTarget { stage_kind: "TypeDecl" }),
598        Stage::Import(_) => return Err(TransformError::NonFnTarget { stage_kind: "Import" }),
599    };
600    let path = parse_node_id(expr_node.as_str())?;
601    if path.is_empty() {
602        return Err(TransformError::UnknownNode { at: expr_node.as_str().into() });
603    }
604    if path[0] != n_params + 1 {
605        return Err(TransformError::UnknownNode { at: expr_node.as_str().into() });
606    }
607    let inner = &path[1..];
608    let target = navigate_to_expr(body, inner, expr_node.as_str())?;
609
610    // Snapshot the expression we're about to extract. We need a
611    // value, not a reference, because we'll move it into the new
612    // fn's body.
613    let extracted_expr = target.clone();
614
615    // Free vars of the extracted expr must exactly match the
616    // declared param names. If the agent passed extra params or
617    // missed any, refuse — the agent did the analysis wrong.
618    let free = free_vars(&extracted_expr);
619    let declared: std::collections::BTreeSet<String> =
620        spec.params.iter().map(|p| p.name.clone()).collect();
621    if free != declared {
622        let only_in_free: Vec<&String> = free.difference(&declared).collect();
623        let only_in_declared: Vec<&String> = declared.difference(&free).collect();
624        return Err(TransformError::ExtractFnRefused {
625            reason: format!(
626                "free vars {free:?} differ from declared params {declared:?}: \
627                 missing {only_in_free:?}, extra {only_in_declared:?}"
628            ),
629        });
630    }
631
632    // Replace the extracted expr with a call to the new fn. Args
633    // are `Var { name }` for each declared param, in the spec's
634    // declared order.
635    let call = CExpr::Call {
636        callee: Box::new(CExpr::Var { name: spec.name.clone() }),
637        args: spec.params.iter()
638            .map(|p| CExpr::Var { name: p.name.clone() })
639            .collect(),
640    };
641    *target = call;
642
643    // Build the new fn stage from the extracted body + spec.
644    let new_fn = Stage::FnDecl(FnDecl {
645        name: spec.name,
646        type_params: spec.type_params,
647        params: spec.params,
648        effects: spec.effects,
649        // Extracted functions carry a closed (concrete) effect row.
650        effect_row_var: None,
651        return_type: spec.return_type,
652        body: extracted_expr,
653        examples: Vec::new(),
654    });
655
656    Ok((modified, new_fn))
657}
658
659fn parse_node_id(id: &str) -> Result<Vec<usize>, TransformError> {
660    let s = id.strip_prefix("n_").ok_or_else(|| TransformError::BadNodeId(id.into()))?;
661    let mut parts = s.split('.');
662    let head = parts.next().ok_or_else(|| TransformError::BadNodeId(id.into()))?;
663    if head != "0" {
664        return Err(TransformError::BadNodeId(id.into()));
665    }
666    let mut out = Vec::new();
667    for p in parts {
668        out.push(p.parse::<usize>().map_err(|_| TransformError::BadNodeId(id.into()))?);
669    }
670    Ok(out)
671}
672
673/// Walk into an expression tree along a path of child indices and
674/// return a `&mut` to the resolved sub-expression. The indexing
675/// matches [`crate::ids::collect_ids`]'s walk order so a
676/// `NodeId` minted from `collect_ids` resolves to the same node
677/// here.
678fn navigate_to_expr<'a>(
679    root: &'a mut CExpr,
680    path: &[usize],
681    target_id: &str,
682) -> Result<&'a mut CExpr, TransformError> {
683    let mut current = root;
684    for &idx in path {
685        current = step_expr(current, idx)
686            .ok_or_else(|| TransformError::UnknownNode { at: target_id.into() })?;
687    }
688    Ok(current)
689}
690
691/// Single step into an expression's `idx`-th child, mirroring the
692/// walk order in `ids::walk_expr`. Returns `None` when the index
693/// is out of range or when the kind has no addressable children.
694fn step_expr(e: &mut CExpr, idx: usize) -> Option<&mut CExpr> {
695    match e {
696        CExpr::Call { callee, args } => {
697            if idx == 0 { return Some(callee); }
698            args.get_mut(idx - 1)
699        }
700        CExpr::Let { value, body, .. } => {
701            match idx {
702                0 => Some(value),
703                1 => Some(body),
704                _ => None,
705            }
706        }
707        CExpr::Match { scrutinee, arms } => {
708            if idx == 0 { return Some(scrutinee); }
709            // Arms are walked as Pattern then Body; only the body
710            // is reachable as a CExpr child. ids::walk_expr emits
711            // pattern + body for each arm, so arm i's body lives
712            // at child index (1 + 2*i + 1).
713            let arm_off = idx - 1;
714            if arm_off % 2 != 1 {
715                return None;
716            }
717            let arm_index = arm_off / 2;
718            arms.get_mut(arm_index).map(|a| &mut a.body)
719        }
720        CExpr::Block { statements, result } => {
721            if idx < statements.len() {
722                statements.get_mut(idx)
723            } else if idx == statements.len() {
724                Some(result)
725            } else {
726                None
727            }
728        }
729        CExpr::Constructor { args, .. } | CExpr::TupleLit { items: args, .. }
730        | CExpr::ListLit { items: args, .. } => args.get_mut(idx),
731        CExpr::RecordLit { fields } => fields.get_mut(idx).map(|f| &mut f.value),
732        CExpr::FieldAccess { value, .. } => if idx == 0 { Some(value) } else { None },
733        CExpr::Lambda { body, .. } => if idx == 0 { Some(body) } else { None },
734        CExpr::BinOp { lhs, rhs, .. } => match idx {
735            0 => Some(lhs), 1 => Some(rhs), _ => None,
736        },
737        CExpr::UnaryOp { expr, .. } => if idx == 0 { Some(expr) } else { None },
738        CExpr::Return { value } => if idx == 0 { Some(value) } else { None },
739        _ => None,
740    }
741}
742
743fn cexpr_kind(e: &CExpr) -> &'static str {
744    match e {
745        CExpr::Literal { .. } => "Literal",
746        CExpr::Var { .. } => "Var",
747        CExpr::Call { .. } => "Call",
748        CExpr::Let { .. } => "Let",
749        CExpr::Match { .. } => "Match",
750        CExpr::Block { .. } => "Block",
751        CExpr::Constructor { .. } => "Constructor",
752        CExpr::RecordLit { .. } => "RecordLit",
753        CExpr::TupleLit { .. } => "TupleLit",
754        CExpr::ListLit { .. } => "ListLit",
755        CExpr::FieldAccess { .. } => "FieldAccess",
756        CExpr::Lambda { .. } => "Lambda",
757        CExpr::BinOp { .. } => "BinOp",
758        CExpr::UnaryOp { .. } => "UnaryOp",
759        CExpr::Return { .. } => "Return",
760    }
761}
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use crate::canonical::{Arm, CLit, FnDecl, Param, Pattern, TypeExpr};
767
768    // ---- rename_local fixtures + tests -----------------------------
769
770    /// `fn outer(n :: Int) -> Int { let x := n + 1; x + 2 }`
771    fn let_stage() -> Stage {
772        let body = CExpr::Let {
773            name: "x".into(),
774            ty: None,
775            value: Box::new(CExpr::BinOp {
776                op: "+".into(),
777                lhs: Box::new(CExpr::Var { name: "n".into() }),
778                rhs: Box::new(CExpr::Literal { value: CLit::Int { value: 1 } }),
779            }),
780            body: Box::new(CExpr::BinOp {
781                op: "+".into(),
782                lhs: Box::new(CExpr::Var { name: "x".into() }),
783                rhs: Box::new(CExpr::Literal { value: CLit::Int { value: 2 } }),
784            }),
785        };
786        Stage::FnDecl(FnDecl {
787            name: "outer".into(),
788            type_params: Vec::new(),
789            params: vec![Param {
790                name: "n".into(),
791                ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
792            }],
793            effects: Vec::new(),
794            effect_row_var: None,
795            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
796            body,
797            examples: Vec::new(),
798        })
799    }
800
801    fn let_node_id() -> NodeId { NodeId("n_0.2".into()) }
802
803    #[test]
804    fn rename_local_renames_binding_and_body_reference() {
805        let stage = let_stage();
806        let out = rename_local(&stage, &let_node_id(), "y").unwrap();
807        let Stage::FnDecl(fd) = out else { panic!() };
808        let CExpr::Let { name, value, body, .. } = fd.body else { panic!() };
809        assert_eq!(name, "y", "binding renamed");
810        // value untouched (no `x` ref in `n + 1`).
811        let CExpr::BinOp { lhs, .. } = *value else { panic!() };
812        assert!(matches!(*lhs, CExpr::Var { name: ref n } if n == "n"));
813        // Body's `x` ref renamed to `y`.
814        let CExpr::BinOp { lhs, .. } = *body else { panic!() };
815        assert!(matches!(*lhs, CExpr::Var { name: ref n } if n == "y"));
816    }
817
818    #[test]
819    fn rename_local_refuses_no_op() {
820        let stage = let_stage();
821        let err = rename_local(&stage, &let_node_id(), "x").unwrap_err();
822        assert!(matches!(err, TransformError::RenameNoOp { .. }));
823    }
824
825    #[test]
826    fn rename_local_respects_inner_let_shadowing() {
827        // `fn f() -> Int { let x := 1; let x := 2; x }`
828        // Renaming the OUTER `x` to `y` should leave both inner
829        // references unchanged because the inner let shadows.
830        let inner = CExpr::Let {
831            name: "x".into(),
832            ty: None,
833            value: Box::new(CExpr::Literal { value: CLit::Int { value: 2 } }),
834            body: Box::new(CExpr::Var { name: "x".into() }),
835        };
836        let body = CExpr::Let {
837            name: "x".into(),
838            ty: None,
839            value: Box::new(CExpr::Literal { value: CLit::Int { value: 1 } }),
840            body: Box::new(inner),
841        };
842        let stage = Stage::FnDecl(FnDecl {
843            name: "f".into(),
844            type_params: Vec::new(),
845            params: Vec::new(),
846            effects: Vec::new(),
847            effect_row_var: None,
848            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
849            body,
850            examples: Vec::new(),
851        });
852        let out = rename_local(&stage, &NodeId("n_0.1".into()), "y").unwrap();
853        let Stage::FnDecl(fd) = out else { panic!() };
854        let CExpr::Let { name: outer_name, body: outer_body, .. } = fd.body else { panic!() };
855        assert_eq!(outer_name, "y", "outer let renamed");
856        let CExpr::Let { name: inner_name, body: inner_body, .. } = *outer_body else { panic!() };
857        // Inner let shadows — its name stays "x".
858        assert_eq!(inner_name, "x");
859        // Inner body's `x` is the inner binding, not renamed.
860        assert!(matches!(*inner_body, CExpr::Var { name: ref n } if n == "x"));
861    }
862
863    #[test]
864    fn rename_local_respects_lambda_param_shadowing() {
865        // Body: `let x := 1; (\x. x)`. Lambda param shadows.
866        let lambda = CExpr::Lambda {
867            params: vec![Param {
868                name: "x".into(),
869                ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
870            }],
871            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
872            effects: Vec::new(),
873            effect_row_var: None,
874            body: Box::new(CExpr::Var { name: "x".into() }),
875        };
876        let body = CExpr::Let {
877            name: "x".into(),
878            ty: None,
879            value: Box::new(CExpr::Literal { value: CLit::Int { value: 1 } }),
880            body: Box::new(lambda),
881        };
882        let stage = Stage::FnDecl(FnDecl {
883            name: "f".into(),
884            type_params: Vec::new(),
885            params: Vec::new(),
886            effects: Vec::new(),
887            effect_row_var: None,
888            return_type: TypeExpr::Function {
889                params: vec![TypeExpr::Named { name: "Int".into(), args: Vec::new() }],
890                effects: Vec::new(),
891                effect_row_var: None,
892                ret: Box::new(TypeExpr::Named { name: "Int".into(), args: Vec::new() }),
893            },
894            body,
895            examples: Vec::new(),
896        });
897        let out = rename_local(&stage, &NodeId("n_0.1".into()), "y").unwrap();
898        let Stage::FnDecl(fd) = out else { panic!() };
899        let CExpr::Let { name, body: outer_body, .. } = fd.body else { panic!() };
900        assert_eq!(name, "y");
901        let CExpr::Lambda { body: lam_body, .. } = *outer_body else { panic!() };
902        // Lambda body's `x` is the param, not renamed.
903        assert!(matches!(*lam_body, CExpr::Var { name: ref n } if n == "x"));
904    }
905
906    #[test]
907    fn rename_local_respects_match_pattern_shadowing() {
908        // Body: `let x := 1; match foo { x => x, _ => x }`
909        // The first arm's pattern is `PVar { x }` — it binds x,
910        // shadowing the let; that arm's body's `x` is the pattern
911        // binding, not the let binding. Should not be renamed.
912        // The `_` wildcard does NOT shadow; its body's `x` is the
913        // let binding, should be renamed.
914        let match_expr = CExpr::Match {
915            scrutinee: Box::new(CExpr::Var { name: "foo".into() }),
916            arms: vec![
917                Arm {
918                    pattern: Pattern::PVar { name: "x".into() },
919                    body: CExpr::Var { name: "x".into() },
920                },
921                Arm {
922                    pattern: Pattern::PWild,
923                    body: CExpr::Var { name: "x".into() },
924                },
925            ],
926        };
927        let body = CExpr::Let {
928            name: "x".into(),
929            ty: None,
930            value: Box::new(CExpr::Literal { value: CLit::Int { value: 1 } }),
931            body: Box::new(match_expr),
932        };
933        let stage = Stage::FnDecl(FnDecl {
934            name: "f".into(),
935            type_params: Vec::new(),
936            params: Vec::new(),
937            effects: Vec::new(),
938            effect_row_var: None,
939            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
940            body,
941            examples: Vec::new(),
942        });
943        let out = rename_local(&stage, &NodeId("n_0.1".into()), "y").unwrap();
944        let Stage::FnDecl(fd) = out else { panic!() };
945        let CExpr::Let { body: outer_body, .. } = fd.body else { panic!() };
946        let CExpr::Match { arms, .. } = *outer_body else { panic!() };
947        // Arm 0 pattern binds x; arm body's `x` is the pattern, NOT renamed.
948        assert!(matches!(arms[0].body, CExpr::Var { name: ref n } if n == "x"));
949        // Arm 1 has wildcard; body's `x` is the let, renamed to `y`.
950        assert!(matches!(arms[1].body, CExpr::Var { name: ref n } if n == "y"));
951    }
952
953    #[test]
954    fn rename_local_not_a_let_errors() {
955        // Stage with a plain Match body (no Let). Targeting `n_0.2`
956        // should error with NotALet.
957        let stage = match_stage_with_two_arms();
958        let err = rename_local(&stage, &NodeId("n_0.2".into()), "y").unwrap_err();
959        assert!(matches!(err, TransformError::NotALet { found_kind: "Match", .. }),
960            "got {err:?}");
961    }
962
963    // ---- inline_let fixtures + tests -------------------------------
964
965    /// `fn f(n :: Int) -> Int { let x := 5; x + n }`
966    fn inlinable_stage() -> Stage {
967        let body = CExpr::Let {
968            name: "x".into(),
969            ty: None,
970            value: Box::new(CExpr::Literal { value: CLit::Int { value: 5 } }),
971            body: Box::new(CExpr::BinOp {
972                op: "+".into(),
973                lhs: Box::new(CExpr::Var { name: "x".into() }),
974                rhs: Box::new(CExpr::Var { name: "n".into() }),
975            }),
976        };
977        Stage::FnDecl(FnDecl {
978            name: "f".into(),
979            type_params: Vec::new(),
980            params: vec![Param {
981                name: "n".into(),
982                ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
983            }],
984            effects: Vec::new(),
985            effect_row_var: None,
986            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
987            body,
988            examples: Vec::new(),
989        })
990    }
991
992    #[test]
993    fn inline_let_substitutes_literal_value() {
994        let stage = inlinable_stage();
995        let out = inline_let(&stage, &NodeId("n_0.2".into())).unwrap();
996        let Stage::FnDecl(fd) = out else { panic!() };
997        // The Let is gone; body root is now `5 + n`.
998        let CExpr::BinOp { lhs, .. } = fd.body else { panic!() };
999        assert!(matches!(*lhs, CExpr::Literal { value: CLit::Int { value: 5 } }));
1000    }
1001
1002    #[test]
1003    fn inline_let_refuses_call_in_value() {
1004        // `let x := f(); x`  →  refused, `f()` may have effects.
1005        let body = CExpr::Let {
1006            name: "x".into(),
1007            ty: None,
1008            value: Box::new(CExpr::Call {
1009                callee: Box::new(CExpr::Var { name: "f".into() }),
1010                args: Vec::new(),
1011            }),
1012            body: Box::new(CExpr::Var { name: "x".into() }),
1013        };
1014        let stage = Stage::FnDecl(FnDecl {
1015            name: "g".into(),
1016            type_params: Vec::new(),
1017            params: Vec::new(),
1018            effects: Vec::new(),
1019            effect_row_var: None,
1020            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1021            body,
1022            examples: Vec::new(),
1023        });
1024        let err = inline_let(&stage, &NodeId("n_0.1".into())).unwrap_err();
1025        assert!(matches!(err, TransformError::InlineLetRefused { .. }), "got {err:?}");
1026    }
1027
1028    #[test]
1029    fn inline_let_refuses_capture() {
1030        // `let x := y; let y := 7; x + y`
1031        // Inlining `x` would capture `y`. Must refuse.
1032        let inner = CExpr::Let {
1033            name: "y".into(),
1034            ty: None,
1035            value: Box::new(CExpr::Literal { value: CLit::Int { value: 7 } }),
1036            body: Box::new(CExpr::BinOp {
1037                op: "+".into(),
1038                lhs: Box::new(CExpr::Var { name: "x".into() }),
1039                rhs: Box::new(CExpr::Var { name: "y".into() }),
1040            }),
1041        };
1042        let body = CExpr::Let {
1043            name: "x".into(),
1044            ty: None,
1045            value: Box::new(CExpr::Var { name: "y".into() }),
1046            body: Box::new(inner),
1047        };
1048        let stage = Stage::FnDecl(FnDecl {
1049            name: "g".into(),
1050            type_params: Vec::new(),
1051            params: vec![Param {
1052                name: "y".into(),
1053                ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1054            }],
1055            effects: Vec::new(),
1056            effect_row_var: None,
1057            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1058            body,
1059            examples: Vec::new(),
1060        });
1061        // Let `x` is at child index 2 (1 param + return + body slot).
1062        let err = inline_let(&stage, &NodeId("n_0.2".into())).unwrap_err();
1063        assert!(matches!(err, TransformError::InlineLetRefused { .. }), "got {err:?}");
1064    }
1065
1066    #[test]
1067    fn inline_let_substitutes_under_shadowing() {
1068        // `let x := 5; let x := n; x + 1`
1069        // Inner let SHADOWS the outer `x`. Inlining the OUTER `x`
1070        // should affect nothing because the inner shadow cuts off
1071        // descent. The outer let is still eliminated.
1072        let inner = CExpr::Let {
1073            name: "x".into(),
1074            ty: None,
1075            value: Box::new(CExpr::Var { name: "n".into() }),
1076            body: Box::new(CExpr::BinOp {
1077                op: "+".into(),
1078                lhs: Box::new(CExpr::Var { name: "x".into() }),
1079                rhs: Box::new(CExpr::Literal { value: CLit::Int { value: 1 } }),
1080            }),
1081        };
1082        let body = CExpr::Let {
1083            name: "x".into(),
1084            ty: None,
1085            value: Box::new(CExpr::Literal { value: CLit::Int { value: 5 } }),
1086            body: Box::new(inner),
1087        };
1088        let stage = Stage::FnDecl(FnDecl {
1089            name: "g".into(),
1090            type_params: Vec::new(),
1091            params: vec![Param {
1092                name: "n".into(),
1093                ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1094            }],
1095            effects: Vec::new(),
1096            effect_row_var: None,
1097            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1098            body,
1099            examples: Vec::new(),
1100        });
1101        let out = inline_let(&stage, &NodeId("n_0.2".into())).unwrap();
1102        let Stage::FnDecl(fd) = out else { panic!() };
1103        // Outer let removed; body is the inner Let.
1104        let CExpr::Let { name, .. } = fd.body else { panic!() };
1105        assert_eq!(name, "x", "inner let preserved");
1106    }
1107
1108    #[test]
1109    fn inline_let_not_a_let_target_errors() {
1110        let stage = match_stage_with_two_arms();
1111        let err = inline_let(&stage, &NodeId("n_0.2".into())).unwrap_err();
1112        assert!(matches!(err, TransformError::NotALet { found_kind: "Match", .. }));
1113    }
1114
1115    // ---- extract_function tests ------------------------------------
1116
1117    /// `fn caller(n :: Int, m :: Int) -> Int { (n * 2) + m }`
1118    /// We'll extract the `n * 2` sub-expression into a new fn.
1119    fn extract_stage() -> Stage {
1120        let body = CExpr::BinOp {
1121            op: "+".into(),
1122            lhs: Box::new(CExpr::BinOp {
1123                op: "*".into(),
1124                lhs: Box::new(CExpr::Var { name: "n".into() }),
1125                rhs: Box::new(CExpr::Literal { value: CLit::Int { value: 2 } }),
1126            }),
1127            rhs: Box::new(CExpr::Var { name: "m".into() }),
1128        };
1129        Stage::FnDecl(FnDecl {
1130            name: "caller".into(),
1131            type_params: Vec::new(),
1132            params: vec![
1133                Param {
1134                    name: "n".into(),
1135                    ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1136                },
1137                Param {
1138                    name: "m".into(),
1139                    ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1140                },
1141            ],
1142            effects: Vec::new(),
1143            effect_row_var: None,
1144            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1145            body,
1146            examples: Vec::new(),
1147        })
1148    }
1149
1150    fn double_n_spec() -> ExtractFnSpec {
1151        ExtractFnSpec {
1152            name: "double_n".into(),
1153            type_params: Vec::new(),
1154            params: vec![Param {
1155                name: "n".into(),
1156                ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1157            }],
1158            effects: Vec::new(),
1159            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1160        }
1161    }
1162
1163    #[test]
1164    fn extract_function_replaces_subexpression_with_call() {
1165        // 2 params + return + body slot → body is at child 3.
1166        // The body is a BinOp; lhs (the `n * 2` sub-expr) is at
1167        // child 0 of the body. So target NodeId = `n_0.3.0`.
1168        let stage = extract_stage();
1169        let (modified, new_fn) = extract_function(
1170            &stage,
1171            &NodeId("n_0.3.0".into()),
1172            double_n_spec(),
1173        ).unwrap();
1174
1175        // Modified source: lhs is now `Call { Var(double_n), [Var(n)] }`.
1176        let Stage::FnDecl(fd) = modified else { panic!() };
1177        let CExpr::BinOp { lhs, .. } = fd.body else { panic!() };
1178        let CExpr::Call { callee, args } = *lhs else { panic!() };
1179        assert!(matches!(*callee, CExpr::Var { name: ref n } if n == "double_n"));
1180        assert_eq!(args.len(), 1);
1181        assert!(matches!(args[0], CExpr::Var { name: ref n } if n == "n"));
1182
1183        // New fn: name + params + body all match.
1184        let Stage::FnDecl(new_fd) = new_fn else { panic!() };
1185        assert_eq!(new_fd.name, "double_n");
1186        assert_eq!(new_fd.params.len(), 1);
1187        assert_eq!(new_fd.params[0].name, "n");
1188        // Body is the original `n * 2`.
1189        let CExpr::BinOp { op, lhs, rhs, .. } = new_fd.body else { panic!() };
1190        assert_eq!(op, "*");
1191        assert!(matches!(*lhs, CExpr::Var { name: ref n } if n == "n"));
1192        assert!(matches!(*rhs, CExpr::Literal { value: CLit::Int { value: 2 } }));
1193    }
1194
1195    #[test]
1196    fn extract_function_refuses_extra_params() {
1197        // Spec declares an extra param `z` not free in `n * 2`.
1198        let stage = extract_stage();
1199        let mut spec = double_n_spec();
1200        spec.params.push(Param {
1201            name: "z".into(),
1202            ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1203        });
1204        let err = extract_function(&stage, &NodeId("n_0.3.0".into()), spec).unwrap_err();
1205        assert!(matches!(err, TransformError::ExtractFnRefused { .. }), "got {err:?}");
1206    }
1207
1208    #[test]
1209    fn extract_function_refuses_missing_params() {
1210        // Spec omits `n`, which IS free in `n * 2`.
1211        let stage = extract_stage();
1212        let spec = ExtractFnSpec {
1213            name: "no_args".into(),
1214            type_params: Vec::new(),
1215            params: Vec::new(),
1216            effects: Vec::new(),
1217            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1218        };
1219        let err = extract_function(&stage, &NodeId("n_0.3.0".into()), spec).unwrap_err();
1220        assert!(matches!(err, TransformError::ExtractFnRefused { .. }), "got {err:?}");
1221    }
1222
1223    #[test]
1224    fn extract_function_handles_zero_free_vars() {
1225        // Body: `1 + 2`. Extract the LHS literal `1`. No free vars.
1226        let body = CExpr::BinOp {
1227            op: "+".into(),
1228            lhs: Box::new(CExpr::Literal { value: CLit::Int { value: 1 } }),
1229            rhs: Box::new(CExpr::Literal { value: CLit::Int { value: 2 } }),
1230        };
1231        let stage = Stage::FnDecl(FnDecl {
1232            name: "caller".into(),
1233            type_params: Vec::new(),
1234            params: Vec::new(),
1235            effects: Vec::new(),
1236            effect_row_var: None,
1237            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1238            body,
1239            examples: Vec::new(),
1240        });
1241        let spec = ExtractFnSpec {
1242            name: "one".into(),
1243            type_params: Vec::new(),
1244            params: Vec::new(),
1245            effects: Vec::new(),
1246            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1247        };
1248        // 0 params + return + body = body at child 1; lhs at 1.0.
1249        let (modified, new_fn) = extract_function(
1250            &stage, &NodeId("n_0.1.0".into()), spec,
1251        ).unwrap();
1252        let Stage::FnDecl(fd) = modified else { panic!() };
1253        let CExpr::BinOp { lhs, .. } = fd.body else { panic!() };
1254        let CExpr::Call { args, .. } = *lhs else { panic!() };
1255        assert_eq!(args.len(), 0, "no args for zero-free-var extract");
1256        let Stage::FnDecl(new_fd) = new_fn else { panic!() };
1257        assert!(matches!(new_fd.body, CExpr::Literal { value: CLit::Int { value: 1 } }));
1258    }
1259
1260    #[test]
1261    fn extract_function_typedecl_target_errors() {
1262        let stage = Stage::TypeDecl(crate::canonical::TypeDecl {
1263            name: "T".into(),
1264            params: Vec::new(),
1265            definition: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1266        });
1267        let err = extract_function(&stage, &NodeId("n_0.0".into()), double_n_spec())
1268            .unwrap_err();
1269        assert!(matches!(err, TransformError::NonFnTarget { stage_kind: "TypeDecl" }));
1270    }
1271
1272    // ---- replace_match_arm fixtures + tests ------------------------
1273
1274    fn match_stage_with_two_arms() -> Stage {
1275        // fn pick(n :: Int) -> Int { match n { 0 => 1, _ => 2 } }
1276        // Two arms: pattern-literal-0 → 1; wildcard → 2.
1277        let body = CExpr::Match {
1278            scrutinee: Box::new(CExpr::Var { name: "n".into() }),
1279            arms: vec![
1280                Arm {
1281                    pattern: Pattern::PLiteral { value: CLit::Int { value: 0 } },
1282                    body: CExpr::Literal { value: CLit::Int { value: 1 } },
1283                },
1284                Arm {
1285                    pattern: Pattern::PWild,
1286                    body: CExpr::Literal { value: CLit::Int { value: 2 } },
1287                },
1288            ],
1289        };
1290        Stage::FnDecl(FnDecl {
1291            name: "pick".into(),
1292            type_params: Vec::new(),
1293            params: vec![Param {
1294                name: "n".into(),
1295                ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1296            }],
1297            effects: Vec::new(),
1298            effect_row_var: None,
1299            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1300            body,
1301            examples: Vec::new(),
1302        })
1303    }
1304
1305    fn match_node_id() -> NodeId {
1306        // FnDecl with 1 param: body is at child index 2 (param 0,
1307        // return_type at 1, body at 2). The body IS the Match
1308        // expression — so the match node is at `n_0.2`.
1309        NodeId("n_0.2".into())
1310    }
1311
1312    #[test]
1313    fn replace_first_arm_body_succeeds() {
1314        let stage = match_stage_with_two_arms();
1315        let new_body = CExpr::Literal { value: CLit::Int { value: 42 } };
1316        let out = replace_match_arm(&stage, &match_node_id(), 0, new_body).unwrap();
1317        let Stage::FnDecl(fd) = out else { panic!() };
1318        let CExpr::Match { arms, .. } = fd.body else { panic!() };
1319        assert_eq!(arms.len(), 2);
1320        assert!(matches!(arms[0].body, CExpr::Literal { value: CLit::Int { value: 42 } }));
1321        // Second arm untouched.
1322        assert!(matches!(arms[1].body, CExpr::Literal { value: CLit::Int { value: 2 } }));
1323        // Pattern preserved.
1324        assert!(matches!(arms[0].pattern, Pattern::PLiteral { .. }));
1325    }
1326
1327    #[test]
1328    fn replace_second_arm_preserves_first() {
1329        let stage = match_stage_with_two_arms();
1330        let new_body = CExpr::Literal { value: CLit::Int { value: 99 } };
1331        let out = replace_match_arm(&stage, &match_node_id(), 1, new_body).unwrap();
1332        let Stage::FnDecl(fd) = out else { panic!() };
1333        let CExpr::Match { arms, .. } = fd.body else { panic!() };
1334        assert!(matches!(arms[0].body, CExpr::Literal { value: CLit::Int { value: 1 } }));
1335        assert!(matches!(arms[1].body, CExpr::Literal { value: CLit::Int { value: 99 } }));
1336    }
1337
1338    #[test]
1339    fn arm_index_out_of_range_errors() {
1340        let stage = match_stage_with_two_arms();
1341        let new_body = CExpr::Literal { value: CLit::Unit };
1342        let err = replace_match_arm(&stage, &match_node_id(), 5, new_body).unwrap_err();
1343        assert!(matches!(err, TransformError::ArmIndexOutOfRange { arm_count: 2, requested: 5, .. }));
1344    }
1345
1346    #[test]
1347    fn non_match_target_errors() {
1348        // The body's scrutinee at n_0.2.0 is a Var, not a Match.
1349        let stage = match_stage_with_two_arms();
1350        let new_body = CExpr::Literal { value: CLit::Unit };
1351        let err = replace_match_arm(&stage, &NodeId("n_0.2.0".into()), 0, new_body)
1352            .unwrap_err();
1353        assert!(matches!(err, TransformError::NotAMatch { found_kind: "Var", .. }),
1354            "got {err:?}");
1355    }
1356
1357    #[test]
1358    fn unknown_node_errors() {
1359        let stage = match_stage_with_two_arms();
1360        let new_body = CExpr::Literal { value: CLit::Unit };
1361        let err = replace_match_arm(&stage, &NodeId("n_0.99".into()), 0, new_body)
1362            .unwrap_err();
1363        assert!(matches!(err, TransformError::UnknownNode { .. }), "got {err:?}");
1364    }
1365
1366    #[test]
1367    fn typedecl_target_errors() {
1368        let stage = Stage::TypeDecl(crate::canonical::TypeDecl {
1369            name: "T".into(),
1370            params: Vec::new(),
1371            definition: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1372        });
1373        let err = replace_match_arm(&stage, &match_node_id(), 0,
1374            CExpr::Literal { value: CLit::Unit }).unwrap_err();
1375        assert!(matches!(err, TransformError::NonFnTarget { stage_kind: "TypeDecl" }));
1376    }
1377}