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        return_type: spec.return_type,
650        body: extracted_expr,
651        examples: Vec::new(),
652    });
653
654    Ok((modified, new_fn))
655}
656
657fn parse_node_id(id: &str) -> Result<Vec<usize>, TransformError> {
658    let s = id.strip_prefix("n_").ok_or_else(|| TransformError::BadNodeId(id.into()))?;
659    let mut parts = s.split('.');
660    let head = parts.next().ok_or_else(|| TransformError::BadNodeId(id.into()))?;
661    if head != "0" {
662        return Err(TransformError::BadNodeId(id.into()));
663    }
664    let mut out = Vec::new();
665    for p in parts {
666        out.push(p.parse::<usize>().map_err(|_| TransformError::BadNodeId(id.into()))?);
667    }
668    Ok(out)
669}
670
671/// Walk into an expression tree along a path of child indices and
672/// return a `&mut` to the resolved sub-expression. The indexing
673/// matches [`crate::ids::collect_ids`]'s walk order so a
674/// `NodeId` minted from `collect_ids` resolves to the same node
675/// here.
676fn navigate_to_expr<'a>(
677    root: &'a mut CExpr,
678    path: &[usize],
679    target_id: &str,
680) -> Result<&'a mut CExpr, TransformError> {
681    let mut current = root;
682    for &idx in path {
683        current = step_expr(current, idx)
684            .ok_or_else(|| TransformError::UnknownNode { at: target_id.into() })?;
685    }
686    Ok(current)
687}
688
689/// Single step into an expression's `idx`-th child, mirroring the
690/// walk order in `ids::walk_expr`. Returns `None` when the index
691/// is out of range or when the kind has no addressable children.
692fn step_expr(e: &mut CExpr, idx: usize) -> Option<&mut CExpr> {
693    match e {
694        CExpr::Call { callee, args } => {
695            if idx == 0 { return Some(callee); }
696            args.get_mut(idx - 1)
697        }
698        CExpr::Let { value, body, .. } => {
699            match idx {
700                0 => Some(value),
701                1 => Some(body),
702                _ => None,
703            }
704        }
705        CExpr::Match { scrutinee, arms } => {
706            if idx == 0 { return Some(scrutinee); }
707            // Arms are walked as Pattern then Body; only the body
708            // is reachable as a CExpr child. ids::walk_expr emits
709            // pattern + body for each arm, so arm i's body lives
710            // at child index (1 + 2*i + 1).
711            let arm_off = idx - 1;
712            if arm_off % 2 != 1 {
713                return None;
714            }
715            let arm_index = arm_off / 2;
716            arms.get_mut(arm_index).map(|a| &mut a.body)
717        }
718        CExpr::Block { statements, result } => {
719            if idx < statements.len() {
720                statements.get_mut(idx)
721            } else if idx == statements.len() {
722                Some(result)
723            } else {
724                None
725            }
726        }
727        CExpr::Constructor { args, .. } | CExpr::TupleLit { items: args, .. }
728        | CExpr::ListLit { items: args, .. } => args.get_mut(idx),
729        CExpr::RecordLit { fields } => fields.get_mut(idx).map(|f| &mut f.value),
730        CExpr::FieldAccess { value, .. } => if idx == 0 { Some(value) } else { None },
731        CExpr::Lambda { body, .. } => if idx == 0 { Some(body) } else { None },
732        CExpr::BinOp { lhs, rhs, .. } => match idx {
733            0 => Some(lhs), 1 => Some(rhs), _ => None,
734        },
735        CExpr::UnaryOp { expr, .. } => if idx == 0 { Some(expr) } else { None },
736        CExpr::Return { value } => if idx == 0 { Some(value) } else { None },
737        _ => None,
738    }
739}
740
741fn cexpr_kind(e: &CExpr) -> &'static str {
742    match e {
743        CExpr::Literal { .. } => "Literal",
744        CExpr::Var { .. } => "Var",
745        CExpr::Call { .. } => "Call",
746        CExpr::Let { .. } => "Let",
747        CExpr::Match { .. } => "Match",
748        CExpr::Block { .. } => "Block",
749        CExpr::Constructor { .. } => "Constructor",
750        CExpr::RecordLit { .. } => "RecordLit",
751        CExpr::TupleLit { .. } => "TupleLit",
752        CExpr::ListLit { .. } => "ListLit",
753        CExpr::FieldAccess { .. } => "FieldAccess",
754        CExpr::Lambda { .. } => "Lambda",
755        CExpr::BinOp { .. } => "BinOp",
756        CExpr::UnaryOp { .. } => "UnaryOp",
757        CExpr::Return { .. } => "Return",
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764    use crate::canonical::{Arm, CLit, FnDecl, Param, Pattern, TypeExpr};
765
766    // ---- rename_local fixtures + tests -----------------------------
767
768    /// `fn outer(n :: Int) -> Int { let x := n + 1; x + 2 }`
769    fn let_stage() -> Stage {
770        let body = CExpr::Let {
771            name: "x".into(),
772            ty: None,
773            value: Box::new(CExpr::BinOp {
774                op: "+".into(),
775                lhs: Box::new(CExpr::Var { name: "n".into() }),
776                rhs: Box::new(CExpr::Literal { value: CLit::Int { value: 1 } }),
777            }),
778            body: Box::new(CExpr::BinOp {
779                op: "+".into(),
780                lhs: Box::new(CExpr::Var { name: "x".into() }),
781                rhs: Box::new(CExpr::Literal { value: CLit::Int { value: 2 } }),
782            }),
783        };
784        Stage::FnDecl(FnDecl {
785            name: "outer".into(),
786            type_params: Vec::new(),
787            params: vec![Param {
788                name: "n".into(),
789                ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
790            }],
791            effects: Vec::new(),
792            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
793            body,
794            examples: Vec::new(),
795        })
796    }
797
798    fn let_node_id() -> NodeId { NodeId("n_0.2".into()) }
799
800    #[test]
801    fn rename_local_renames_binding_and_body_reference() {
802        let stage = let_stage();
803        let out = rename_local(&stage, &let_node_id(), "y").unwrap();
804        let Stage::FnDecl(fd) = out else { panic!() };
805        let CExpr::Let { name, value, body, .. } = fd.body else { panic!() };
806        assert_eq!(name, "y", "binding renamed");
807        // value untouched (no `x` ref in `n + 1`).
808        let CExpr::BinOp { lhs, .. } = *value else { panic!() };
809        assert!(matches!(*lhs, CExpr::Var { name: ref n } if n == "n"));
810        // Body's `x` ref renamed to `y`.
811        let CExpr::BinOp { lhs, .. } = *body else { panic!() };
812        assert!(matches!(*lhs, CExpr::Var { name: ref n } if n == "y"));
813    }
814
815    #[test]
816    fn rename_local_refuses_no_op() {
817        let stage = let_stage();
818        let err = rename_local(&stage, &let_node_id(), "x").unwrap_err();
819        assert!(matches!(err, TransformError::RenameNoOp { .. }));
820    }
821
822    #[test]
823    fn rename_local_respects_inner_let_shadowing() {
824        // `fn f() -> Int { let x := 1; let x := 2; x }`
825        // Renaming the OUTER `x` to `y` should leave both inner
826        // references unchanged because the inner let shadows.
827        let inner = CExpr::Let {
828            name: "x".into(),
829            ty: None,
830            value: Box::new(CExpr::Literal { value: CLit::Int { value: 2 } }),
831            body: Box::new(CExpr::Var { name: "x".into() }),
832        };
833        let body = CExpr::Let {
834            name: "x".into(),
835            ty: None,
836            value: Box::new(CExpr::Literal { value: CLit::Int { value: 1 } }),
837            body: Box::new(inner),
838        };
839        let stage = Stage::FnDecl(FnDecl {
840            name: "f".into(),
841            type_params: Vec::new(),
842            params: Vec::new(),
843            effects: Vec::new(),
844            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
845            body,
846            examples: Vec::new(),
847        });
848        let out = rename_local(&stage, &NodeId("n_0.1".into()), "y").unwrap();
849        let Stage::FnDecl(fd) = out else { panic!() };
850        let CExpr::Let { name: outer_name, body: outer_body, .. } = fd.body else { panic!() };
851        assert_eq!(outer_name, "y", "outer let renamed");
852        let CExpr::Let { name: inner_name, body: inner_body, .. } = *outer_body else { panic!() };
853        // Inner let shadows — its name stays "x".
854        assert_eq!(inner_name, "x");
855        // Inner body's `x` is the inner binding, not renamed.
856        assert!(matches!(*inner_body, CExpr::Var { name: ref n } if n == "x"));
857    }
858
859    #[test]
860    fn rename_local_respects_lambda_param_shadowing() {
861        // Body: `let x := 1; (\x. x)`. Lambda param shadows.
862        let lambda = CExpr::Lambda {
863            params: vec![Param {
864                name: "x".into(),
865                ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
866            }],
867            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
868            effects: Vec::new(),
869            body: Box::new(CExpr::Var { name: "x".into() }),
870        };
871        let body = CExpr::Let {
872            name: "x".into(),
873            ty: None,
874            value: Box::new(CExpr::Literal { value: CLit::Int { value: 1 } }),
875            body: Box::new(lambda),
876        };
877        let stage = Stage::FnDecl(FnDecl {
878            name: "f".into(),
879            type_params: Vec::new(),
880            params: Vec::new(),
881            effects: Vec::new(),
882            return_type: TypeExpr::Function {
883                params: vec![TypeExpr::Named { name: "Int".into(), args: Vec::new() }],
884                effects: Vec::new(),
885                ret: Box::new(TypeExpr::Named { name: "Int".into(), args: Vec::new() }),
886            },
887            body,
888            examples: Vec::new(),
889        });
890        let out = rename_local(&stage, &NodeId("n_0.1".into()), "y").unwrap();
891        let Stage::FnDecl(fd) = out else { panic!() };
892        let CExpr::Let { name, body: outer_body, .. } = fd.body else { panic!() };
893        assert_eq!(name, "y");
894        let CExpr::Lambda { body: lam_body, .. } = *outer_body else { panic!() };
895        // Lambda body's `x` is the param, not renamed.
896        assert!(matches!(*lam_body, CExpr::Var { name: ref n } if n == "x"));
897    }
898
899    #[test]
900    fn rename_local_respects_match_pattern_shadowing() {
901        // Body: `let x := 1; match foo { x => x, _ => x }`
902        // The first arm's pattern is `PVar { x }` — it binds x,
903        // shadowing the let; that arm's body's `x` is the pattern
904        // binding, not the let binding. Should not be renamed.
905        // The `_` wildcard does NOT shadow; its body's `x` is the
906        // let binding, should be renamed.
907        let match_expr = CExpr::Match {
908            scrutinee: Box::new(CExpr::Var { name: "foo".into() }),
909            arms: vec![
910                Arm {
911                    pattern: Pattern::PVar { name: "x".into() },
912                    body: CExpr::Var { name: "x".into() },
913                },
914                Arm {
915                    pattern: Pattern::PWild,
916                    body: CExpr::Var { name: "x".into() },
917                },
918            ],
919        };
920        let body = CExpr::Let {
921            name: "x".into(),
922            ty: None,
923            value: Box::new(CExpr::Literal { value: CLit::Int { value: 1 } }),
924            body: Box::new(match_expr),
925        };
926        let stage = Stage::FnDecl(FnDecl {
927            name: "f".into(),
928            type_params: Vec::new(),
929            params: Vec::new(),
930            effects: Vec::new(),
931            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
932            body,
933            examples: Vec::new(),
934        });
935        let out = rename_local(&stage, &NodeId("n_0.1".into()), "y").unwrap();
936        let Stage::FnDecl(fd) = out else { panic!() };
937        let CExpr::Let { body: outer_body, .. } = fd.body else { panic!() };
938        let CExpr::Match { arms, .. } = *outer_body else { panic!() };
939        // Arm 0 pattern binds x; arm body's `x` is the pattern, NOT renamed.
940        assert!(matches!(arms[0].body, CExpr::Var { name: ref n } if n == "x"));
941        // Arm 1 has wildcard; body's `x` is the let, renamed to `y`.
942        assert!(matches!(arms[1].body, CExpr::Var { name: ref n } if n == "y"));
943    }
944
945    #[test]
946    fn rename_local_not_a_let_errors() {
947        // Stage with a plain Match body (no Let). Targeting `n_0.2`
948        // should error with NotALet.
949        let stage = match_stage_with_two_arms();
950        let err = rename_local(&stage, &NodeId("n_0.2".into()), "y").unwrap_err();
951        assert!(matches!(err, TransformError::NotALet { found_kind: "Match", .. }),
952            "got {err:?}");
953    }
954
955    // ---- inline_let fixtures + tests -------------------------------
956
957    /// `fn f(n :: Int) -> Int { let x := 5; x + n }`
958    fn inlinable_stage() -> Stage {
959        let body = CExpr::Let {
960            name: "x".into(),
961            ty: None,
962            value: Box::new(CExpr::Literal { value: CLit::Int { value: 5 } }),
963            body: Box::new(CExpr::BinOp {
964                op: "+".into(),
965                lhs: Box::new(CExpr::Var { name: "x".into() }),
966                rhs: Box::new(CExpr::Var { name: "n".into() }),
967            }),
968        };
969        Stage::FnDecl(FnDecl {
970            name: "f".into(),
971            type_params: Vec::new(),
972            params: vec![Param {
973                name: "n".into(),
974                ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
975            }],
976            effects: Vec::new(),
977            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
978            body,
979            examples: Vec::new(),
980        })
981    }
982
983    #[test]
984    fn inline_let_substitutes_literal_value() {
985        let stage = inlinable_stage();
986        let out = inline_let(&stage, &NodeId("n_0.2".into())).unwrap();
987        let Stage::FnDecl(fd) = out else { panic!() };
988        // The Let is gone; body root is now `5 + n`.
989        let CExpr::BinOp { lhs, .. } = fd.body else { panic!() };
990        assert!(matches!(*lhs, CExpr::Literal { value: CLit::Int { value: 5 } }));
991    }
992
993    #[test]
994    fn inline_let_refuses_call_in_value() {
995        // `let x := f(); x`  →  refused, `f()` may have effects.
996        let body = CExpr::Let {
997            name: "x".into(),
998            ty: None,
999            value: Box::new(CExpr::Call {
1000                callee: Box::new(CExpr::Var { name: "f".into() }),
1001                args: Vec::new(),
1002            }),
1003            body: Box::new(CExpr::Var { name: "x".into() }),
1004        };
1005        let stage = Stage::FnDecl(FnDecl {
1006            name: "g".into(),
1007            type_params: Vec::new(),
1008            params: Vec::new(),
1009            effects: Vec::new(),
1010            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1011            body,
1012            examples: Vec::new(),
1013        });
1014        let err = inline_let(&stage, &NodeId("n_0.1".into())).unwrap_err();
1015        assert!(matches!(err, TransformError::InlineLetRefused { .. }), "got {err:?}");
1016    }
1017
1018    #[test]
1019    fn inline_let_refuses_capture() {
1020        // `let x := y; let y := 7; x + y`
1021        // Inlining `x` would capture `y`. Must refuse.
1022        let inner = CExpr::Let {
1023            name: "y".into(),
1024            ty: None,
1025            value: Box::new(CExpr::Literal { value: CLit::Int { value: 7 } }),
1026            body: Box::new(CExpr::BinOp {
1027                op: "+".into(),
1028                lhs: Box::new(CExpr::Var { name: "x".into() }),
1029                rhs: Box::new(CExpr::Var { name: "y".into() }),
1030            }),
1031        };
1032        let body = CExpr::Let {
1033            name: "x".into(),
1034            ty: None,
1035            value: Box::new(CExpr::Var { name: "y".into() }),
1036            body: Box::new(inner),
1037        };
1038        let stage = Stage::FnDecl(FnDecl {
1039            name: "g".into(),
1040            type_params: Vec::new(),
1041            params: vec![Param {
1042                name: "y".into(),
1043                ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1044            }],
1045            effects: Vec::new(),
1046            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1047            body,
1048            examples: Vec::new(),
1049        });
1050        // Let `x` is at child index 2 (1 param + return + body slot).
1051        let err = inline_let(&stage, &NodeId("n_0.2".into())).unwrap_err();
1052        assert!(matches!(err, TransformError::InlineLetRefused { .. }), "got {err:?}");
1053    }
1054
1055    #[test]
1056    fn inline_let_substitutes_under_shadowing() {
1057        // `let x := 5; let x := n; x + 1`
1058        // Inner let SHADOWS the outer `x`. Inlining the OUTER `x`
1059        // should affect nothing because the inner shadow cuts off
1060        // descent. The outer let is still eliminated.
1061        let inner = CExpr::Let {
1062            name: "x".into(),
1063            ty: None,
1064            value: Box::new(CExpr::Var { name: "n".into() }),
1065            body: Box::new(CExpr::BinOp {
1066                op: "+".into(),
1067                lhs: Box::new(CExpr::Var { name: "x".into() }),
1068                rhs: Box::new(CExpr::Literal { value: CLit::Int { value: 1 } }),
1069            }),
1070        };
1071        let body = CExpr::Let {
1072            name: "x".into(),
1073            ty: None,
1074            value: Box::new(CExpr::Literal { value: CLit::Int { value: 5 } }),
1075            body: Box::new(inner),
1076        };
1077        let stage = Stage::FnDecl(FnDecl {
1078            name: "g".into(),
1079            type_params: Vec::new(),
1080            params: vec![Param {
1081                name: "n".into(),
1082                ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1083            }],
1084            effects: Vec::new(),
1085            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1086            body,
1087            examples: Vec::new(),
1088        });
1089        let out = inline_let(&stage, &NodeId("n_0.2".into())).unwrap();
1090        let Stage::FnDecl(fd) = out else { panic!() };
1091        // Outer let removed; body is the inner Let.
1092        let CExpr::Let { name, .. } = fd.body else { panic!() };
1093        assert_eq!(name, "x", "inner let preserved");
1094    }
1095
1096    #[test]
1097    fn inline_let_not_a_let_target_errors() {
1098        let stage = match_stage_with_two_arms();
1099        let err = inline_let(&stage, &NodeId("n_0.2".into())).unwrap_err();
1100        assert!(matches!(err, TransformError::NotALet { found_kind: "Match", .. }));
1101    }
1102
1103    // ---- extract_function tests ------------------------------------
1104
1105    /// `fn caller(n :: Int, m :: Int) -> Int { (n * 2) + m }`
1106    /// We'll extract the `n * 2` sub-expression into a new fn.
1107    fn extract_stage() -> Stage {
1108        let body = CExpr::BinOp {
1109            op: "+".into(),
1110            lhs: Box::new(CExpr::BinOp {
1111                op: "*".into(),
1112                lhs: Box::new(CExpr::Var { name: "n".into() }),
1113                rhs: Box::new(CExpr::Literal { value: CLit::Int { value: 2 } }),
1114            }),
1115            rhs: Box::new(CExpr::Var { name: "m".into() }),
1116        };
1117        Stage::FnDecl(FnDecl {
1118            name: "caller".into(),
1119            type_params: Vec::new(),
1120            params: vec![
1121                Param {
1122                    name: "n".into(),
1123                    ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1124                },
1125                Param {
1126                    name: "m".into(),
1127                    ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1128                },
1129            ],
1130            effects: Vec::new(),
1131            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1132            body,
1133            examples: Vec::new(),
1134        })
1135    }
1136
1137    fn double_n_spec() -> ExtractFnSpec {
1138        ExtractFnSpec {
1139            name: "double_n".into(),
1140            type_params: Vec::new(),
1141            params: vec![Param {
1142                name: "n".into(),
1143                ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1144            }],
1145            effects: Vec::new(),
1146            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1147        }
1148    }
1149
1150    #[test]
1151    fn extract_function_replaces_subexpression_with_call() {
1152        // 2 params + return + body slot → body is at child 3.
1153        // The body is a BinOp; lhs (the `n * 2` sub-expr) is at
1154        // child 0 of the body. So target NodeId = `n_0.3.0`.
1155        let stage = extract_stage();
1156        let (modified, new_fn) = extract_function(
1157            &stage,
1158            &NodeId("n_0.3.0".into()),
1159            double_n_spec(),
1160        ).unwrap();
1161
1162        // Modified source: lhs is now `Call { Var(double_n), [Var(n)] }`.
1163        let Stage::FnDecl(fd) = modified else { panic!() };
1164        let CExpr::BinOp { lhs, .. } = fd.body else { panic!() };
1165        let CExpr::Call { callee, args } = *lhs else { panic!() };
1166        assert!(matches!(*callee, CExpr::Var { name: ref n } if n == "double_n"));
1167        assert_eq!(args.len(), 1);
1168        assert!(matches!(args[0], CExpr::Var { name: ref n } if n == "n"));
1169
1170        // New fn: name + params + body all match.
1171        let Stage::FnDecl(new_fd) = new_fn else { panic!() };
1172        assert_eq!(new_fd.name, "double_n");
1173        assert_eq!(new_fd.params.len(), 1);
1174        assert_eq!(new_fd.params[0].name, "n");
1175        // Body is the original `n * 2`.
1176        let CExpr::BinOp { op, lhs, rhs, .. } = new_fd.body else { panic!() };
1177        assert_eq!(op, "*");
1178        assert!(matches!(*lhs, CExpr::Var { name: ref n } if n == "n"));
1179        assert!(matches!(*rhs, CExpr::Literal { value: CLit::Int { value: 2 } }));
1180    }
1181
1182    #[test]
1183    fn extract_function_refuses_extra_params() {
1184        // Spec declares an extra param `z` not free in `n * 2`.
1185        let stage = extract_stage();
1186        let mut spec = double_n_spec();
1187        spec.params.push(Param {
1188            name: "z".into(),
1189            ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1190        });
1191        let err = extract_function(&stage, &NodeId("n_0.3.0".into()), spec).unwrap_err();
1192        assert!(matches!(err, TransformError::ExtractFnRefused { .. }), "got {err:?}");
1193    }
1194
1195    #[test]
1196    fn extract_function_refuses_missing_params() {
1197        // Spec omits `n`, which IS free in `n * 2`.
1198        let stage = extract_stage();
1199        let spec = ExtractFnSpec {
1200            name: "no_args".into(),
1201            type_params: Vec::new(),
1202            params: Vec::new(),
1203            effects: Vec::new(),
1204            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1205        };
1206        let err = extract_function(&stage, &NodeId("n_0.3.0".into()), spec).unwrap_err();
1207        assert!(matches!(err, TransformError::ExtractFnRefused { .. }), "got {err:?}");
1208    }
1209
1210    #[test]
1211    fn extract_function_handles_zero_free_vars() {
1212        // Body: `1 + 2`. Extract the LHS literal `1`. No free vars.
1213        let body = CExpr::BinOp {
1214            op: "+".into(),
1215            lhs: Box::new(CExpr::Literal { value: CLit::Int { value: 1 } }),
1216            rhs: Box::new(CExpr::Literal { value: CLit::Int { value: 2 } }),
1217        };
1218        let stage = Stage::FnDecl(FnDecl {
1219            name: "caller".into(),
1220            type_params: Vec::new(),
1221            params: Vec::new(),
1222            effects: Vec::new(),
1223            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1224            body,
1225            examples: Vec::new(),
1226        });
1227        let spec = ExtractFnSpec {
1228            name: "one".into(),
1229            type_params: Vec::new(),
1230            params: Vec::new(),
1231            effects: Vec::new(),
1232            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1233        };
1234        // 0 params + return + body = body at child 1; lhs at 1.0.
1235        let (modified, new_fn) = extract_function(
1236            &stage, &NodeId("n_0.1.0".into()), spec,
1237        ).unwrap();
1238        let Stage::FnDecl(fd) = modified else { panic!() };
1239        let CExpr::BinOp { lhs, .. } = fd.body else { panic!() };
1240        let CExpr::Call { args, .. } = *lhs else { panic!() };
1241        assert_eq!(args.len(), 0, "no args for zero-free-var extract");
1242        let Stage::FnDecl(new_fd) = new_fn else { panic!() };
1243        assert!(matches!(new_fd.body, CExpr::Literal { value: CLit::Int { value: 1 } }));
1244    }
1245
1246    #[test]
1247    fn extract_function_typedecl_target_errors() {
1248        let stage = Stage::TypeDecl(crate::canonical::TypeDecl {
1249            name: "T".into(),
1250            params: Vec::new(),
1251            definition: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1252        });
1253        let err = extract_function(&stage, &NodeId("n_0.0".into()), double_n_spec())
1254            .unwrap_err();
1255        assert!(matches!(err, TransformError::NonFnTarget { stage_kind: "TypeDecl" }));
1256    }
1257
1258    // ---- replace_match_arm fixtures + tests ------------------------
1259
1260    fn match_stage_with_two_arms() -> Stage {
1261        // fn pick(n :: Int) -> Int { match n { 0 => 1, _ => 2 } }
1262        // Two arms: pattern-literal-0 → 1; wildcard → 2.
1263        let body = CExpr::Match {
1264            scrutinee: Box::new(CExpr::Var { name: "n".into() }),
1265            arms: vec![
1266                Arm {
1267                    pattern: Pattern::PLiteral { value: CLit::Int { value: 0 } },
1268                    body: CExpr::Literal { value: CLit::Int { value: 1 } },
1269                },
1270                Arm {
1271                    pattern: Pattern::PWild,
1272                    body: CExpr::Literal { value: CLit::Int { value: 2 } },
1273                },
1274            ],
1275        };
1276        Stage::FnDecl(FnDecl {
1277            name: "pick".into(),
1278            type_params: Vec::new(),
1279            params: vec![Param {
1280                name: "n".into(),
1281                ty: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1282            }],
1283            effects: Vec::new(),
1284            return_type: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1285            body,
1286            examples: Vec::new(),
1287        })
1288    }
1289
1290    fn match_node_id() -> NodeId {
1291        // FnDecl with 1 param: body is at child index 2 (param 0,
1292        // return_type at 1, body at 2). The body IS the Match
1293        // expression — so the match node is at `n_0.2`.
1294        NodeId("n_0.2".into())
1295    }
1296
1297    #[test]
1298    fn replace_first_arm_body_succeeds() {
1299        let stage = match_stage_with_two_arms();
1300        let new_body = CExpr::Literal { value: CLit::Int { value: 42 } };
1301        let out = replace_match_arm(&stage, &match_node_id(), 0, new_body).unwrap();
1302        let Stage::FnDecl(fd) = out else { panic!() };
1303        let CExpr::Match { arms, .. } = fd.body else { panic!() };
1304        assert_eq!(arms.len(), 2);
1305        assert!(matches!(arms[0].body, CExpr::Literal { value: CLit::Int { value: 42 } }));
1306        // Second arm untouched.
1307        assert!(matches!(arms[1].body, CExpr::Literal { value: CLit::Int { value: 2 } }));
1308        // Pattern preserved.
1309        assert!(matches!(arms[0].pattern, Pattern::PLiteral { .. }));
1310    }
1311
1312    #[test]
1313    fn replace_second_arm_preserves_first() {
1314        let stage = match_stage_with_two_arms();
1315        let new_body = CExpr::Literal { value: CLit::Int { value: 99 } };
1316        let out = replace_match_arm(&stage, &match_node_id(), 1, new_body).unwrap();
1317        let Stage::FnDecl(fd) = out else { panic!() };
1318        let CExpr::Match { arms, .. } = fd.body else { panic!() };
1319        assert!(matches!(arms[0].body, CExpr::Literal { value: CLit::Int { value: 1 } }));
1320        assert!(matches!(arms[1].body, CExpr::Literal { value: CLit::Int { value: 99 } }));
1321    }
1322
1323    #[test]
1324    fn arm_index_out_of_range_errors() {
1325        let stage = match_stage_with_two_arms();
1326        let new_body = CExpr::Literal { value: CLit::Unit };
1327        let err = replace_match_arm(&stage, &match_node_id(), 5, new_body).unwrap_err();
1328        assert!(matches!(err, TransformError::ArmIndexOutOfRange { arm_count: 2, requested: 5, .. }));
1329    }
1330
1331    #[test]
1332    fn non_match_target_errors() {
1333        // The body's scrutinee at n_0.2.0 is a Var, not a Match.
1334        let stage = match_stage_with_two_arms();
1335        let new_body = CExpr::Literal { value: CLit::Unit };
1336        let err = replace_match_arm(&stage, &NodeId("n_0.2.0".into()), 0, new_body)
1337            .unwrap_err();
1338        assert!(matches!(err, TransformError::NotAMatch { found_kind: "Var", .. }),
1339            "got {err:?}");
1340    }
1341
1342    #[test]
1343    fn unknown_node_errors() {
1344        let stage = match_stage_with_two_arms();
1345        let new_body = CExpr::Literal { value: CLit::Unit };
1346        let err = replace_match_arm(&stage, &NodeId("n_0.99".into()), 0, new_body)
1347            .unwrap_err();
1348        assert!(matches!(err, TransformError::UnknownNode { .. }), "got {err:?}");
1349    }
1350
1351    #[test]
1352    fn typedecl_target_errors() {
1353        let stage = Stage::TypeDecl(crate::canonical::TypeDecl {
1354            name: "T".into(),
1355            params: Vec::new(),
1356            definition: TypeExpr::Named { name: "Int".into(), args: Vec::new() },
1357        });
1358        let err = replace_match_arm(&stage, &match_node_id(), 0,
1359            CExpr::Literal { value: CLit::Unit }).unwrap_err();
1360        assert!(matches!(err, TransformError::NonFnTarget { stage_kind: "TypeDecl" }));
1361    }
1362}