Skip to main content

mq_lang/
optimizer.rs

1use std::sync::OnceLock;
2
3use rustc_hash::{FxHashMap, FxHashSet};
4use smallvec::SmallVec;
5
6use crate::{
7    Ident, IdentWithToken, Shared,
8    ast::{
9        Program, TokenId,
10        node::{self as ast, Args, Branches, Literal, MatchArm, MatchArms, Params, Pattern, StringSegment},
11    },
12    selector::Selector,
13};
14
15/// SmallVec-backed `(Ident, Literal)` map used during let-literal propagation.
16type LiteralEnv = SmallVec<[(Ident, Literal); 8]>;
17
18fn env_get(env: &LiteralEnv, key: Ident) -> Option<&Literal> {
19    env.iter().rev().find(|(k, _)| *k == key).map(|(_, v)| v)
20}
21
22fn env_insert(env: &mut LiteralEnv, key: Ident, val: Literal) {
23    if let Some(entry) = env.iter_mut().find(|(k, _)| *k == key) {
24        entry.1 = val;
25    } else {
26        env.push((key, val));
27    }
28}
29
30fn env_remove(env: &mut LiteralEnv, key: Ident) {
31    env.retain(|(k, _)| *k != key);
32}
33
34/// Pointer-equality helper that works for both `Rc` and `Arc` (the `sync` feature).
35#[inline]
36fn ptr_eq<T: ?Sized>(a: &Shared<T>, b: &Shared<T>) -> bool {
37    #[cfg(not(feature = "sync"))]
38    {
39        std::rc::Rc::ptr_eq(a, b)
40    }
41    #[cfg(feature = "sync")]
42    {
43        std::sync::Arc::ptr_eq(a, b)
44    }
45}
46
47/// Controls which optimization passes are applied by the [`Optimizer`].
48///
49/// - `None` (default): no transformations; the AST is returned unchanged.
50/// - `Basic`: constant folding, dead-branch elimination, and selector-chain merging.
51/// - `Full`: all passes — `Basic` plus let-literal propagation, function inlining, and tail-call optimization.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum OptimizationLevel {
54    #[default]
55    None,
56    Basic,
57    Full,
58}
59
60/// Map `program` through `f` without allocating a new `Vec` when no node changes.
61///
62/// If every node is pointer-equal after `f`, the original `program` is returned as-is.
63/// Only when a changed node is encountered does a new `Vec` get allocated, pre-seeded with
64/// the unchanged prefix already seen.
65fn lazy_map_program<F>(program: Program, mut f: F) -> Program
66where
67    F: FnMut(&Shared<ast::Node>) -> Shared<ast::Node>,
68{
69    let mut result: Option<Vec<Shared<ast::Node>>> = None;
70    for (i, node) in program.iter().enumerate() {
71        let opt = f(node);
72        if !ptr_eq(&opt, node) {
73            if result.is_none() {
74                let mut r = Vec::with_capacity(program.len());
75                r.extend(program[..i].iter().cloned());
76                result = Some(r);
77            }
78            result.as_mut().unwrap().push(opt);
79        } else if let Some(ref mut r) = result {
80            r.push(Shared::clone(node));
81        }
82    }
83    result.unwrap_or(program)
84}
85
86/// AST optimizer that applies safe, semantics-preserving transformations before evaluation.
87#[derive(Default)]
88pub struct Optimizer {
89    level: OptimizationLevel,
90}
91
92impl Optimizer {
93    /// Creates an `Optimizer` that runs only the passes enabled by `level`.
94    pub fn with_level(level: OptimizationLevel) -> Self {
95        Self { level }
96    }
97
98    /// Runs all enabled optimization passes on `program` and returns the transformed AST.
99    pub fn optimize(&self, program: Program) -> Program {
100        self.optimize_impl(program, true)
101    }
102
103    /// Optimize a nested sub-program (body of a Def, Block, While, Loop, Foreach, etc.).
104    fn optimize_nested(&self, program: Program, parent_user_defs: &FxHashSet<Ident>) -> Program {
105        if matches!(self.level, OptimizationLevel::None) {
106            return program;
107        }
108
109        // Merge parent user_defs with any local Defs. When there are no local Defs (the
110        // common case for loop bodies and blocks), skip the allocation entirely.
111        let merged;
112        let user_defs: &FxHashSet<Ident> = if program.iter().any(|n| matches!(&*n.expr, ast::Expr::Def(..))) {
113            merged = parent_user_defs
114                .iter()
115                .copied()
116                .chain(program.iter().filter_map(|n| {
117                    if let ast::Expr::Def(ident, ..) = &*n.expr {
118                        Some(ident.name)
119                    } else {
120                        None
121                    }
122                }))
123                .collect();
124            &merged
125        } else {
126            parent_user_defs
127        };
128
129        let optimized = lazy_map_program(program, |n| self.optimize_node(Shared::clone(n), user_defs));
130        self.merge_selector_chains(optimized)
131    }
132
133    /// Internal optimization entry point.
134    fn optimize_impl(&self, program: Program, top_level: bool) -> Program {
135        // Collect user-defined function names only when Def nodes are actually present.
136        // Programs without any `def` (the common case for simple queries) share a static
137        // empty set — no heap allocation.
138        static EMPTY_DEFS: OnceLock<FxHashSet<Ident>> = OnceLock::new();
139        let user_defs_owned: FxHashSet<Ident>;
140        let user_defs: &FxHashSet<Ident> = if program.iter().any(|n| matches!(&*n.expr, ast::Expr::Def(..))) {
141            user_defs_owned = program
142                .iter()
143                .filter_map(|n| {
144                    if let ast::Expr::Def(ident, ..) = &*n.expr {
145                        Some(ident.name)
146                    } else {
147                        None
148                    }
149                })
150                .collect();
151            &user_defs_owned
152        } else {
153            EMPTY_DEFS.get_or_init(FxHashSet::default)
154        };
155
156        match self.level {
157            OptimizationLevel::None => program,
158            OptimizationLevel::Basic => {
159                let optimized: Program = program
160                    .into_iter()
161                    .map(|node| self.optimize_node(node, user_defs))
162                    .collect();
163                self.merge_selector_chains(optimized)
164            }
165            OptimizationLevel::Full => {
166                // Pass 1: constant folding + let-literal propagation in a single traversal.
167                let program = self.propagate_and_fold(program, user_defs);
168                let program = self.merge_selector_chains(program);
169
170                // Passes 2-4 are only worthwhile when Def nodes are present.
171                if !program.iter().any(|n| matches!(&*n.expr, ast::Expr::Def(..))) {
172                    return program;
173                }
174
175                let inlinable = collect_inlinable(&program);
176                let program: Program = if inlinable.is_empty() {
177                    program
178                } else {
179                    program.into_iter().map(|n| self.apply_inline(n, &inlinable)).collect()
180                };
181                let program = apply_tco_transforms(program);
182                // Dead-def elimination is safe only at the top level where all call sites
183                // are visible. In nested scopes, external callers are not in scope.
184                let program = if top_level {
185                    eliminate_dead_defs(program, &inlinable)
186                } else {
187                    program
188                };
189                let refolded: Program = program.into_iter().map(|n| self.optimize_node(n, user_defs)).collect();
190                self.merge_selector_chains(refolded)
191            }
192        }
193    }
194
195    /// Single-pass constant folding + let-literal propagation.
196    ///
197    /// Processes top-level nodes left-to-right:
198    /// - `let x = <foldable-expr>`: optimises the RHS, registers `x` in the substitution
199    ///   map if the result is a literal.
200    /// - All other nodes: substitute known literals, then fold constants.
201    fn propagate_and_fold(&self, program: Program, user_defs: &FxHashSet<Ident>) -> Program {
202        let has_let_literal = program.iter().any(|n| {
203            matches!(&*n.expr, ast::Expr::Let(Pattern::Ident(_), rhs) if matches!(&*rhs.expr, ast::Expr::Literal(_)))
204        });
205
206        if !has_let_literal {
207            return lazy_map_program(program, |n| self.optimize_node(Shared::clone(n), user_defs));
208        }
209
210        let mut env: LiteralEnv = LiteralEnv::new();
211        let mut result: Program = Vec::with_capacity(program.len());
212
213        for node in program {
214            let token_id = node.token_id;
215            match &*node.expr {
216                ast::Expr::Let(Pattern::Ident(ident), rhs) => {
217                    let opt_rhs = self.optimize_node(Shared::clone(rhs), user_defs);
218                    if let ast::Expr::Literal(lit) = &*opt_rhs.expr {
219                        env_insert(&mut env, ident.name, lit.clone());
220                    } else {
221                        env_remove(&mut env, ident.name);
222                    }
223                    // Reuse the original node when the RHS didn't change (e.g., already a literal).
224                    if ptr_eq(&opt_rhs, rhs) {
225                        result.push(node);
226                    } else {
227                        result.push(Shared::new(ast::Node {
228                            token_id,
229                            expr: Shared::new(ast::Expr::Let(Pattern::Ident(ident.clone()), opt_rhs)),
230                        }));
231                    }
232                }
233                _ => {
234                    let optimized = if env.is_empty() {
235                        self.optimize_node(node, user_defs)
236                    } else {
237                        self.optimize_node(self.substitute_literals(node, &env), user_defs)
238                    };
239                    result.push(optimized);
240                }
241            }
242        }
243
244        result
245    }
246
247    fn merge_selector_chains(&self, program: Program) -> Program {
248        // Fast path: skip allocation when no consecutive Selector nodes exist.
249        let has_consecutive = program
250            .windows(2)
251            .any(|w| matches!(&*w[0].expr, ast::Expr::Selector(_)) && matches!(&*w[1].expr, ast::Expr::Selector(_)));
252        if !has_consecutive {
253            return program;
254        }
255
256        let mut result: Program = Vec::with_capacity(program.len());
257        let mut iter = program.into_iter().peekable();
258
259        while let Some(node) = iter.next() {
260            if let ast::Expr::Selector(sel) = &*node.expr {
261                let token_id = node.token_id;
262                let mut chain: SmallVec<[Selector; 4]> = SmallVec::new();
263                chain.push(sel.clone());
264
265                while let Some(next) = iter.peek() {
266                    if let ast::Expr::Selector(next_sel) = &*next.expr {
267                        chain.push(next_sel.clone());
268                        iter.next();
269                    } else {
270                        break;
271                    }
272                }
273
274                if chain.len() == 1 {
275                    result.push(node);
276                } else {
277                    result.push(Shared::new(ast::Node {
278                        token_id,
279                        expr: Shared::new(ast::Expr::SelectorChain(chain)),
280                    }));
281                }
282            } else {
283                result.push(node);
284            }
285        }
286
287        result
288    }
289
290    /// Substitute `Ident` references with their bound literals, without crossing scope boundaries.
291    fn substitute_literals(&self, node: Shared<ast::Node>, env: &LiteralEnv) -> Shared<ast::Node> {
292        if env.is_empty() {
293            return node;
294        }
295        let token_id = node.token_id;
296
297        match &*node.expr {
298            ast::Expr::Ident(ident) => {
299                if let Some(lit) = env_get(env, ident.name) {
300                    return Shared::new(ast::Node {
301                        token_id,
302                        expr: Shared::new(ast::Expr::Literal(lit.clone())),
303                    });
304                }
305                node
306            }
307            ast::Expr::Call(ident, args) => {
308                let subst_args: Args = args
309                    .iter()
310                    .map(|a| self.substitute_literals(Shared::clone(a), env))
311                    .collect();
312                Shared::new(ast::Node {
313                    token_id,
314                    expr: Shared::new(ast::Expr::Call(ident.clone(), subst_args)),
315                })
316            }
317            ast::Expr::CallDynamic(callable, args) => {
318                let subst_callable = self.substitute_literals(Shared::clone(callable), env);
319                let subst_args: Args = args
320                    .iter()
321                    .map(|a| self.substitute_literals(Shared::clone(a), env))
322                    .collect();
323                Shared::new(ast::Node {
324                    token_id,
325                    expr: Shared::new(ast::Expr::CallDynamic(subst_callable, subst_args)),
326                })
327            }
328            ast::Expr::SelectorCall(selector, args) => {
329                let subst_args: Args = args
330                    .iter()
331                    .map(|a| self.substitute_literals(Shared::clone(a), env))
332                    .collect();
333                Shared::new(ast::Node {
334                    token_id,
335                    expr: Shared::new(ast::Expr::SelectorCall(selector.clone(), subst_args)),
336                })
337            }
338            ast::Expr::If(branches) => {
339                let subst_branches: Branches = branches
340                    .iter()
341                    .map(|(cond, body)| {
342                        (
343                            cond.as_ref().map(|c| self.substitute_literals(Shared::clone(c), env)),
344                            self.substitute_literals(Shared::clone(body), env),
345                        )
346                    })
347                    .collect();
348                Shared::new(ast::Node {
349                    token_id,
350                    expr: Shared::new(ast::Expr::If(subst_branches)),
351                })
352            }
353            ast::Expr::And(operands) => {
354                let subst: Vec<_> = operands
355                    .iter()
356                    .map(|o| self.substitute_literals(Shared::clone(o), env))
357                    .collect();
358                Shared::new(ast::Node {
359                    token_id,
360                    expr: Shared::new(ast::Expr::And(subst)),
361                })
362            }
363            ast::Expr::Or(operands) => {
364                let subst: Vec<_> = operands
365                    .iter()
366                    .map(|o| self.substitute_literals(Shared::clone(o), env))
367                    .collect();
368                Shared::new(ast::Node {
369                    token_id,
370                    expr: Shared::new(ast::Expr::Or(subst)),
371                })
372            }
373            ast::Expr::Paren(inner) => Shared::new(ast::Node {
374                token_id,
375                expr: Shared::new(ast::Expr::Paren(self.substitute_literals(Shared::clone(inner), env))),
376            }),
377            ast::Expr::Try(try_expr, catch_expr) => Shared::new(ast::Node {
378                token_id,
379                expr: Shared::new(ast::Expr::Try(
380                    self.substitute_literals(Shared::clone(try_expr), env),
381                    self.substitute_literals(Shared::clone(catch_expr), env),
382                )),
383            }),
384            ast::Expr::Break(Some(val)) => Shared::new(ast::Node {
385                token_id,
386                expr: Shared::new(ast::Expr::Break(Some(
387                    self.substitute_literals(Shared::clone(val), env),
388                ))),
389            }),
390            // Substitute into Expr segments of interpolated strings so that
391            // `let x = "hi" | s"${x}!"` can later be folded to `"hi!"`.
392            ast::Expr::InterpolatedString(segments) => {
393                let subst_segs: Vec<StringSegment> = segments
394                    .iter()
395                    .map(|seg| match seg {
396                        StringSegment::Expr(inner) => {
397                            StringSegment::Expr(self.substitute_literals(Shared::clone(inner), env))
398                        }
399                        other => other.clone(),
400                    })
401                    .collect();
402                Shared::new(ast::Node {
403                    token_id,
404                    expr: Shared::new(ast::Expr::InterpolatedString(subst_segs)),
405                })
406            }
407            // Scope-creating or leaf nodes: stop substitution here.
408            ast::Expr::Block(_)
409            | ast::Expr::Def(_, _, _)
410            | ast::Expr::Fn(_, _)
411            | ast::Expr::While(_, _)
412            | ast::Expr::Loop(_)
413            | ast::Expr::Foreach(_, _, _)
414            | ast::Expr::Let(_, _)
415            | ast::Expr::Var(_, _)
416            | ast::Expr::As(_, _)
417            | ast::Expr::Assign(_, _)
418            | ast::Expr::Match(_, _)
419            | ast::Expr::Literal(_)
420            | ast::Expr::Selector(_)
421            | ast::Expr::SelectorChain(_)
422            | ast::Expr::Self_
423            | ast::Expr::Nodes
424            | ast::Expr::Break(None)
425            | ast::Expr::Continue
426            | ast::Expr::Include(_)
427            | ast::Expr::Import(_)
428            | ast::Expr::Module(_, _)
429            | ast::Expr::Macro(_, _, _)
430            | ast::Expr::Quote(_)
431            | ast::Expr::Unquote(_)
432            | ast::Expr::QualifiedAccess(_, _) => node,
433        }
434    }
435
436    fn apply_inline(&self, node: Shared<ast::Node>, fns: &FxHashMap<Ident, InlinableFn>) -> Shared<ast::Node> {
437        if fns.is_empty() {
438            return node;
439        }
440        let token_id = node.token_id;
441
442        match &*node.expr {
443            ast::Expr::Call(ident, args) => {
444                let opt_args: Args = args.iter().map(|a| self.apply_inline(Shared::clone(a), fns)).collect();
445
446                if let Some(f) = fns.get(&ident.name).filter(|f| opt_args.len() == f.params.len()) {
447                    return substitute_params(Shared::clone(&f.body), &f.params, &opt_args, token_id);
448                }
449
450                Shared::new(ast::Node {
451                    token_id,
452                    expr: Shared::new(ast::Expr::Call(ident.clone(), opt_args)),
453                })
454            }
455            // Recurse into sub-expressions — but not across scope-creating nodes (Def, Fn, Block).
456            ast::Expr::If(branches) => {
457                let branches: ast::Branches = branches
458                    .iter()
459                    .map(|(cond, body)| {
460                        (
461                            cond.as_ref().map(|c| self.apply_inline(Shared::clone(c), fns)),
462                            self.apply_inline(Shared::clone(body), fns),
463                        )
464                    })
465                    .collect();
466                Shared::new(ast::Node {
467                    token_id,
468                    expr: Shared::new(ast::Expr::If(branches)),
469                })
470            }
471            ast::Expr::And(ops) => Shared::new(ast::Node {
472                token_id,
473                expr: Shared::new(ast::Expr::And(
474                    ops.iter().map(|o| self.apply_inline(Shared::clone(o), fns)).collect(),
475                )),
476            }),
477            ast::Expr::Or(ops) => Shared::new(ast::Node {
478                token_id,
479                expr: Shared::new(ast::Expr::Or(
480                    ops.iter().map(|o| self.apply_inline(Shared::clone(o), fns)).collect(),
481                )),
482            }),
483            ast::Expr::Try(try_expr, catch_expr) => Shared::new(ast::Node {
484                token_id,
485                expr: Shared::new(ast::Expr::Try(
486                    self.apply_inline(Shared::clone(try_expr), fns),
487                    self.apply_inline(Shared::clone(catch_expr), fns),
488                )),
489            }),
490            ast::Expr::SelectorCall(sel, args) => {
491                let opt_args: Args = args.iter().map(|a| self.apply_inline(Shared::clone(a), fns)).collect();
492                Shared::new(ast::Node {
493                    token_id,
494                    expr: Shared::new(ast::Expr::SelectorCall(sel.clone(), opt_args)),
495                })
496            }
497            // Scope-creating and leaf nodes are left unchanged.
498            _ => node,
499        }
500    }
501
502    fn optimize_node(&self, node: Shared<ast::Node>, user_defs: &FxHashSet<Ident>) -> Shared<ast::Node> {
503        let token_id = node.token_id;
504
505        match &*node.expr {
506            ast::Expr::Paren(inner) => self.optimize_node(Shared::clone(inner), user_defs),
507            ast::Expr::Call(ident, args) => {
508                let opt_args: Args = args
509                    .iter()
510                    .map(|a| self.optimize_node(Shared::clone(a), user_defs))
511                    .collect();
512                if let Some(folded) = self.try_fold_call(token_id, &ident.name, &opt_args, user_defs) {
513                    return folded;
514                }
515                // Return the original node when no argument changed (avoids allocation).
516                if args.iter().zip(opt_args.iter()).all(|(orig, opt)| ptr_eq(orig, opt)) {
517                    return node;
518                }
519                Shared::new(ast::Node {
520                    token_id,
521                    expr: Shared::new(ast::Expr::Call(ident.clone(), opt_args)),
522                })
523            }
524            ast::Expr::If(branches) => self.optimize_if(token_id, branches, user_defs),
525            ast::Expr::And(operands) => self.optimize_and(token_id, operands, user_defs),
526            ast::Expr::Or(operands) => self.optimize_or(token_id, operands, user_defs),
527            ast::Expr::Block(program) => {
528                let opt = self.optimize_nested(program.clone(), user_defs);
529                if program.iter().zip(opt.iter()).all(|(a, b)| ptr_eq(a, b)) {
530                    return node;
531                }
532                Shared::new(ast::Node {
533                    token_id,
534                    expr: Shared::new(ast::Expr::Block(opt)),
535                })
536            }
537            ast::Expr::Def(ident, params, program) => Shared::new(ast::Node {
538                token_id,
539                expr: Shared::new(ast::Expr::Def(
540                    ident.clone(),
541                    self.optimize_params(params, user_defs),
542                    self.optimize_nested(program.clone(), user_defs),
543                )),
544            }),
545            ast::Expr::Fn(params, program) => Shared::new(ast::Node {
546                token_id,
547                expr: Shared::new(ast::Expr::Fn(
548                    self.optimize_params(params, user_defs),
549                    self.optimize_nested(program.clone(), user_defs),
550                )),
551            }),
552            ast::Expr::While(cond, program) => {
553                let opt_cond = self.optimize_node(Shared::clone(cond), user_defs);
554                let opt_body = self.optimize_nested(program.clone(), user_defs);
555                if ptr_eq(&opt_cond, cond) && program.iter().zip(opt_body.iter()).all(|(a, b)| ptr_eq(a, b)) {
556                    return node;
557                }
558                Shared::new(ast::Node {
559                    token_id,
560                    expr: Shared::new(ast::Expr::While(opt_cond, opt_body)),
561                })
562            }
563            ast::Expr::Loop(program) => {
564                let opt = self.optimize_nested(program.clone(), user_defs);
565                if program.iter().zip(opt.iter()).all(|(a, b)| ptr_eq(a, b)) {
566                    return node;
567                }
568                Shared::new(ast::Node {
569                    token_id,
570                    expr: Shared::new(ast::Expr::Loop(opt)),
571                })
572            }
573            ast::Expr::Foreach(ident, values, program) => {
574                let opt_values = self.optimize_node(Shared::clone(values), user_defs);
575                let opt_body = self.optimize_nested(program.clone(), user_defs);
576                if ptr_eq(&opt_values, values) && program.iter().zip(opt_body.iter()).all(|(a, b)| ptr_eq(a, b)) {
577                    return node;
578                }
579                Shared::new(ast::Node {
580                    token_id,
581                    expr: Shared::new(ast::Expr::Foreach(ident.clone(), opt_values, opt_body)),
582                })
583            }
584            ast::Expr::As(ident, inner) => {
585                let opt_inner = self.optimize_node(Shared::clone(inner), user_defs);
586                if ptr_eq(&opt_inner, inner) {
587                    return node;
588                }
589                Shared::new(ast::Node {
590                    token_id,
591                    expr: Shared::new(ast::Expr::As(ident.clone(), opt_inner)),
592                })
593            }
594            ast::Expr::Let(pattern, inner) => {
595                let opt_inner = self.optimize_node(Shared::clone(inner), user_defs);
596                if ptr_eq(&opt_inner, inner) {
597                    return node;
598                }
599                Shared::new(ast::Node {
600                    token_id,
601                    expr: Shared::new(ast::Expr::Let(pattern.clone(), opt_inner)),
602                })
603            }
604            ast::Expr::Var(pattern, inner) => {
605                let opt_inner = self.optimize_node(Shared::clone(inner), user_defs);
606                if ptr_eq(&opt_inner, inner) {
607                    return node;
608                }
609                Shared::new(ast::Node {
610                    token_id,
611                    expr: Shared::new(ast::Expr::Var(pattern.clone(), opt_inner)),
612                })
613            }
614            ast::Expr::Assign(ident, inner) => {
615                let opt_inner = self.optimize_node(Shared::clone(inner), user_defs);
616                if ptr_eq(&opt_inner, inner) {
617                    return node;
618                }
619                Shared::new(ast::Node {
620                    token_id,
621                    expr: Shared::new(ast::Expr::Assign(ident.clone(), opt_inner)),
622                })
623            }
624            ast::Expr::Try(try_expr, catch_expr) => {
625                let opt_try = self.optimize_node(Shared::clone(try_expr), user_defs);
626                let opt_catch = self.optimize_node(Shared::clone(catch_expr), user_defs);
627                if ptr_eq(&opt_try, try_expr) && ptr_eq(&opt_catch, catch_expr) {
628                    return node;
629                }
630                Shared::new(ast::Node {
631                    token_id,
632                    expr: Shared::new(ast::Expr::Try(opt_try, opt_catch)),
633                })
634            }
635            ast::Expr::Break(Some(val)) => {
636                let opt_val = self.optimize_node(Shared::clone(val), user_defs);
637                if ptr_eq(&opt_val, val) {
638                    return node;
639                }
640                Shared::new(ast::Node {
641                    token_id,
642                    expr: Shared::new(ast::Expr::Break(Some(opt_val))),
643                })
644            }
645            ast::Expr::Match(value_node, arms) => {
646                let opt_value = self.optimize_node(Shared::clone(value_node), user_defs);
647                let opt_arms: MatchArms = arms
648                    .iter()
649                    .map(|arm| MatchArm {
650                        pattern: arm.pattern.clone(),
651                        guard: arm
652                            .guard
653                            .as_ref()
654                            .map(|g| self.optimize_node(Shared::clone(g), user_defs)),
655                        body: self.optimize_node(Shared::clone(&arm.body), user_defs),
656                    })
657                    .collect();
658                Shared::new(ast::Node {
659                    token_id,
660                    expr: Shared::new(ast::Expr::Match(opt_value, opt_arms)),
661                })
662            }
663            ast::Expr::CallDynamic(callable, args) => {
664                let opt_callable = self.optimize_node(Shared::clone(callable), user_defs);
665                let opt_args: Args = args
666                    .iter()
667                    .map(|a| self.optimize_node(Shared::clone(a), user_defs))
668                    .collect();
669                if ptr_eq(&opt_callable, callable)
670                    && args.iter().zip(opt_args.iter()).all(|(orig, opt)| ptr_eq(orig, opt))
671                {
672                    return node;
673                }
674                Shared::new(ast::Node {
675                    token_id,
676                    expr: Shared::new(ast::Expr::CallDynamic(opt_callable, opt_args)),
677                })
678            }
679            ast::Expr::SelectorCall(selector, args) => {
680                let opt_args: Args = args
681                    .iter()
682                    .map(|a| self.optimize_node(Shared::clone(a), user_defs))
683                    .collect();
684                if args.iter().zip(opt_args.iter()).all(|(orig, opt)| ptr_eq(orig, opt)) {
685                    return node;
686                }
687                Shared::new(ast::Node {
688                    token_id,
689                    expr: Shared::new(ast::Expr::SelectorCall(selector.clone(), opt_args)),
690                })
691            }
692            ast::Expr::InterpolatedString(segments) => {
693                // Optimize each Expr segment; also promote string-literal Expr segments to
694                // Text so that fully-constant interpolated strings can be folded.
695                let opt_segs: Vec<StringSegment> = segments
696                    .iter()
697                    .map(|seg| match seg {
698                        StringSegment::Expr(n) => {
699                            let opt = self.optimize_node(Shared::clone(n), user_defs);
700                            if let ast::Expr::Literal(Literal::String(s)) = &*opt.expr {
701                                StringSegment::Text(s.clone())
702                            } else {
703                                StringSegment::Expr(opt)
704                            }
705                        }
706                        other => other.clone(),
707                    })
708                    .collect();
709
710                if opt_segs.iter().all(|s| matches!(s, StringSegment::Text(_))) {
711                    let folded = opt_segs.iter().fold(String::new(), |mut acc, s| {
712                        if let StringSegment::Text(t) = s {
713                            acc.push_str(t);
714                        }
715                        acc
716                    });
717                    return Shared::new(ast::Node {
718                        token_id,
719                        expr: Shared::new(ast::Expr::Literal(Literal::String(folded))),
720                    });
721                }
722
723                Shared::new(ast::Node {
724                    token_id,
725                    expr: Shared::new(ast::Expr::InterpolatedString(opt_segs)),
726                })
727            }
728            ast::Expr::Module(ident, program) => Shared::new(ast::Node {
729                token_id,
730                expr: Shared::new(ast::Expr::Module(
731                    ident.clone(),
732                    self.optimize_nested(program.clone(), user_defs),
733                )),
734            }),
735            ast::Expr::Unquote(inner) => Shared::new(ast::Node {
736                token_id,
737                expr: Shared::new(ast::Expr::Unquote(self.optimize_node(Shared::clone(inner), user_defs))),
738            }),
739            ast::Expr::Literal(_)
740            | ast::Expr::Ident(_)
741            | ast::Expr::Selector(_)
742            | ast::Expr::SelectorChain(_)
743            | ast::Expr::Self_
744            | ast::Expr::Nodes
745            | ast::Expr::Break(None)
746            | ast::Expr::Continue
747            | ast::Expr::Include(_)
748            | ast::Expr::Import(_)
749            | ast::Expr::Macro(_, _, _)
750            | ast::Expr::Quote(_)
751            | ast::Expr::QualifiedAccess(_, _) => node,
752        }
753    }
754
755    fn try_fold_call(
756        &self,
757        token_id: TokenId,
758        name: &crate::Ident,
759        args: &Args,
760        user_defs: &FxHashSet<Ident>,
761    ) -> Option<Shared<ast::Node>> {
762        use crate::ast::constants::builtins;
763
764        // Never fold a call whose name is shadowed by a user-defined function.
765        if user_defs.contains(name) {
766            return None;
767        }
768
769        let make_lit = |lit: Literal| {
770            Shared::new(ast::Node {
771                token_id,
772                expr: Shared::new(ast::Expr::Literal(lit)),
773            })
774        };
775
776        if args.len() == 2 {
777            let lhs_lit = literal_of(&args[0]);
778            let rhs_lit = literal_of(&args[1]);
779
780            if let (Some(lhs), Some(rhs)) = (lhs_lit.clone(), rhs_lit.clone()) {
781                match name.as_str().as_str() {
782                    n @ (builtins::ADD | builtins::SUB | builtins::MUL | builtins::DIV | builtins::MOD) => {
783                        match (lhs, rhs) {
784                            (Literal::Number(a), Literal::Number(b)) => {
785                                if (n == builtins::DIV || n == builtins::MOD) && b.is_zero() {
786                                    return None;
787                                }
788                                let result = match n {
789                                    builtins::ADD => a + b,
790                                    builtins::SUB => a - b,
791                                    builtins::MUL => a * b,
792                                    builtins::DIV => a / b,
793                                    builtins::MOD => a % b,
794                                    _ => unreachable!(),
795                                };
796                                return Some(make_lit(Literal::Number(result)));
797                            }
798                            (Literal::String(a), Literal::String(b)) if n == builtins::ADD => {
799                                return Some(make_lit(Literal::String(a + &b)));
800                            }
801                            _ => {}
802                        }
803                    }
804
805                    builtins::EQ => return Some(make_lit(Literal::Bool(literal_eq(lhs, rhs)))),
806                    builtins::NE => return Some(make_lit(Literal::Bool(!literal_eq(lhs, rhs)))),
807
808                    n @ (builtins::LT | builtins::LTE | builtins::GT | builtins::GTE) => match (lhs, rhs) {
809                        (Literal::Number(a), Literal::Number(b)) => {
810                            if a.is_nan() || b.is_nan() {
811                                return None;
812                            }
813                            let result = match n {
814                                builtins::LT => a < b,
815                                builtins::LTE => a <= b,
816                                builtins::GT => a > b,
817                                builtins::GTE => a >= b,
818                                _ => unreachable!(),
819                            };
820                            return Some(make_lit(Literal::Bool(result)));
821                        }
822                        (Literal::String(a), Literal::String(b)) => {
823                            let result = match n {
824                                builtins::LT => a < b,
825                                builtins::LTE => a <= b,
826                                builtins::GT => a > b,
827                                builtins::GTE => a >= b,
828                                _ => unreachable!(),
829                            };
830                            return Some(make_lit(Literal::Bool(result)));
831                        }
832                        _ => {}
833                    },
834
835                    builtins::STARTS_WITH => {
836                        if let (Literal::String(s), Literal::String(prefix)) = (lhs, rhs) {
837                            return Some(make_lit(Literal::Bool(s.starts_with(&*prefix))));
838                        }
839                    }
840
841                    builtins::ENDS_WITH => {
842                        if let (Literal::String(s), Literal::String(suffix)) = (lhs, rhs) {
843                            return Some(make_lit(Literal::Bool(s.ends_with(&*suffix))));
844                        }
845                    }
846
847                    builtins::INDEX => {
848                        if let (Literal::String(s), Literal::String(sub)) = (lhs, rhs) {
849                            let pos = s.find(&*sub).map(|v| v as i64).unwrap_or(-1);
850                            return Some(make_lit(Literal::Number(pos.into())));
851                        }
852                    }
853
854                    builtins::RINDEX => {
855                        if let (Literal::String(s), Literal::String(sub)) = (lhs, rhs) {
856                            let pos = s.rfind(&*sub).map(|v| v as i64).unwrap_or(-1);
857                            return Some(make_lit(Literal::Number(pos.into())));
858                        }
859                    }
860
861                    builtins::COALESCE => {
862                        return Some(match lhs {
863                            Literal::None => Shared::clone(&args[1]),
864                            _ => Shared::clone(&args[0]),
865                        });
866                    }
867
868                    _ => {}
869                }
870            }
871
872            // Partial fold: coalesce(none, x) → x even when x is not a literal.
873            if name.as_str().as_str() == builtins::COALESCE {
874                if matches!(&lhs_lit, Some(Literal::None)) {
875                    return Some(Shared::clone(&args[1]));
876                }
877                if lhs_lit.as_ref().is_some_and(|lit| !matches!(lit, Literal::None)) {
878                    return Some(Shared::clone(&args[0]));
879                }
880            }
881
882            // One operand is a literal: algebraic identity folding.
883            let op = name.as_str();
884            match op.as_str() {
885                builtins::ADD => {
886                    // add(x, 0) → x, add(0, x) → x
887                    if matches!(&rhs_lit, Some(Literal::Number(n)) if n.is_zero()) {
888                        return Some(Shared::clone(&args[0]));
889                    }
890                    if matches!(&lhs_lit, Some(Literal::Number(n)) if n.is_zero()) {
891                        return Some(Shared::clone(&args[1]));
892                    }
893                    // add("", x) → x, add(x, "") → x
894                    if matches!(&rhs_lit, Some(Literal::String(s)) if s.is_empty()) {
895                        return Some(Shared::clone(&args[0]));
896                    }
897                    if matches!(&lhs_lit, Some(Literal::String(s)) if s.is_empty()) {
898                        return Some(Shared::clone(&args[1]));
899                    }
900                }
901                builtins::SUB => {
902                    // sub(x, 0) → x
903                    if matches!(&rhs_lit, Some(Literal::Number(n)) if n.is_zero()) {
904                        return Some(Shared::clone(&args[0]));
905                    }
906                }
907                builtins::MUL => {
908                    // mul(x, 1) → x, mul(1, x) → x
909                    if matches!(&rhs_lit, Some(Literal::Number(n)) if is_one(n)) {
910                        return Some(Shared::clone(&args[0]));
911                    }
912                    if matches!(&lhs_lit, Some(Literal::Number(n)) if is_one(n)) {
913                        return Some(Shared::clone(&args[1]));
914                    }
915                    // mul(x, 0) → 0, mul(0, x) → 0
916                    if matches!(&rhs_lit, Some(Literal::Number(n)) if n.is_zero()) {
917                        return Some(make_lit(Literal::Number(0i64.into())));
918                    }
919                    if matches!(&lhs_lit, Some(Literal::Number(n)) if n.is_zero()) {
920                        return Some(make_lit(Literal::Number(0i64.into())));
921                    }
922                }
923                builtins::DIV => {
924                    // div(x, 1) → x
925                    if matches!(&rhs_lit, Some(Literal::Number(n)) if is_one(n)) {
926                        return Some(Shared::clone(&args[0]));
927                    }
928                }
929                _ => {}
930            }
931        }
932
933        if args.len() == 1 {
934            let arg = literal_of(&args[0])?;
935            match name.as_str().as_str() {
936                builtins::NOT => {
937                    if let Literal::Bool(b) = arg {
938                        return Some(make_lit(Literal::Bool(!b)));
939                    }
940                }
941                builtins::NEGATE => {
942                    if let Literal::Number(n) = arg {
943                        return Some(make_lit(Literal::Number(-n)));
944                    }
945                }
946                // Numeric rounding/absolute value — safe because these are total functions on numbers.
947                n @ (builtins::FLOOR | builtins::CEIL | builtins::ROUND | builtins::ABS | builtins::TRUNC) => {
948                    if let Literal::Number(num) = arg {
949                        if num.is_nan() {
950                            return None;
951                        }
952                        let result = match n {
953                            builtins::FLOOR => num.value().floor(),
954                            builtins::CEIL => num.value().ceil(),
955                            builtins::ROUND => num.value().round(),
956                            builtins::ABS => num.value().abs(),
957                            builtins::TRUNC => num.value().trunc(),
958                            _ => unreachable!(),
959                        };
960                        return Some(make_lit(Literal::Number(result.into())));
961                    }
962                }
963                builtins::LEN => match arg {
964                    Literal::String(s) => return Some(make_lit(Literal::Number(s.chars().count().into()))),
965                    Literal::Bytes(b) => return Some(make_lit(Literal::Number(b.len().into()))),
966                    _ => {}
967                },
968                // to_string on any primitive literal — replicates the runtime behaviour exactly.
969                builtins::TO_STRING => {
970                    let s = match arg {
971                        Literal::String(s) => s,
972                        Literal::Number(n) => n.to_string(),
973                        Literal::Bool(b) => b.to_string(),
974                        Literal::None => String::new(),
975                        Literal::Symbol(sym) => sym.to_string(),
976                        Literal::Bytes(_) => return None, // hex encoding would need extra logic
977                    };
978                    return Some(make_lit(Literal::String(s)));
979                }
980                // to_number on a string literal — only fold when parsing succeeds.
981                builtins::TO_NUMBER => {
982                    if let Literal::String(s) = arg {
983                        return s.parse::<f64>().ok().map(|n| make_lit(Literal::Number(n.into())));
984                    }
985                }
986                n @ (builtins::TRIM | builtins::LTRIM | builtins::RTRIM) => {
987                    if let Literal::String(s) = arg {
988                        let result = match n {
989                            builtins::TRIM => s.trim().to_string(),
990                            builtins::LTRIM => s.trim_start().to_string(),
991                            builtins::RTRIM => s.trim_end().to_string(),
992                            _ => unreachable!(),
993                        };
994                        return Some(make_lit(Literal::String(result)));
995                    }
996                }
997                n @ (builtins::UPCASE | builtins::DOWNCASE) => {
998                    if let Literal::String(s) = arg {
999                        let result = match n {
1000                            builtins::UPCASE => s.to_uppercase(),
1001                            builtins::DOWNCASE => s.to_lowercase(),
1002                            _ => unreachable!(),
1003                        };
1004                        return Some(make_lit(Literal::String(result)));
1005                    }
1006                }
1007
1008                _ => {}
1009            }
1010        }
1011
1012        if args.len() == 3 {
1013            let a = literal_of(&args[0]);
1014            let b = literal_of(&args[1]);
1015            let c = literal_of(&args[2]);
1016            if let (Some(Literal::String(s)), Some(Literal::String(from)), Some(Literal::String(to))) = (a, b, c) {
1017                // replace("foo", "o", "a") → "faa"
1018                if name.as_str().as_str() == builtins::REPLACE {
1019                    return Some(make_lit(Literal::String(s.replace(&*from, &to))));
1020                }
1021            }
1022        }
1023
1024        None
1025    }
1026
1027    fn optimize_if(&self, token_id: TokenId, branches: &Branches, user_defs: &FxHashSet<Ident>) -> Shared<ast::Node> {
1028        let mut remaining: Branches = SmallVec::new();
1029
1030        for (cond_node, body_node) in branches {
1031            let opt_body = self.optimize_node(Shared::clone(body_node), user_defs);
1032
1033            match cond_node {
1034                None => {
1035                    remaining.push((None, opt_body));
1036                    break;
1037                }
1038                Some(cond) => {
1039                    let opt_cond = self.optimize_node(Shared::clone(cond), user_defs);
1040                    match &*opt_cond.expr {
1041                        ast::Expr::Literal(Literal::Bool(true)) => {
1042                            remaining.push((None, opt_body));
1043                            break;
1044                        }
1045                        ast::Expr::Literal(Literal::Bool(false)) => {
1046                            continue;
1047                        }
1048                        _ => {
1049                            remaining.push((Some(opt_cond), opt_body));
1050                        }
1051                    }
1052                }
1053            }
1054        }
1055
1056        match remaining.len() {
1057            0 => Shared::new(ast::Node {
1058                token_id,
1059                expr: Shared::new(ast::Expr::Literal(Literal::None)),
1060            }),
1061            1 if remaining[0].0.is_none() => Shared::clone(&remaining[0].1),
1062            _ => Shared::new(ast::Node {
1063                token_id,
1064                expr: Shared::new(ast::Expr::If(remaining)),
1065            }),
1066        }
1067    }
1068
1069    fn optimize_and(
1070        &self,
1071        token_id: TokenId,
1072        operands: &[Shared<ast::Node>],
1073        user_defs: &FxHashSet<Ident>,
1074    ) -> Shared<ast::Node> {
1075        let mut remaining: Vec<Shared<ast::Node>> = Vec::with_capacity(operands.len());
1076
1077        for op in operands {
1078            let opt = self.optimize_node(Shared::clone(op), user_defs);
1079            match &*opt.expr {
1080                ast::Expr::Literal(lit) if !literal_is_truthy(lit) => {
1081                    return Shared::new(ast::Node {
1082                        token_id,
1083                        expr: Shared::new(ast::Expr::Literal(Literal::Bool(false))),
1084                    });
1085                }
1086                ast::Expr::Literal(lit) if literal_is_truthy(lit) => continue,
1087                _ => remaining.push(opt),
1088            }
1089        }
1090
1091        match remaining.len() {
1092            0 => Shared::new(ast::Node {
1093                token_id,
1094                expr: Shared::new(ast::Expr::Literal(Literal::Bool(true))),
1095            }),
1096            _ => Shared::new(ast::Node {
1097                token_id,
1098                expr: Shared::new(ast::Expr::And(remaining)),
1099            }),
1100        }
1101    }
1102
1103    fn optimize_or(
1104        &self,
1105        token_id: TokenId,
1106        operands: &[Shared<ast::Node>],
1107        user_defs: &FxHashSet<Ident>,
1108    ) -> Shared<ast::Node> {
1109        let mut remaining: Vec<Shared<ast::Node>> = Vec::with_capacity(operands.len());
1110
1111        for op in operands {
1112            let opt = self.optimize_node(Shared::clone(op), user_defs);
1113            match &*opt.expr {
1114                ast::Expr::Literal(lit) if literal_is_truthy(lit) => return opt,
1115                ast::Expr::Literal(lit) if !literal_is_truthy(lit) => continue,
1116                _ => remaining.push(opt),
1117            }
1118        }
1119
1120        match remaining.len() {
1121            0 => Shared::new(ast::Node {
1122                token_id,
1123                expr: Shared::new(ast::Expr::Literal(Literal::Bool(false))),
1124            }),
1125            _ => Shared::new(ast::Node {
1126                token_id,
1127                expr: Shared::new(ast::Expr::Or(remaining)),
1128            }),
1129        }
1130    }
1131
1132    fn optimize_params(&self, params: &Params, user_defs: &FxHashSet<Ident>) -> Params {
1133        params
1134            .iter()
1135            .map(|p| ast::Param {
1136                ident: p.ident.clone(),
1137                default: p
1138                    .default
1139                    .as_ref()
1140                    .map(|d| self.optimize_node(Shared::clone(d), user_defs)),
1141                is_variadic: p.is_variadic,
1142            })
1143            .collect()
1144    }
1145}
1146
1147struct InlinableFn {
1148    params: Vec<Ident>,
1149    body: Shared<ast::Node>,
1150}
1151
1152/// Scan `program` for `Def` nodes whose bodies are safe to inline.
1153///
1154/// A function is inlineable when:
1155/// - Its body has exactly one node.
1156/// - It has no variadic or default-valued parameters.
1157/// - It is not self-recursive.
1158/// - Its body contains no free variables (all `Ident` refs are parameter names).
1159fn collect_inlinable(program: &Program) -> FxHashMap<Ident, InlinableFn> {
1160    let mut map = FxHashMap::default();
1161    for node in program {
1162        let ast::Expr::Def(ident, params, body) = &*node.expr else {
1163            continue;
1164        };
1165        if body.len() != 1 {
1166            continue;
1167        }
1168        if params.iter().any(|p| p.is_variadic || p.default.is_some()) {
1169            continue;
1170        }
1171        let body_node = &body[0];
1172        let param_names: Vec<Ident> = params.iter().map(|p| p.ident.name).collect();
1173        if has_recursion(body_node, ident.name) || has_free_vars(body_node, &param_names) {
1174            continue;
1175        }
1176        map.insert(
1177            ident.name,
1178            InlinableFn {
1179                params: param_names,
1180                body: Shared::clone(body_node),
1181            },
1182        );
1183    }
1184    map
1185}
1186
1187/// Returns `true` if `node` contains a direct or indirect call to `fn_name`.
1188fn has_recursion(node: &Shared<ast::Node>, fn_name: Ident) -> bool {
1189    match &*node.expr {
1190        ast::Expr::Call(ident, args) => ident.name == fn_name || args.iter().any(|a| has_recursion(a, fn_name)),
1191        ast::Expr::Ident(ident) => ident.name == fn_name,
1192        ast::Expr::And(ops) | ast::Expr::Or(ops) => ops.iter().any(|o| has_recursion(o, fn_name)),
1193        ast::Expr::If(branches) => branches.iter().any(|(cond, body)| {
1194            cond.as_ref().is_some_and(|c| has_recursion(c, fn_name)) || has_recursion(body, fn_name)
1195        }),
1196        ast::Expr::Try(t, c) => has_recursion(t, fn_name) || has_recursion(c, fn_name),
1197        ast::Expr::SelectorCall(_, args) => args.iter().any(|a| has_recursion(a, fn_name)),
1198        ast::Expr::Paren(inner) => has_recursion(inner, fn_name),
1199        _ => false,
1200    }
1201}
1202
1203/// Returns `true` if `node` contains any `Ident` reference that is not in `params`,
1204/// OR if any `Call` node uses a parameter name as its callee (those cannot be substituted
1205/// at the AST level because the callee position is an `IdentWithToken`, not an `Expr::Ident`).
1206///
1207/// Conservative: complex sub-expressions (blocks, lambdas, loops, let/var) cause
1208/// the function to return `true` immediately so that inlining is skipped.
1209fn has_free_vars(node: &Shared<ast::Node>, params: &[Ident]) -> bool {
1210    match &*node.expr {
1211        ast::Expr::Ident(ident) => !params.contains(&ident.name),
1212        ast::Expr::Literal(_) | ast::Expr::Self_ | ast::Expr::Selector(_) | ast::Expr::SelectorChain(_) => false,
1213        ast::Expr::Call(callee, args) => {
1214            // A parameter used as a callee cannot be inlined (callee is IdentWithToken, not Expr).
1215            params.contains(&callee.name) || args.iter().any(|a| has_free_vars(a, params))
1216        }
1217        ast::Expr::SelectorCall(_, args) => args.iter().any(|a| has_free_vars(a, params)),
1218        ast::Expr::And(ops) | ast::Expr::Or(ops) => ops.iter().any(|o| has_free_vars(o, params)),
1219        ast::Expr::If(branches) => branches
1220            .iter()
1221            .any(|(cond, body)| cond.as_ref().is_some_and(|c| has_free_vars(c, params)) || has_free_vars(body, params)),
1222        ast::Expr::Try(t, c) => has_free_vars(t, params) || has_free_vars(c, params),
1223        ast::Expr::Paren(inner) => has_free_vars(inner, params),
1224        _ => true,
1225    }
1226}
1227
1228/// Substitute parameter references in `body` with the supplied arguments.
1229///
1230/// `Ident(param_name)` → corresponding `arg` node.
1231/// All other expression types are cloned unchanged.
1232fn substitute_params(
1233    node: Shared<ast::Node>,
1234    params: &[Ident],
1235    args: &Args,
1236    call_token_id: TokenId,
1237) -> Shared<ast::Node> {
1238    let token_id = call_token_id;
1239    match &*node.expr {
1240        ast::Expr::Ident(ident) => {
1241            if let Some(pos) = params.iter().position(|p| *p == ident.name) {
1242                return Shared::clone(&args[pos]);
1243            }
1244            node
1245        }
1246        ast::Expr::Call(ident, call_args) => {
1247            let subst: Args = call_args
1248                .iter()
1249                .map(|a| substitute_params(Shared::clone(a), params, args, call_token_id))
1250                .collect();
1251            Shared::new(ast::Node {
1252                token_id,
1253                expr: Shared::new(ast::Expr::Call(ident.clone(), subst)),
1254            })
1255        }
1256        ast::Expr::SelectorCall(sel, call_args) => {
1257            let subst: Args = call_args
1258                .iter()
1259                .map(|a| substitute_params(Shared::clone(a), params, args, call_token_id))
1260                .collect();
1261            Shared::new(ast::Node {
1262                token_id,
1263                expr: Shared::new(ast::Expr::SelectorCall(sel.clone(), subst)),
1264            })
1265        }
1266        ast::Expr::And(ops) => Shared::new(ast::Node {
1267            token_id,
1268            expr: Shared::new(ast::Expr::And(
1269                ops.iter()
1270                    .map(|o| substitute_params(Shared::clone(o), params, args, call_token_id))
1271                    .collect(),
1272            )),
1273        }),
1274        ast::Expr::Or(ops) => Shared::new(ast::Node {
1275            token_id,
1276            expr: Shared::new(ast::Expr::Or(
1277                ops.iter()
1278                    .map(|o| substitute_params(Shared::clone(o), params, args, call_token_id))
1279                    .collect(),
1280            )),
1281        }),
1282        ast::Expr::If(branches) => {
1283            let branches: ast::Branches = branches
1284                .iter()
1285                .map(|(cond, body)| {
1286                    (
1287                        cond.as_ref()
1288                            .map(|c| substitute_params(Shared::clone(c), params, args, call_token_id)),
1289                        substitute_params(Shared::clone(body), params, args, call_token_id),
1290                    )
1291                })
1292                .collect();
1293            Shared::new(ast::Node {
1294                token_id,
1295                expr: Shared::new(ast::Expr::If(branches)),
1296            })
1297        }
1298        ast::Expr::Try(t, c) => Shared::new(ast::Node {
1299            token_id,
1300            expr: Shared::new(ast::Expr::Try(
1301                substitute_params(Shared::clone(t), params, args, call_token_id),
1302                substitute_params(Shared::clone(c), params, args, call_token_id),
1303            )),
1304        }),
1305        ast::Expr::Paren(inner) => substitute_params(Shared::clone(inner), params, args, call_token_id),
1306        _ => node,
1307    }
1308}
1309
1310fn literal_of(node: &Shared<ast::Node>) -> Option<Literal> {
1311    match &*node.expr {
1312        ast::Expr::Literal(lit) => Some(lit.clone()),
1313        _ => None,
1314    }
1315}
1316
1317fn literal_eq(a: Literal, b: Literal) -> bool {
1318    match (a, b) {
1319        (Literal::Number(x), Literal::Number(y)) => x == y,
1320        (Literal::Bool(x), Literal::Bool(y)) => x == y,
1321        (Literal::String(x), Literal::String(y)) => x == y,
1322        (Literal::Symbol(x), Literal::Symbol(y)) => x == y,
1323        (Literal::Bytes(x), Literal::Bytes(y)) => x == y,
1324        (Literal::None, Literal::None) => true,
1325        _ => false,
1326    }
1327}
1328
1329/// Scan `program` and rewrite self-tail-recursive `def` functions to use a loop.
1330fn apply_tco_transforms(program: Program) -> Program {
1331    program
1332        .into_iter()
1333        .map(|node| {
1334            let ast::Expr::Def(ident, params, body) = &*node.expr else {
1335                return node;
1336            };
1337            let param_names: Vec<Ident> = params.iter().map(|p| p.ident.name).collect();
1338            match try_tco_transform(ident.name, &param_names, body, node.token_id) {
1339                Some(new_body) => Shared::new(ast::Node {
1340                    token_id: node.token_id,
1341                    expr: Shared::new(ast::Expr::Def(ident.clone(), params.clone(), new_body)),
1342                }),
1343                None => node,
1344            }
1345        })
1346        .collect()
1347}
1348
1349/// Returns `Some(new_body)` if `body` matches the TCO pattern:
1350/// - Single `If` node whose branches are either non-recursive base cases or direct self-calls.
1351/// - At least one base case and at least one recursive call.
1352fn try_tco_transform(fn_name: Ident, param_names: &[Ident], body: &Program, token_id: TokenId) -> Option<Program> {
1353    if body.len() != 1 {
1354        return None;
1355    }
1356    let ast::Expr::If(branches) = &*body[0].expr else {
1357        return None;
1358    };
1359
1360    let mut has_recursive = false;
1361    let mut has_base = false;
1362
1363    for (_, branch_body) in branches {
1364        if is_direct_self_call(branch_body, fn_name) {
1365            has_recursive = true;
1366        } else if !contains_self_call(branch_body, fn_name) {
1367            has_base = true;
1368        } else {
1369            return None;
1370        }
1371    }
1372
1373    if !has_recursive || !has_base {
1374        return None;
1375    }
1376
1377    Some(build_tco_loop(fn_name, param_names, branches, token_id))
1378}
1379
1380/// Returns `true` if `node` is exactly `Call(fn_name, args)`.
1381fn is_direct_self_call(node: &Shared<ast::Node>, fn_name: Ident) -> bool {
1382    matches!(&*node.expr, ast::Expr::Call(ident, _) if ident.name == fn_name)
1383}
1384
1385/// Returns `true` if `node` contains any call to `fn_name` at any depth.
1386fn contains_self_call(node: &Shared<ast::Node>, fn_name: Ident) -> bool {
1387    match &*node.expr {
1388        ast::Expr::Call(ident, args) => ident.name == fn_name || args.iter().any(|a| contains_self_call(a, fn_name)),
1389        ast::Expr::Ident(ident) => ident.name == fn_name,
1390        ast::Expr::And(ops) | ast::Expr::Or(ops) => ops.iter().any(|o| contains_self_call(o, fn_name)),
1391        ast::Expr::If(branches) => branches.iter().any(|(cond, body)| {
1392            cond.as_ref().is_some_and(|c| contains_self_call(c, fn_name)) || contains_self_call(body, fn_name)
1393        }),
1394        ast::Expr::Try(t, c) => contains_self_call(t, fn_name) || contains_self_call(c, fn_name),
1395        ast::Expr::SelectorCall(_, args) => args.iter().any(|a| contains_self_call(a, fn_name)),
1396        ast::Expr::Paren(inner) | ast::Expr::Break(Some(inner)) | ast::Expr::Unquote(inner) => {
1397            contains_self_call(inner, fn_name)
1398        }
1399        ast::Expr::Block(prog) => prog.iter().any(|n| contains_self_call(n, fn_name)),
1400        _ => false,
1401    }
1402}
1403
1404/// Build the loop-based body that replaces a tail-recursive function.
1405///
1406/// For `def f(a, b): if (cond): base else: f(new_a, new_b);` generates:
1407/// ```text
1408/// var __tco_a = a;
1409/// var __tco_b = b;
1410/// loop {
1411///   let a = __tco_a;
1412///   let b = __tco_b;
1413///   if (cond): break base
1414///   else: { __tco_a = new_a; __tco_b = new_b; continue }
1415/// }
1416/// ```
1417fn build_tco_loop(fn_name: Ident, param_names: &[Ident], branches: &Branches, token_id: TokenId) -> Program {
1418    let syn = |expr: ast::Expr| -> Shared<ast::Node> {
1419        Shared::new(ast::Node {
1420            token_id,
1421            expr: Shared::new(expr),
1422        })
1423    };
1424
1425    let tco_ident = |p: Ident| IdentWithToken::new(&format!("__tco_{}", p.as_str()));
1426
1427    // var __tco_p = p;
1428    let var_decls: Program = param_names
1429        .iter()
1430        .map(|p| {
1431            syn(ast::Expr::Var(
1432                Pattern::Ident(tco_ident(*p)),
1433                syn(ast::Expr::Ident(IdentWithToken::new(&p.as_str()))),
1434            ))
1435        })
1436        .collect();
1437
1438    // let p = __tco_p;  (re-bind at the top of each loop iteration)
1439    let let_rebinds: Program = param_names
1440        .iter()
1441        .map(|p| {
1442            syn(ast::Expr::Let(
1443                Pattern::Ident(IdentWithToken::new(&p.as_str())),
1444                syn(ast::Expr::Ident(tco_ident(*p))),
1445            ))
1446        })
1447        .collect();
1448
1449    // Transform each If branch
1450    let new_branches: Branches = branches
1451        .iter()
1452        .map(|(cond, body)| {
1453            let new_body = if is_direct_self_call(body, fn_name) {
1454                let ast::Expr::Call(_, rec_args) = &*body.expr else {
1455                    unreachable!()
1456                };
1457                // __tco_p = new_p; continue
1458                let mut block: Program = param_names
1459                    .iter()
1460                    .zip(rec_args.iter())
1461                    .map(|(p, new_val)| syn(ast::Expr::Assign(tco_ident(*p), Shared::clone(new_val))))
1462                    .collect();
1463                block.push(syn(ast::Expr::Continue));
1464                syn(ast::Expr::Block(block))
1465            } else {
1466                syn(ast::Expr::Break(Some(Shared::clone(body))))
1467            };
1468            (cond.clone(), new_body)
1469        })
1470        .collect();
1471
1472    let mut loop_body = let_rebinds;
1473    loop_body.push(syn(ast::Expr::If(new_branches)));
1474
1475    let mut result = var_decls;
1476    result.push(syn(ast::Expr::Loop(loop_body)));
1477    result
1478}
1479
1480/// Returns `true` if `n` is exactly the integer 1.
1481#[inline]
1482fn is_one(n: &crate::number::Number) -> bool {
1483    (n.value() - 1.0).abs() < f64::EPSILON
1484}
1485
1486/// Collect the names of every function directly called in `program` (recursively).
1487fn collect_called_fns(program: &Program) -> FxHashSet<Ident> {
1488    let mut set = FxHashSet::default();
1489    for node in program {
1490        collect_called_fns_node(node, &mut set);
1491    }
1492    set
1493}
1494
1495fn collect_called_fns_node(node: &Shared<ast::Node>, set: &mut FxHashSet<Ident>) {
1496    match &*node.expr {
1497        ast::Expr::Call(ident, args) => {
1498            set.insert(ident.name);
1499            for a in args {
1500                collect_called_fns_node(a, set);
1501            }
1502        }
1503        // An ident may be a first-class function value (e.g. passed to `map`, `filter`);
1504        // record it so we don't accidentally eliminate its Def.
1505        ast::Expr::Ident(ident) => {
1506            set.insert(ident.name);
1507        }
1508        ast::Expr::Def(_, _, body) | ast::Expr::Block(body) | ast::Expr::Loop(body) | ast::Expr::Module(_, body) => {
1509            for n in body {
1510                collect_called_fns_node(n, set);
1511            }
1512        }
1513        ast::Expr::Fn(_, body) => {
1514            for n in body {
1515                collect_called_fns_node(n, set);
1516            }
1517        }
1518        ast::Expr::If(branches) => {
1519            for (cond, body) in branches {
1520                if let Some(c) = cond {
1521                    collect_called_fns_node(c, set);
1522                }
1523                collect_called_fns_node(body, set);
1524            }
1525        }
1526        ast::Expr::And(ops) | ast::Expr::Or(ops) => {
1527            for o in ops {
1528                collect_called_fns_node(o, set);
1529            }
1530        }
1531        ast::Expr::Try(t, c) => {
1532            collect_called_fns_node(t, set);
1533            collect_called_fns_node(c, set);
1534        }
1535        ast::Expr::SelectorCall(_, args) => {
1536            for a in args {
1537                collect_called_fns_node(a, set);
1538            }
1539        }
1540        ast::Expr::CallDynamic(callee, args) => {
1541            collect_called_fns_node(callee, set);
1542            for a in args {
1543                collect_called_fns_node(a, set);
1544            }
1545        }
1546        ast::Expr::Let(_, rhs) | ast::Expr::Var(_, rhs) | ast::Expr::Assign(_, rhs) | ast::Expr::As(_, rhs) => {
1547            collect_called_fns_node(rhs, set);
1548        }
1549        ast::Expr::While(cond, body) | ast::Expr::Foreach(_, cond, body) => {
1550            collect_called_fns_node(cond, set);
1551            for n in body {
1552                collect_called_fns_node(n, set);
1553            }
1554        }
1555        ast::Expr::Match(val, arms) => {
1556            collect_called_fns_node(val, set);
1557            for arm in arms {
1558                if let Some(g) = &arm.guard {
1559                    collect_called_fns_node(g, set);
1560                }
1561                collect_called_fns_node(&arm.body, set);
1562            }
1563        }
1564        ast::Expr::Paren(inner) | ast::Expr::Break(Some(inner)) | ast::Expr::Unquote(inner) => {
1565            collect_called_fns_node(inner, set);
1566        }
1567        ast::Expr::InterpolatedString(segs) => {
1568            for seg in segs {
1569                if let StringSegment::Expr(n) = seg {
1570                    collect_called_fns_node(n, set);
1571                }
1572            }
1573        }
1574        _ => {}
1575    }
1576}
1577
1578/// Remove top-level `Def` nodes that were inlined everywhere they were called.
1579///
1580/// Only removes `Def`s that were candidates for inlining (present in `inlinable`) and
1581/// are no longer referenced by any `Call` in the program. Non-inlinable functions (recursive,
1582/// variadic, TCO-transformed) are always preserved because they may be called at runtime.
1583fn eliminate_dead_defs(program: Program, inlinable: &FxHashMap<Ident, InlinableFn>) -> Program {
1584    if inlinable.is_empty() {
1585        return program;
1586    }
1587    let used = collect_called_fns(&program);
1588    program
1589        .into_iter()
1590        .filter(|node| match &*node.expr {
1591            ast::Expr::Def(ident, _, _) => !inlinable.contains_key(&ident.name) || used.contains(&ident.name),
1592            _ => true,
1593        })
1594        .collect()
1595}
1596
1597fn literal_is_truthy(lit: &Literal) -> bool {
1598    match lit {
1599        Literal::Bool(b) => *b,
1600        Literal::Number(n) => !n.is_zero(),
1601        Literal::String(s) => !s.is_empty(),
1602        Literal::Symbol(_) => true,
1603        Literal::Bytes(b) => !b.is_empty(),
1604        Literal::None => false,
1605    }
1606}
1607
1608#[cfg(test)]
1609mod tests {
1610    use crate::{
1611        DefaultEngine,
1612        ast::node::{Expr, Literal},
1613        optimizer::OptimizationLevel,
1614    };
1615    use rstest::rstest;
1616
1617    /// Compile `query` at the given `level` and return the resulting AST.
1618    fn ast_with(query: &str, level: OptimizationLevel) -> crate::ast::Program {
1619        let mut engine = DefaultEngine::default();
1620        engine.set_optimization_level(level);
1621        engine.compile(query).unwrap().program().clone()
1622    }
1623
1624    fn ast_none(query: &str) -> crate::ast::Program {
1625        ast_with(query, OptimizationLevel::None)
1626    }
1627
1628    fn ast_basic(query: &str) -> crate::ast::Program {
1629        ast_with(query, OptimizationLevel::Basic)
1630    }
1631
1632    fn ast_full(query: &str) -> crate::ast::Program {
1633        ast_with(query, OptimizationLevel::Full)
1634    }
1635
1636    fn assert_literal(node: &crate::Shared<crate::AstNode>, expected: &str, ctx: &str) {
1637        match &*node.expr {
1638            Expr::Literal(lit) => assert_eq!(lit.to_string(), expected, "{ctx}"),
1639            other => panic!("{ctx}: expected Literal({expected:?}), got {other:?}"),
1640        }
1641    }
1642
1643    #[test]
1644    fn none_arithmetic_stays_as_call() {
1645        let prog = ast_none("1 + 2");
1646        assert_eq!(prog.len(), 1);
1647        assert!(
1648            matches!(&*prog[0].expr, Expr::Call(..)),
1649            "None: expected Call, got {:?}",
1650            prog[0].expr
1651        );
1652    }
1653
1654    #[test]
1655    fn none_consecutive_selectors_stay_separate() {
1656        let prog = ast_none(".h1 | .text");
1657        assert_eq!(prog.len(), 2, "None must not merge selectors");
1658        assert!(matches!(&*prog[0].expr, Expr::Selector(_)));
1659        assert!(matches!(&*prog[1].expr, Expr::Selector(_)));
1660    }
1661
1662    #[test]
1663    fn none_if_with_literal_cond_stays_as_if() {
1664        let prog = ast_none("if (true): 1 else: 2");
1665        assert_eq!(prog.len(), 1);
1666        assert!(
1667            matches!(&*prog[0].expr, Expr::If(_)),
1668            "None: expected If, got {:?}",
1669            prog[0].expr
1670        );
1671    }
1672
1673    #[test]
1674    fn none_and_stays_as_and() {
1675        let prog = ast_none("false && .");
1676        assert_eq!(prog.len(), 1);
1677        assert!(
1678            matches!(&*prog[0].expr, Expr::And(_)),
1679            "None: expected And, got {:?}",
1680            prog[0].expr
1681        );
1682    }
1683
1684    #[test]
1685    fn none_interpolated_string_stays_unfolded() {
1686        let prog = ast_none("s\"hello world\"");
1687        assert_eq!(prog.len(), 1);
1688        assert!(
1689            matches!(&*prog[0].expr, Expr::InterpolatedString(_)),
1690            "None: expected InterpolatedString, got {:?}",
1691            prog[0].expr
1692        );
1693    }
1694
1695    #[test]
1696    fn none_def_body_stays_as_if_no_tco() {
1697        let prog = ast_none("def countdown(n): if (n == 0): \"done\" else: countdown(n - 1);");
1698        assert_eq!(prog.len(), 1);
1699        let Expr::Def(_, _, body) = &*prog[0].expr else {
1700            panic!("expected Def");
1701        };
1702        assert!(
1703            !body.iter().any(|n| matches!(&*n.expr, Expr::Loop(_))),
1704            "None: must not apply TCO; Loop found in body"
1705        );
1706        assert!(
1707            body.iter().any(|n| matches!(&*n.expr, Expr::If(_))),
1708            "None: original If must remain in body"
1709        );
1710    }
1711
1712    #[rstest]
1713    #[case("1 + 2", "3")]
1714    #[case("10 - 3", "7")]
1715    #[case("3 * 4", "12")]
1716    #[case("10 / 2", "5")]
1717    #[case("10 % 3", "1")]
1718    #[case("\"hello\" + \" world\"", "hello world")]
1719    #[case("negate(5)", "-5")]
1720    #[case("not(false)", "true")]
1721    #[case("not(true)", "false")]
1722    fn fold_arithmetic_to_literal(#[case] query: &str, #[case] expected: &str) {
1723        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1724            let prog = ast_with(query, level);
1725            assert_eq!(prog.len(), 1, "{level:?}: expected single literal node");
1726            assert_literal(&prog[0], expected, &format!("{level:?}"));
1727        }
1728    }
1729
1730    #[rstest]
1731    #[case("1 == 1", "true")]
1732    #[case("1 == 2", "false")]
1733    #[case("1 != 2", "true")]
1734    #[case("1 != 1", "false")]
1735    #[case("1 < 2", "true")]
1736    #[case("2 < 1", "false")]
1737    #[case("2 <= 2", "true")]
1738    #[case("3 > 2", "true")]
1739    #[case("2 > 3", "false")]
1740    #[case("2 >= 2", "true")]
1741    fn fold_comparisons_to_literal(#[case] query: &str, #[case] expected: &str) {
1742        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1743            let prog = ast_with(query, level);
1744            assert_eq!(prog.len(), 1, "{level:?}: expected single literal node");
1745            assert_literal(&prog[0], expected, &format!("{level:?}"));
1746        }
1747    }
1748
1749    #[test]
1750    fn fold_nested_arithmetic_to_literal() {
1751        // (1 + 2) * (3 + 4) — both sub-expressions fold first, then the outer mul folds.
1752        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1753            let prog = ast_with("(1 + 2) * (3 + 4)", level);
1754            assert_eq!(prog.len(), 1, "{level:?}");
1755            assert_literal(&prog[0], "21", &format!("{level:?}"));
1756        }
1757    }
1758
1759    #[test]
1760    fn fold_double_negation_to_literal() {
1761        // not(not(true)) → true
1762        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1763            let prog = ast_with("not(not(true))", level);
1764            assert_eq!(prog.len(), 1, "{level:?}");
1765            assert_literal(&prog[0], "true", &format!("{level:?}"));
1766        }
1767    }
1768
1769    #[test]
1770    fn div_by_zero_not_folded() {
1771        // Division by zero must stay as Call — the evaluator handles the error.
1772        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1773            let prog = ast_with("1 / 0", level);
1774            assert_eq!(prog.len(), 1, "{level:?}");
1775            assert!(
1776                matches!(&*prog[0].expr, Expr::Call(..)),
1777                "{level:?}: div-by-zero must stay as Call, got {:?}",
1778                prog[0].expr
1779            );
1780        }
1781    }
1782
1783    #[test]
1784    fn dynamic_arg_prevents_folding() {
1785        // add(., 1): left operand is the pipeline value — not a literal, cannot fold.
1786        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1787            let prog = ast_with("add(., 1)", level);
1788            assert_eq!(prog.len(), 1, "{level:?}");
1789            assert!(
1790                matches!(&*prog[0].expr, Expr::Call(..)),
1791                "{level:?}: dynamic arg must prevent folding, got {:?}",
1792                prog[0].expr
1793            );
1794        }
1795    }
1796
1797    #[test]
1798    fn if_true_collapses_to_then_body() {
1799        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1800            let prog = ast_with("if (true): 1 else: 2", level);
1801            assert_eq!(prog.len(), 1, "{level:?}");
1802            assert_literal(&prog[0], "1", &format!("{level:?}: if(true)"));
1803        }
1804    }
1805
1806    #[test]
1807    fn if_false_collapses_to_else_body() {
1808        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1809            let prog = ast_with("if (false): 1 else: 2", level);
1810            assert_eq!(prog.len(), 1, "{level:?}");
1811            assert_literal(&prog[0], "2", &format!("{level:?}: if(false)+else"));
1812        }
1813    }
1814
1815    #[test]
1816    fn if_false_no_else_collapses_to_literal_none() {
1817        // All branches eliminated → optimizer emits Literal::None.
1818        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1819            let prog = ast_with("if (false): 1", level);
1820            assert_eq!(prog.len(), 1, "{level:?}");
1821            assert!(
1822                matches!(&*prog[0].expr, Expr::Literal(Literal::None)),
1823                "{level:?}: expected Literal(None), got {:?}",
1824                prog[0].expr
1825            );
1826        }
1827    }
1828
1829    #[test]
1830    fn if_foldable_condition_also_eliminated() {
1831        // Condition `1 == 1` folds to `true`, then branch is eliminated.
1832        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1833            let prog = ast_with("if (1 == 1): \"yes\" else: \"no\"", level);
1834            assert_eq!(prog.len(), 1, "{level:?}");
1835            assert_literal(&prog[0], "yes", &format!("{level:?}"));
1836        }
1837    }
1838
1839    #[test]
1840    fn if_dynamic_condition_stays_as_if() {
1841        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1842            let prog = ast_with("if (.): 1 else: 2", level);
1843            assert_eq!(prog.len(), 1, "{level:?}");
1844            assert!(
1845                matches!(&*prog[0].expr, Expr::If(_)),
1846                "{level:?}: dynamic condition must not eliminate branch, got {:?}",
1847                prog[0].expr
1848            );
1849        }
1850    }
1851
1852    #[test]
1853    fn elif_first_true_branch_wins() {
1854        // `if (false): 1 elif (true): 2 elif (true): 3 else: 4` → Literal(2)
1855        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1856            let prog = ast_with("if (false): 1 elif (true): 2 elif (true): 3 else: 4", level);
1857            assert_eq!(prog.len(), 1, "{level:?}");
1858            assert_literal(&prog[0], "2", &format!("{level:?}"));
1859        }
1860    }
1861
1862    #[test]
1863    fn elif_all_false_no_else_collapses_to_literal_none() {
1864        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1865            let prog = ast_with("if (false): 1 elif (false): 2 elif (false): 3", level);
1866            assert_eq!(prog.len(), 1, "{level:?}");
1867            assert!(
1868                matches!(&*prog[0].expr, Expr::Literal(Literal::None)),
1869                "{level:?}: expected Literal(None), got {:?}",
1870                prog[0].expr
1871            );
1872        }
1873    }
1874
1875    #[test]
1876    fn nested_if_both_levels_eliminated() {
1877        // `if (true): if (false): 1 else: 2 else: 3` → outer true → inner, inner false → 2
1878        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1879            let prog = ast_with("if (true): if (false): 1 else: 2 else: 3", level);
1880            assert_eq!(prog.len(), 1, "{level:?}");
1881            assert_literal(&prog[0], "2", &format!("{level:?}"));
1882        }
1883    }
1884
1885    #[test]
1886    fn and_falsy_literal_short_circuits_to_false() {
1887        // `false && .` — falsy operand → entire And becomes Literal(false).
1888        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1889            let prog = ast_with("false && .", level);
1890            assert_eq!(prog.len(), 1, "{level:?}");
1891            assert!(
1892                matches!(&*prog[0].expr, Expr::Literal(Literal::Bool(false))),
1893                "{level:?}: expected Literal(false), got {:?}",
1894                prog[0].expr
1895            );
1896        }
1897    }
1898
1899    #[test]
1900    fn and_truthy_literal_dropped_dynamic_preserved() {
1901        // `true && .` — truthy literal is dropped; remaining dynamic operand wrapped in And.
1902        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1903            let prog = ast_with("true && .", level);
1904            assert_eq!(prog.len(), 1, "{level:?}");
1905            assert!(
1906                matches!(&*prog[0].expr, Expr::And(_)),
1907                "{level:?}: expected And([.]), got {:?}",
1908                prog[0].expr
1909            );
1910        }
1911    }
1912
1913    #[test]
1914    fn and_all_truthy_collapses_to_literal_true() {
1915        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1916            let prog = ast_with("true && true && true", level);
1917            assert_eq!(prog.len(), 1, "{level:?}");
1918            assert!(
1919                matches!(&*prog[0].expr, Expr::Literal(Literal::Bool(true))),
1920                "{level:?}: expected Literal(true), got {:?}",
1921                prog[0].expr
1922            );
1923        }
1924    }
1925
1926    #[test]
1927    fn or_truthy_literal_short_circuits() {
1928        // `true || .` — truthy operand → entire Or becomes Literal(true).
1929        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1930            let prog = ast_with("true || .", level);
1931            assert_eq!(prog.len(), 1, "{level:?}");
1932            assert!(
1933                matches!(&*prog[0].expr, Expr::Literal(Literal::Bool(true))),
1934                "{level:?}: expected Literal(true), got {:?}",
1935                prog[0].expr
1936            );
1937        }
1938    }
1939
1940    #[test]
1941    fn or_falsy_literal_dropped_dynamic_preserved() {
1942        // `false || .` — falsy literal is dropped; remaining dynamic operand wrapped in Or.
1943        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1944            let prog = ast_with("false || .", level);
1945            assert_eq!(prog.len(), 1, "{level:?}");
1946            assert!(
1947                matches!(&*prog[0].expr, Expr::Or(_)),
1948                "{level:?}: expected Or([.]), got {:?}",
1949                prog[0].expr
1950            );
1951        }
1952    }
1953
1954    #[test]
1955    fn or_all_falsy_collapses_to_literal_false() {
1956        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1957            let prog = ast_with("false || false || false", level);
1958            assert_eq!(prog.len(), 1, "{level:?}");
1959            assert!(
1960                matches!(&*prog[0].expr, Expr::Literal(Literal::Bool(false))),
1961                "{level:?}: expected Literal(false), got {:?}",
1962                prog[0].expr
1963            );
1964        }
1965    }
1966
1967    #[rstest]
1968    #[case(".h1 | .text", 2usize)]
1969    #[case(".h1 | .text | .code", 3usize)]
1970    fn consecutive_selectors_merged_into_chain(#[case] query: &str, #[case] expected_len: usize) {
1971        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1972            let prog = ast_with(query, level);
1973            assert_eq!(prog.len(), 1, "{level:?}: expected single SelectorChain node");
1974            assert!(
1975                matches!(&*prog[0].expr, Expr::SelectorChain(c) if c.len() == expected_len),
1976                "{level:?}: expected SelectorChain(len={expected_len}), got {:?}",
1977                prog[0].expr
1978            );
1979        }
1980    }
1981
1982    #[test]
1983    fn single_selector_stays_as_selector_not_chain() {
1984        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1985            let prog = ast_with(".h1", level);
1986            assert_eq!(prog.len(), 1, "{level:?}");
1987            assert!(
1988                matches!(&*prog[0].expr, Expr::Selector(_)),
1989                "{level:?}: single selector must NOT become SelectorChain"
1990            );
1991        }
1992    }
1993
1994    #[rstest]
1995    #[case(".h1 | to_string | .text")]
1996    #[case(".h1 | len() | .text")]
1997    fn call_between_selectors_prevents_chain_merge(#[case] query: &str) {
1998        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
1999            let prog = ast_with(query, level);
2000            assert!(prog.len() > 1, "{level:?}: call between selectors must break the chain");
2001            assert!(
2002                !matches!(&*prog[0].expr, Expr::SelectorChain(_)),
2003                "{level:?}: must not merge selectors across a call"
2004            );
2005        }
2006    }
2007
2008    #[test]
2009    fn none_level_does_not_merge_selectors() {
2010        let prog = ast_none(".h1 | .text");
2011        assert_eq!(prog.len(), 2, "None must not merge consecutive selectors");
2012        assert!(matches!(&*prog[0].expr, Expr::Selector(_)));
2013        assert!(matches!(&*prog[1].expr, Expr::Selector(_)));
2014    }
2015
2016    #[test]
2017    fn selector_chain_inside_def_body_merged() {
2018        // Full inlines single-node def bodies and then eliminates the unused Def.
2019        // `.h1 | .text` is merged into SelectorChain during inlining, so the final program
2020        // has one top-level SelectorChain (the inlined call site) and no Def.
2021        let prog = ast_full("def extract: .h1 | .text; | extract()");
2022        assert!(
2023            prog.iter().any(|n| matches!(&*n.expr, Expr::SelectorChain(_))),
2024            "Full: inlined extract() must produce a top-level SelectorChain"
2025        );
2026        assert!(
2027            !prog.iter().any(|n| matches!(&*n.expr, Expr::Def(..))),
2028            "Full: fully-inlined Def must be eliminated"
2029        );
2030    }
2031
2032    #[rstest]
2033    #[case("s\"hello world\"", "hello world")]
2034    #[case("s\"static text only\"", "static text only")]
2035    fn all_text_interpolated_string_folded_to_literal(#[case] query: &str, #[case] expected: &str) {
2036        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2037            let prog = ast_with(query, level);
2038            assert_eq!(prog.len(), 1, "{level:?}");
2039            assert!(
2040                matches!(&*prog[0].expr, Expr::Literal(_)),
2041                "{level:?}: all-text interpolated string must fold to Literal"
2042            );
2043            assert_literal(&prog[0], expected, &format!("{level:?}"));
2044        }
2045    }
2046
2047    #[test]
2048    fn interpolated_string_with_dynamic_expr_not_folded() {
2049        // A dynamic segment (`${self}`) prevents folding.
2050        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2051            let prog = ast_with("s\"${self} end\"", level);
2052            assert_eq!(prog.len(), 1, "{level:?}");
2053            assert!(
2054                matches!(&*prog[0].expr, Expr::InterpolatedString(_)),
2055                "{level:?}: dynamic segment must prevent folding to Literal"
2056            );
2057        }
2058    }
2059
2060    #[test]
2061    fn none_level_does_not_fold_interpolated_string() {
2062        // Even a fully-static string must stay as InterpolatedString under None.
2063        let prog = ast_none("s\"hello world\"");
2064        assert_eq!(prog.len(), 1);
2065        assert!(
2066            matches!(&*prog[0].expr, Expr::InterpolatedString(_)),
2067            "None must not fold interpolated strings"
2068        );
2069    }
2070
2071    #[test]
2072    fn let_literal_propagated_and_folded_in_full() {
2073        // Full: x is bound to 5, substituted into `x + 1`, folded to 6.
2074        let prog = ast_full("let x = 5 | x + 1");
2075        assert_eq!(prog.len(), 2);
2076        assert_literal(&prog[1], "6", "Full: x+1 after propagation");
2077    }
2078
2079    #[test]
2080    fn let_literal_not_propagated_in_basic() {
2081        // Basic does not propagate — `x + 1` stays as Call with Ident.
2082        let prog = ast_basic("let x = 5 | x + 1");
2083        assert_eq!(prog.len(), 2);
2084        assert!(
2085            matches!(&*prog[1].expr, Expr::Call(..)),
2086            "Basic must not propagate let-literals; expected Call, got {:?}",
2087            prog[1].expr
2088        );
2089    }
2090
2091    #[test]
2092    fn let_rebind_propagates_latest_value() {
2093        // Second binding of x shadows the first; `x + 0` folds to 2.
2094        let prog = ast_full("let x = 1 | let x = 2 | x + 0");
2095        assert_eq!(prog.len(), 3);
2096        assert_literal(&prog[2], "2", "Full: second binding must shadow first");
2097    }
2098
2099    #[test]
2100    fn let_multiple_bindings_all_propagated() {
2101        // Three bindings, all propagated and folded into a single literal.
2102        let prog = ast_full("let a = 2 | let b = 3 | let c = 4 | a + b + c");
2103        assert_eq!(prog.len(), 4);
2104        assert_literal(&prog[3], "9", "Full: a+b+c after propagation");
2105    }
2106
2107    #[test]
2108    fn let_non_literal_rhs_not_propagated() {
2109        // `let x = add(1, .)` — RHS is not a literal (dynamic arg) → x is not registered.
2110        // Full folds `x + 0` to `x` via algebraic identity, but `x` stays as Ident (not a
2111        // literal), proving the let binding was not propagated to a constant.
2112        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2113            let prog = ast_with("let x = add(1, .) | x + 0", level);
2114            assert_eq!(prog.len(), 2, "{level:?}");
2115            assert!(
2116                !matches!(&*prog[1].expr, Expr::Literal(_)),
2117                "{level:?}: non-literal let must not propagate to a literal, got {:?}",
2118                prog[1].expr
2119            );
2120        }
2121    }
2122
2123    #[test]
2124    fn simple_function_inlined_and_folded_in_full() {
2125        // Full: double(4) → inlined to 4 * 2 → folded to Literal(8).
2126        let prog = ast_full("def double(x): x * 2; | double(4)");
2127        let last = prog.last().unwrap();
2128        assert!(
2129            matches!(&*last.expr, Expr::Literal(_)),
2130            "Full: inlined+folded call must be Literal, got {:?}",
2131            last.expr
2132        );
2133        assert_literal(last, "8", "Full: double(4)");
2134    }
2135
2136    #[test]
2137    fn function_call_stays_as_call_in_basic() {
2138        // Basic does not inline — the call node is preserved.
2139        let prog = ast_basic("def double(x): x * 2; | double(4)");
2140        let last = prog.last().unwrap();
2141        assert!(
2142            matches!(&*last.expr, Expr::Call(..)),
2143            "Basic must not inline; expected Call, got {:?}",
2144            last.expr
2145        );
2146    }
2147
2148    #[test]
2149    fn zero_param_constant_alias_inlined_in_full() {
2150        let prog = ast_full("def pi: 3; | pi()");
2151        let last = prog.last().unwrap();
2152        assert!(
2153            matches!(&*last.expr, Expr::Literal(_)),
2154            "Full: 0-param constant alias must inline to Literal, got {:?}",
2155            last.expr
2156        );
2157        assert_literal(last, "3", "Full: pi()");
2158    }
2159
2160    #[test]
2161    fn recursive_function_not_inlined() {
2162        // Recursive functions must never be inlined (infinite unrolling).
2163        let prog = ast_full("def fact(n): if (n == 0): 1 else: n * fact(n - 1); | fact(5)");
2164        let last = prog.last().unwrap();
2165        assert!(
2166            matches!(&*last.expr, Expr::Call(..)),
2167            "Full: recursive function must not be inlined; expected Call, got {:?}",
2168            last.expr
2169        );
2170    }
2171
2172    #[test]
2173    fn function_with_free_variable_not_inlined() {
2174        // `add_k` references `k` which is not a parameter → not inlineable.
2175        let prog = ast_full("let k = 10 | def add_k(x): x + k; | add_k(5)");
2176        let last = prog.last().unwrap();
2177        assert!(
2178            matches!(&*last.expr, Expr::Call(..)),
2179            "Full: function with free var must not be inlined; expected Call, got {:?}",
2180            last.expr
2181        );
2182    }
2183
2184    #[test]
2185    fn chained_inlining_collapses_to_literal() {
2186        // Both add1 and mul2 are inlineable; mul2(add1(3)) → (3+1)*2 → 8.
2187        let prog = ast_full("def add1(x): x + 1; | def mul2(x): x * 2; | mul2(add1(3))");
2188        let last = prog.last().unwrap();
2189        assert!(
2190            matches!(&*last.expr, Expr::Literal(_)),
2191            "Full: chained inline+fold must collapse to Literal, got {:?}",
2192            last.expr
2193        );
2194        assert_literal(last, "8", "Full: mul2(add1(3))");
2195    }
2196
2197    #[test]
2198    fn tco_tail_recursive_def_gets_loop_in_full() {
2199        let prog = ast_full("def countdown(n): if (n == 0): \"done\" else: countdown(n - 1);");
2200        let Expr::Def(_, _, body) = &*prog[0].expr else {
2201            panic!("expected Def");
2202        };
2203        assert!(
2204            body.iter().any(|n| matches!(&*n.expr, Expr::Loop(_))),
2205            "Full: TCO-transformed Def must contain a Loop node"
2206        );
2207        // The original top-level If must be replaced — not left alongside the Loop.
2208        assert!(
2209            !body.iter().any(|n| matches!(&*n.expr, Expr::If(_))),
2210            "Full: original If must be replaced by Loop after TCO"
2211        );
2212    }
2213
2214    #[test]
2215    fn tco_not_applied_in_basic() {
2216        let prog = ast_basic("def countdown(n): if (n == 0): \"done\" else: countdown(n - 1);");
2217        let Expr::Def(_, _, body) = &*prog[0].expr else {
2218            panic!("expected Def");
2219        };
2220        assert!(
2221            !body.iter().any(|n| matches!(&*n.expr, Expr::Loop(_))),
2222            "Basic must not apply TCO; Loop found unexpectedly"
2223        );
2224    }
2225
2226    #[test]
2227    fn tco_not_applied_in_none() {
2228        let prog = ast_none("def countdown(n): if (n == 0): \"done\" else: countdown(n - 1);");
2229        let Expr::Def(_, _, body) = &*prog[0].expr else {
2230            panic!("expected Def");
2231        };
2232        assert!(
2233            !body.iter().any(|n| matches!(&*n.expr, Expr::Loop(_))),
2234            "None must not apply TCO"
2235        );
2236    }
2237
2238    #[test]
2239    fn tco_not_applied_to_non_tail_call() {
2240        // `n * fact(n-1)` is a binary op wrapping the recursive call — NOT a tail call.
2241        let prog = ast_full("def fact(n): if (n == 0): 1 else: n * fact(n - 1);");
2242        let Expr::Def(_, _, body) = &*prog[0].expr else {
2243            panic!("expected Def");
2244        };
2245        assert!(
2246            !body.iter().any(|n| matches!(&*n.expr, Expr::Loop(_))),
2247            "Full: non-tail-recursive function must not be TCO-transformed"
2248        );
2249    }
2250
2251    #[test]
2252    fn tco_multi_param_def_gets_loop() {
2253        let prog = ast_full("def loop2(a, b): if (a == 0): b else: loop2(a - 1, b + 1);");
2254        let Expr::Def(_, _, body) = &*prog[0].expr else {
2255            panic!("expected Def");
2256        };
2257        assert!(
2258            body.iter().any(|n| matches!(&*n.expr, Expr::Loop(_))),
2259            "Full: multi-param tail-recursive Def must contain Loop"
2260        );
2261    }
2262
2263    #[test]
2264    fn inline_then_constant_fold() {
2265        // double(3 + 4): argument folds to 7, then inlined body 7 * 2 folds to 14.
2266        let prog = ast_full("def double(x): x * 2; | double(3 + 4)");
2267        let last = prog.last().unwrap();
2268        assert_literal(last, "14", "Full: double(3+4)");
2269    }
2270
2271    #[test]
2272    fn inline_reveals_dead_branch() {
2273        // always_false(0) inlines to `0 == 999`, folds to false, if-branch eliminated.
2274        let prog = ast_full("def always_false(x): x == 999; | if (always_false(0)): \"bad\" else: \"good\"");
2275        let last = prog.last().unwrap();
2276        assert!(
2277            matches!(&*last.expr, Expr::Literal(_)),
2278            "Full: inline+dead-branch must collapse to Literal, got {:?}",
2279            last.expr
2280        );
2281        assert_literal(last, "good", "Full: always_false(0)");
2282    }
2283
2284    #[test]
2285    fn let_propagation_enables_inline_and_fold() {
2286        // n=9 propagated, inc(9) inlined to 9+1, folded to 10.
2287        let prog = ast_full("def inc(x): x + 1; | let n = 9 | inc(n)");
2288        let last = prog.last().unwrap();
2289        assert!(
2290            matches!(&*last.expr, Expr::Literal(_)),
2291            "Full: propagation+inline+fold must collapse to Literal, got {:?}",
2292            last.expr
2293        );
2294        assert_literal(last, "10", "Full: inc(n) where n=9");
2295    }
2296
2297    #[test]
2298    fn fold_then_dead_branch_elimination() {
2299        // `if (1 + 2 == 3): "yes" else: "no"` — fold condition first, then eliminate branch.
2300        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2301            let prog = ast_with("if (1 + 2 == 3): \"yes\" else: \"no\"", level);
2302            assert_eq!(prog.len(), 1, "{level:?}");
2303            assert_literal(&prog[0], "yes", &format!("{level:?}"));
2304        }
2305    }
2306
2307    #[test]
2308    fn let_propagation_into_and_or_operands() {
2309        // `let n = 0 | n == 0 && true` — n substituted, `0 == 0` folds to true,
2310        // then `true && true` collapses to Literal(true).
2311        let prog = ast_full("let n = 0 | n == 0 && true");
2312        let last = prog.last().unwrap();
2313        assert!(
2314            matches!(&*last.expr, Expr::Literal(Literal::Bool(true))),
2315            "Full: propagation+and fold must collapse to Literal(true), got {:?}",
2316            last.expr
2317        );
2318    }
2319
2320    #[rstest]
2321    #[case("floor(3.9)", "3")]
2322    #[case("ceil(3.1)", "4")]
2323    #[case("round(3.5)", "4")]
2324    #[case("abs(-7)", "7")]
2325    #[case("trunc(3.9)", "3")]
2326    fn numeric_unary_folds(#[case] query: &str, #[case] expected: &str) {
2327        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2328            let prog = ast_with(query, level);
2329            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2330            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2331        }
2332    }
2333
2334    #[rstest]
2335    #[case("len(\"hello\")", "5")]
2336    #[case("len(\"\")", "0")]
2337    fn len_string_folds(#[case] query: &str, #[case] expected: &str) {
2338        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2339            let prog = ast_with(query, level);
2340            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2341            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2342        }
2343    }
2344
2345    #[rstest]
2346    #[case("to_string(42)", "42")]
2347    #[case("to_string(3.5)", "3.5")]
2348    #[case("to_string(true)", "true")]
2349    #[case("to_string(false)", "false")]
2350    fn to_string_folds(#[case] query: &str, #[case] expected: &str) {
2351        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2352            let prog = ast_with(query, level);
2353            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2354            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2355        }
2356    }
2357
2358    #[rstest]
2359    #[case("to_number(\"42\")", "42")]
2360    #[case("to_number(\"3.14\")", "3.14")]
2361    fn to_number_folds(#[case] query: &str, #[case] expected: &str) {
2362        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2363            let prog = ast_with(query, level);
2364            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2365            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2366        }
2367    }
2368
2369    #[test]
2370    fn to_number_invalid_string_not_folded() {
2371        // Runtime would return an error — optimizer must not fold.
2372        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2373            let prog = ast_with("to_number(\"abc\")", level);
2374            assert_eq!(prog.len(), 1, "{level:?}");
2375            assert!(
2376                matches!(&*prog[0].expr, Expr::Call(..)),
2377                "{level:?}: unparsable to_number must stay as Call, got {:?}",
2378                prog[0].expr
2379            );
2380        }
2381    }
2382
2383    #[rstest]
2384    #[case("lt(\"a\", \"b\")", "true")] // #[case(..)]  "a" < "b"
2385    #[case("gt(\"b\", \"a\")", "true")]
2386    #[case("lte(\"a\", \"a\")", "true")]
2387    #[case("gte(\"b\", \"a\")", "true")]
2388    #[case("lt(\"b\", \"a\")", "false")]
2389    fn string_comparison_folds(#[case] query: &str, #[case] expected: &str) {
2390        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2391            let prog = ast_with(query, level);
2392            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2393            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2394        }
2395    }
2396
2397    #[test]
2398    fn algebraic_identity_add_zero() {
2399        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2400            let prog = ast_with(". + 0", level);
2401            assert_eq!(prog.len(), 1, "{level:?}");
2402            assert!(
2403                matches!(&*prog[0].expr, Expr::Self_),
2404                "{level:?}: . + 0 must fold to Self_, got {:?}",
2405                prog[0].expr
2406            );
2407        }
2408    }
2409
2410    #[test]
2411    fn algebraic_identity_mul_zero() {
2412        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2413            let prog = ast_with(". * 0", level);
2414            assert_eq!(prog.len(), 1, "{level:?}");
2415            assert_literal(&prog[0], "0", &format!("{level:?}: . * 0"));
2416        }
2417    }
2418
2419    #[test]
2420    fn algebraic_identity_mul_one() {
2421        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2422            let prog = ast_with(". * 1", level);
2423            assert_eq!(prog.len(), 1, "{level:?}");
2424            assert!(
2425                matches!(&*prog[0].expr, Expr::Self_),
2426                "{level:?}: . * 1 must fold to Self_, got {:?}",
2427                prog[0].expr
2428            );
2429        }
2430    }
2431
2432    #[test]
2433    fn algebraic_identity_div_one() {
2434        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2435            let prog = ast_with(". / 1", level);
2436            assert_eq!(prog.len(), 1, "{level:?}");
2437            assert!(
2438                matches!(&*prog[0].expr, Expr::Self_),
2439                "{level:?}: . / 1 must fold to Self_, got {:?}",
2440                prog[0].expr
2441            );
2442        }
2443    }
2444
2445    #[rstest]
2446    #[case("trim(\"  hi  \")", "hi")]
2447    #[case("ltrim(\"  hi\")", "hi")]
2448    #[case("rtrim(\"hi  \")", "hi")]
2449    #[case("upcase(\"hello\")", "HELLO")]
2450    #[case("downcase(\"WORLD\")", "world")]
2451    fn string_transform_folds(#[case] query: &str, #[case] expected: &str) {
2452        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2453            let prog = ast_with(query, level);
2454            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2455            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2456        }
2457    }
2458
2459    #[rstest]
2460    #[case("starts_with(\"hello\", \"he\")", "true")]
2461    #[case("starts_with(\"hello\", \"wo\")", "false")]
2462    #[case("ends_with(\"hello\", \"lo\")", "true")]
2463    #[case("ends_with(\"hello\", \"he\")", "false")]
2464    #[case("index(\"hello\", \"ll\")", "2")]
2465    #[case("index(\"hello\", \"xx\")", "-1")]
2466    #[case("rindex(\"hello\", \"l\")", "3")]
2467    fn string_search_folds(#[case] query: &str, #[case] expected: &str) {
2468        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2469            let prog = ast_with(query, level);
2470            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2471            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2472        }
2473    }
2474
2475    #[rstest]
2476    #[case("replace(\"hello\", \"l\", \"r\")", "herro")]
2477    #[case("replace(\"aaa\", \"a\", \"b\")", "bbb")]
2478    fn replace_folds(#[case] query: &str, #[case] expected: &str) {
2479        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2480            let prog = ast_with(query, level);
2481            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2482            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2483        }
2484    }
2485
2486    #[test]
2487    fn coalesce_none_folded() {
2488        // coalesce(None, .) → Self_ (rhs is dynamic; None is Literal::None)
2489        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2490            let prog = ast_with("coalesce(None, .)", level);
2491            assert_eq!(prog.len(), 1, "{level:?}");
2492            assert!(
2493                matches!(&*prog[0].expr, Expr::Self_),
2494                "{level:?}: coalesce(None, .) must fold to Self_, got {:?}",
2495                prog[0].expr
2496            );
2497        }
2498    }
2499
2500    #[test]
2501    fn coalesce_non_none_lhs_folded() {
2502        // coalesce("hi", .) → "hi" (lhs is non-none, rhs is never evaluated)
2503        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2504            let prog = ast_with("coalesce(\"hi\", .)", level);
2505            assert_eq!(prog.len(), 1, "{level:?}");
2506            assert_literal(&prog[0], "hi", &format!("{level:?}: coalesce(\"hi\", .)"));
2507        }
2508    }
2509
2510    #[test]
2511    fn coalesce_both_literals_folded() {
2512        // coalesce(None, 42) → 42 (both are literals)
2513        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2514            let prog = ast_with("coalesce(None, 42)", level);
2515            assert_eq!(prog.len(), 1, "{level:?}");
2516            assert_literal(&prog[0], "42", &format!("{level:?}: coalesce(None, 42)"));
2517        }
2518    }
2519
2520    #[test]
2521    fn user_def_shadows_builtin_uses_user_semantics() {
2522        // User's `upcase(x): x` is an identity — it gets inlined, producing "hello".
2523        // The builtin would produce "HELLO". Verifying we get "hello" proves the
2524        // user's definition was used (via inlining), not the builtin's constant fold.
2525        let prog = ast_full("def upcase(x): x; | upcase(\"hello\")");
2526        let last = prog.last().unwrap();
2527        assert_literal(
2528            last,
2529            "hello",
2530            "Full: user-shadowed upcase must produce identity, not builtin 'HELLO'",
2531        );
2532    }
2533
2534    #[test]
2535    fn user_def_shadows_trim_uses_user_semantics() {
2536        // User's `trim(x): x` is an identity. Builtin would strip spaces → "hi".
2537        // After inlining, result should be "  hi  " (spaces preserved).
2538        let prog = ast_full("def trim(x): x; | trim(\"  hi  \")");
2539        let last = prog.last().unwrap();
2540        assert_literal(last, "  hi  ", "Full: user-shadowed trim must preserve spaces");
2541    }
2542
2543    #[test]
2544    fn non_shadowed_builtin_still_folded() {
2545        // Having an unrelated user def must not block folding of other builtins.
2546        let prog = ast_full("def my_fn(x): x; | upcase(\"hello\")");
2547        let last = prog.last().unwrap();
2548        assert_literal(last, "HELLO", "Full: non-shadowed upcase must fold");
2549    }
2550
2551    #[rstest]
2552    #[case("trim(\"  a  \")", "a")]
2553    #[case("ltrim(\"  a\")", "a")]
2554    #[case("rtrim(\"a  \")", "a")]
2555    #[case("trim(\"\")", "")]
2556    #[case("upcase(\"abc\")", "ABC")]
2557    #[case("downcase(\"XYZ\")", "xyz")]
2558    #[case("upcase(\"123\")", "123")]
2559    fn string_ops_fold(#[case] query: &str, #[case] expected: &str) {
2560        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2561            let prog = ast_with(query, level);
2562            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2563            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2564        }
2565    }
2566
2567    #[test]
2568    fn string_ops_with_dynamic_arg_not_folded() {
2569        for q in ["trim(.)", "upcase(.)"] {
2570            for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2571                let prog = ast_with(q, level);
2572                assert_eq!(prog.len(), 1, "{level:?}: {q}");
2573                assert!(
2574                    matches!(&*prog[0].expr, Expr::Call(..)),
2575                    "{level:?}: {q} must remain Call"
2576                );
2577            }
2578        }
2579    }
2580
2581    #[rstest]
2582    #[case("starts_with(\"hello\", \"he\")", "true")]
2583    #[case("starts_with(\"hello\", \"lo\")", "false")]
2584    #[case("ends_with(\"world\", \"ld\")", "true")]
2585    #[case("ends_with(\"world\", \"wo\")", "false")]
2586    #[case("index(\"abcabc\", \"bc\")", "1")]
2587    #[case("rindex(\"abcabc\", \"bc\")", "4")]
2588    #[case("index(\"abc\", \"x\")", "-1")]
2589    fn string_search_fold(#[case] query: &str, #[case] expected: &str) {
2590        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2591            let prog = ast_with(query, level);
2592            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2593            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2594        }
2595    }
2596
2597    #[rstest]
2598    #[case("replace(\"aabbcc\", \"b\", \"x\")", "aaxxcc")]
2599    #[case("replace(\"hello\", \"l\", \"\")", "heo")]
2600    #[case("replace(\"\", \"x\", \"y\")", "")]
2601    fn replace_fold(#[case] query: &str, #[case] expected: &str) {
2602        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2603            let prog = ast_with(query, level);
2604            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2605            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2606        }
2607    }
2608
2609    #[rstest]
2610    #[case("floor(3.7)", "3")]
2611    #[case("floor(-1.2)", "-2")]
2612    #[case("ceil(3.1)", "4")]
2613    #[case("ceil(-1.8)", "-1")]
2614    #[case("round(2.5)", "3")]
2615    #[case("round(2.4)", "2")]
2616    #[case("abs(-5)", "5")]
2617    #[case("abs(5)", "5")]
2618    #[case("trunc(3.9)", "3")]
2619    #[case("trunc(-3.9)", "-3")]
2620    fn numeric_math_folds(#[case] query: &str, #[case] expected: &str) {
2621        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2622            let prog = ast_with(query, level);
2623            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2624            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2625        }
2626    }
2627
2628    #[test]
2629    fn numeric_math_dynamic_not_folded() {
2630        for q in ["floor(.)", "abs(.)"] {
2631            let prog = ast_basic(q);
2632            assert!(
2633                matches!(&*prog[0].expr, Expr::Call(..)),
2634                "{q} with dynamic arg must stay Call"
2635            );
2636        }
2637    }
2638
2639    #[rstest]
2640    #[case("len(\"\")", "0")]
2641    #[case("len(\"hello\")", "5")]
2642    #[case("len(\"こんにちは\")", "5")] // Unicode: 5 chars
2643    fn len_folds(#[case] query: &str, #[case] expected: &str) {
2644        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2645            let prog = ast_with(query, level);
2646            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2647            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2648        }
2649    }
2650
2651    #[rstest]
2652    #[case("to_string(0)", "0")]
2653    #[case("to_string(\"hi\")", "hi")]
2654    #[case("to_string(true)", "true")]
2655    fn to_string_lit_folds(#[case] query: &str, #[case] expected: &str) {
2656        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2657            let prog = ast_with(query, level);
2658            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2659            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2660        }
2661    }
2662
2663    #[rstest]
2664    #[case("to_number(\"-1\")", "-1")]
2665    #[case("to_number(\"0.5\")", "0.5")]
2666    fn to_number_lit_folds(#[case] query: &str, #[case] expected: &str) {
2667        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2668            let prog = ast_with(query, level);
2669            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2670            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2671        }
2672    }
2673
2674    #[rstest]
2675    #[case(". + 0")]
2676    #[case("0 + .")]
2677    #[case(". - 0")]
2678    #[case(". * 1")]
2679    #[case("1 * .")]
2680    #[case(". / 1")]
2681    fn algebraic_identity_returns_self(#[case] query: &str) {
2682        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2683            let prog = ast_with(query, level);
2684            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2685            assert!(
2686                matches!(&*prog[0].expr, Expr::Self_),
2687                "{level:?}: {query} must fold to Self_"
2688            );
2689        }
2690    }
2691
2692    #[rstest]
2693    #[case(". * 0")]
2694    #[case("0 * .")]
2695    fn algebraic_mul_zero_returns_literal_zero(#[case] query: &str) {
2696        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2697            let prog = ast_with(query, level);
2698            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2699            assert_literal(&prog[0], "0", &format!("{level:?}: {query}"));
2700        }
2701    }
2702
2703    #[rstest]
2704    #[case(". + \"\"")]
2705    #[case("\"\" + .")]
2706    fn algebraic_add_empty_string_returns_self(#[case] query: &str) {
2707        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2708            let prog = ast_with(query, level);
2709            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2710            assert!(
2711                matches!(&*prog[0].expr, Expr::Self_),
2712                "{level:?}: {query} must fold to Self_"
2713            );
2714        }
2715    }
2716
2717    #[test]
2718    fn constant_fold_inside_block() {
2719        // Blocks are nested scopes; constant folding must still apply.
2720        let prog = ast_basic("(1 + 2)");
2721        assert_eq!(prog.len(), 1);
2722        assert_literal(&prog[0], "3", "Basic: (1+2) inside Paren must fold");
2723    }
2724
2725    #[test]
2726    fn selector_chain_merged_inside_while_body() {
2727        // The selector chain inside a while condition (2 selectors) must merge.
2728        // while(.h1 | .text) is only 2 nodes in the condition, but the condition
2729        // itself is a single Call node; so we check a def body instead.
2730        let prog = ast_basic("def f: .h1 | .text;");
2731        let Expr::Def(_, _, body) = &*prog[0].expr else {
2732            panic!("expected Def");
2733        };
2734        assert!(
2735            body.iter().any(|n| matches!(&*n.expr, Expr::SelectorChain(_))),
2736            "Basic: SelectorChain must be merged inside Def body"
2737        );
2738    }
2739
2740    #[test]
2741    fn nested_let_literal_not_propagated_across_scope() {
2742        // A let binding defined at the top level must not propagate into a nested
2743        // def body — they are separate scopes.
2744        let prog = ast_full("let x = 99 | def f: x;");
2745        let Expr::Def(_, _, body) = &*prog.iter().find(|n| matches!(&*n.expr, Expr::Def(..))).unwrap().expr else {
2746            panic!("expected Def");
2747        };
2748        assert!(
2749            matches!(&*body[0].expr, Expr::Ident(_)),
2750            "Full: top-level let must not propagate into def body, got {:?}",
2751            body[0].expr
2752        );
2753    }
2754
2755    #[test]
2756    fn inlined_def_is_eliminated() {
2757        // After inlining, the Def is no longer called → eliminated.
2758        let prog = ast_full("def double(x): x * 2; | double(5)");
2759        assert!(
2760            !prog.iter().any(|n| matches!(&*n.expr, Expr::Def(..))),
2761            "Full: fully-inlined Def must be eliminated from the program"
2762        );
2763        let last = prog.last().unwrap();
2764        assert_literal(last, "10", "Full: double(5) must fold to 10");
2765    }
2766
2767    #[test]
2768    fn non_inlinable_def_preserved() {
2769        // A recursive Def is not inlinable → must be kept.
2770        let prog = ast_full("def count(n): if (n == 0): 0 else: count(n - 1);");
2771        assert!(
2772            prog.iter().any(|n| matches!(&*n.expr, Expr::Def(..))),
2773            "Full: non-inlinable Def must be preserved"
2774        );
2775    }
2776
2777    #[test]
2778    fn def_passed_as_value_not_eliminated() {
2779        // When a Def is passed as a first-class function value, it must not be eliminated.
2780        let prog = ast_full("def is_pos(x): gt(x, 0); | filter(array(1, -1, 2), is_pos)");
2781        assert!(
2782            prog.iter().any(|n| matches!(&*n.expr, Expr::Def(..))),
2783            "Full: Def passed as first-class value must be preserved"
2784        );
2785    }
2786
2787    #[test]
2788    fn tco_only_in_full() {
2789        let query = "def sum(n): if (n == 0): 0 else: sum(n - 1);";
2790        let none_prog = ast_none(query);
2791        let basic_prog = ast_basic(query);
2792        let full_prog = ast_full(query);
2793
2794        let has_loop = |prog: &crate::ast::Program| {
2795            prog.iter().any(|n| {
2796                if let Expr::Def(_, _, body) = &*n.expr {
2797                    body.iter().any(|b| matches!(&*b.expr, Expr::Loop(_)))
2798                } else {
2799                    false
2800                }
2801            })
2802        };
2803        assert!(!has_loop(&none_prog), "None: no TCO");
2804        assert!(!has_loop(&basic_prog), "Basic: no TCO");
2805        assert!(has_loop(&full_prog), "Full: TCO must apply");
2806    }
2807
2808    #[test]
2809    fn none_level_is_identity() {
2810        // At None, the program must be returned byte-for-byte unchanged.
2811        let queries = ["1 + 2", "if (true): 1 else: 2", "false && .", "def f(x): x + 1; | f(5)"];
2812        for q in queries {
2813            let none = ast_none(q);
2814            let basic = ast_basic(q);
2815            assert!(!none.is_empty(), "None: {q} must produce nodes");
2816            assert!(none.len() >= basic.len(), "None: {q}");
2817        }
2818    }
2819
2820    #[test]
2821    fn chained_string_ops_fold() {
2822        // upcase(trim("  hello  ")) → upcase("hello") → "HELLO"
2823        let prog = ast_full("upcase(trim(\"  hello  \"))");
2824        assert_eq!(prog.len(), 1);
2825        assert_literal(&prog[0], "HELLO", "Full: chained upcase(trim(...)) must fold");
2826    }
2827
2828    #[test]
2829    fn nested_arithmetic_folds() {
2830        // floor(abs(-3.7) + 1) → floor(3.7 + 1) → floor(4.7) → 4
2831        let prog = ast_full("floor(abs(-3.7) + 1)");
2832        assert_eq!(prog.len(), 1);
2833        assert_literal(&prog[0], "4", "Full: nested floor(abs+add) must fold");
2834    }
2835
2836    #[test]
2837    fn let_chain_propagation() {
2838        // let a = 1 | let b = 2 | let c = a + b → 3
2839        let prog = ast_full("let a = 1 | let b = 2 | a + b");
2840        let last = prog.last().unwrap();
2841        assert_literal(last, "3", "Full: chained let propagation must fold to 3");
2842    }
2843
2844    #[test]
2845    fn let_and_string_propagation() {
2846        // let prefix = "Hello" | starts_with(prefix, "He") → true
2847        let prog = ast_full("let prefix = \"Hello\" | starts_with(prefix, \"He\")");
2848        let last = prog.last().unwrap();
2849        assert_literal(last, "true", "Full: let+starts_with must fold");
2850    }
2851
2852    #[test]
2853    fn full_pipeline_fold_then_dead_branch() {
2854        // let x = 5 | if (x == 5): "yes" else: "no"
2855        // Full: x substituted → if (true): "yes" → "yes"
2856        let prog = ast_full("let x = 5 | if (x == 5): \"yes\" else: \"no\"");
2857        let last = prog.last().unwrap();
2858        assert_literal(last, "yes", "Full: propagate+fold+dead-branch must yield \"yes\"");
2859    }
2860
2861    #[test]
2862    fn basic_does_not_propagate_let() {
2863        // Basic does not do let-literal propagation.
2864        let prog = ast_basic("let x = 5 | if (x == 5): \"yes\" else: \"no\"");
2865        let last = prog.last().unwrap();
2866        assert!(
2867            matches!(&*last.expr, Expr::If(_)),
2868            "Basic: let propagation must not happen, expected If, got {:?}",
2869            last.expr
2870        );
2871    }
2872
2873    #[test]
2874    fn module_scoped_def_not_mistaken_for_builtin_shadow() {
2875        // A Def inside a Module body must not prevent folding of the same-named
2876        // builtin at the top level.
2877        let prog = ast_full("upcase(\"hello\") | module m: def upcase(x): x; end");
2878        // The upcase("hello") call at the top level should be folded.
2879        let first = &prog[0];
2880        assert_literal(
2881            first,
2882            "HELLO",
2883            "Full: module-internal def must not shadow top-level fold",
2884        );
2885    }
2886
2887    // ---- len on bytes literal folds ----
2888    #[rstest]
2889    #[case("len(b\"hello\")", "5")]
2890    #[case("len(b\"\")", "0")]
2891    fn len_bytes_folds(#[case] query: &str, #[case] expected: &str) {
2892        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2893            let prog = ast_with(query, level);
2894            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2895            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2896        }
2897    }
2898
2899    // ---- to_string on None and Bool folds ----
2900    #[rstest]
2901    #[case("to_string(None)", "")]
2902    fn to_string_none_folds(#[case] query: &str, #[case] expected: &str) {
2903        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2904            let prog = ast_with(query, level);
2905            assert_eq!(prog.len(), 1, "{level:?}: {query}");
2906            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
2907        }
2908    }
2909
2910    // ---- Bytes literal stays as Call for to_string (not foldable) ----
2911    #[test]
2912    fn to_string_bytes_not_folded() {
2913        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2914            let prog = ast_with("to_string(b\"hi\")", level);
2915            assert_eq!(prog.len(), 1, "{level:?}");
2916            assert!(
2917                matches!(&*prog[0].expr, Expr::Call(..)),
2918                "{level:?}: to_string(bytes) must stay as Call"
2919            );
2920        }
2921    }
2922
2923    // ---- NaN-guarded arithmetic does not fold ----
2924    #[test]
2925    fn nan_arithmetic_not_folded() {
2926        // nan() is not a literal, so arithmetic on it cannot be constant-folded.
2927        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2928            let prog = ast_with("floor(nan())", level);
2929            assert_eq!(prog.len(), 1, "{level:?}");
2930            assert!(
2931                matches!(&*prog[0].expr, Expr::Call(..)),
2932                "{level:?}: floor(nan()) must not fold"
2933            );
2934        }
2935    }
2936
2937    // ---- substitute_literals into And/Or/Paren/Try/Break/InterpolatedString ----
2938    #[test]
2939    fn let_propagation_into_try_block() {
2940        // let x = "ok" | try: x catch: "err" — x is substituted, try is preserved because it might error.
2941        let prog = ast_full("let x = 5 | try: x catch: 0");
2942        assert_eq!(prog.len(), 2, "Full: let + try must produce 2 nodes");
2943    }
2944
2945    #[test]
2946    fn let_propagation_into_interpolated_string() {
2947        // let x = "hi" | s"${x} world" → substitute x, fold to literal "hi world"
2948        let prog = ast_full("let x = \"hi\" | s\"${x} world\"");
2949        let last = prog.last().unwrap();
2950        assert_literal(last, "hi world", "Full: let+interpolated string must fold");
2951    }
2952
2953    #[test]
2954    fn let_propagation_into_paren() {
2955        // let x = 3 | (x + 1) → substitute x=3, fold 3+1=4
2956        let prog = ast_full("let x = 3 | (x + 1)");
2957        let last = prog.last().unwrap();
2958        assert_literal(last, "4", "Full: let+paren must fold");
2959    }
2960
2961    // ---- Dead-def after inlining multiple call sites ----
2962    #[test]
2963    fn two_call_sites_both_inlined_def_eliminated() {
2964        // inc is called twice — both sites get inlined, so def is eliminated.
2965        let prog = ast_full("def inc(x): x + 1; | inc(3) | inc(7)");
2966        assert!(
2967            !prog.iter().any(|n| matches!(&*n.expr, Expr::Def(..))),
2968            "Full: Def with two inlined call sites must be eliminated"
2969        );
2970        let last = prog.last().unwrap();
2971        assert_literal(last, "8", "Full: inc(7) must fold to 8");
2972    }
2973
2974    // ---- optimize_node handles While / Loop / Foreach / Assign ----
2975    #[test]
2976    fn optimize_while_folds_constant_in_body() {
2977        // while(true): 1 + 1 — body constant should fold.
2978        let prog = ast_basic("while(true): 1 + 1;");
2979        assert_eq!(prog.len(), 1);
2980        // The while node itself must remain (condition is not false-literal).
2981        assert!(
2982            matches!(&*prog[0].expr, Expr::While(..)),
2983            "Basic: while must remain when condition is dynamic-ish"
2984        );
2985    }
2986
2987    #[test]
2988    fn optimize_try_with_constant_folds_body() {
2989        // try: 1 + 2 catch: 0 — body must fold to 3.
2990        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
2991            let prog = ast_with("try: 1 + 2 catch: 0", level);
2992            assert_eq!(prog.len(), 1, "{level:?}");
2993            // The Try node remains because catch matters even when body is constant.
2994            assert!(
2995                matches!(&*prog[0].expr, Expr::Try(..)),
2996                "{level:?}: Try must remain; got {:?}",
2997                prog[0].expr
2998            );
2999        }
3000    }
3001
3002    #[test]
3003    fn optimize_foreach_folds_constant_values() {
3004        // foreach(x, [1]): 2 + 3 — body constant 5 should fold.
3005        let prog = ast_basic("foreach(x, [1]): 2 + 3;");
3006        assert_eq!(prog.len(), 1, "Basic: foreach must be single node");
3007        let Expr::Foreach(_, _, body) = &*prog[0].expr else {
3008            panic!("expected Foreach");
3009        };
3010        assert!(
3011            body.iter().any(|n| matches!(&*n.expr, Expr::Literal(_))),
3012            "Basic: Foreach body must have folded constant"
3013        );
3014    }
3015
3016    #[test]
3017    fn optimize_match_folds_constant_in_arms() {
3018        // match(1+2) do | 3: "three" | _: "other" end — condition 1+2 folds to 3.
3019        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
3020            let prog = ast_with("match(1 + 2) do | 3: \"three\" | _: \"other\" end", level);
3021            assert_eq!(prog.len(), 1, "{level:?}");
3022            // The match node remains but its value should be folded.
3023            assert!(
3024                matches!(&*prog[0].expr, Expr::Match(..)),
3025                "{level:?}: Match must remain when value is not a pattern-eliminating literal"
3026            );
3027        }
3028    }
3029
3030    // ---- algebraic identity for sub(x, 0) ----
3031    #[test]
3032    fn algebraic_identity_sub_zero() {
3033        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
3034            let prog = ast_with(". - 0", level);
3035            assert_eq!(prog.len(), 1, "{level:?}");
3036            assert!(
3037                matches!(&*prog[0].expr, Expr::Self_),
3038                "{level:?}: . - 0 must fold to Self_"
3039            );
3040        }
3041    }
3042
3043    // ---- EQ/NE fold across mixed literal types ----
3044    #[rstest]
3045    #[case("eq(1, \"one\")", "false")]
3046    #[case("ne(1, \"one\")", "true")]
3047    #[case("eq(None, None)", "true")]
3048    #[case("ne(None, 0)", "true")]
3049    fn eq_ne_cross_type_fold(#[case] query: &str, #[case] expected: &str) {
3050        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
3051            let prog = ast_with(query, level);
3052            assert_eq!(prog.len(), 1, "{level:?}: {query}");
3053            assert_literal(&prog[0], expected, &format!("{level:?}: {query}"));
3054        }
3055    }
3056
3057    // ---- multiple user defs, some inlinable, some not ----
3058    #[test]
3059    fn mixed_inline_and_non_inline_defs() {
3060        // double is inlineable (simple); fact is recursive (not inlineable).
3061        let prog = ast_full(
3062            "def double(x): x * 2; | def fact(n): if (n == 0): 1 else: n * fact(n - 1); | double(3) | fact(4)",
3063        );
3064        // double(3) should be inlined and folded to 6.
3065        // fact(4) must remain as Call.
3066        assert!(
3067            prog.iter().any(|n| matches!(&*n.expr, Expr::Def(..))),
3068            "Full: recursive fact must be preserved"
3069        );
3070        let last = prog.last().unwrap();
3071        assert!(matches!(&*last.expr, Expr::Call(..)), "Full: fact(4) must stay as Call");
3072    }
3073
3074    // ---- substitute_literals into a CallDynamic node ----
3075    #[test]
3076    fn let_propagation_into_selectorchain_body() {
3077        // Selector chains are scope-stopping nodes; let propagation doesn't cross them.
3078        let prog = ast_full("let x = 5 | .h1 | x + 0");
3079        // x+0 should fold to 5 (via propagation).
3080        let last = prog.last().unwrap();
3081        assert_literal(last, "5", "Full: let + selector + x+0 must fold");
3082    }
3083
3084    // ---- literal_is_truthy for all variants ----
3085    #[rstest]
3086    #[case("\"\" && .", "false")] // empty string is falsy
3087    #[case("\"x\" && .", r#"."#)] // non-empty string is truthy — drops literal
3088    fn literal_is_truthy_string(#[case] query: &str, #[case] _expected: &str) {
3089        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
3090            let prog = ast_with(query, level);
3091            assert_eq!(prog.len(), 1, "{level:?}: {query}");
3092        }
3093    }
3094
3095    #[test]
3096    fn literal_is_truthy_zero_number_is_falsy() {
3097        // `0 && .` → falsy literal → short-circuit to false.
3098        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
3099            let prog = ast_with("0 && .", level);
3100            assert_eq!(prog.len(), 1, "{level:?}");
3101            assert!(
3102                matches!(&*prog[0].expr, Expr::Literal(Literal::Bool(false))),
3103                "{level:?}: 0 && . must short-circuit to false"
3104            );
3105        }
3106    }
3107
3108    #[test]
3109    fn literal_is_truthy_nonzero_number_is_truthy() {
3110        // `1 && .` → truthy literal dropped, remaining And([.]) emitted.
3111        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
3112            let prog = ast_with("1 && .", level);
3113            assert_eq!(prog.len(), 1, "{level:?}");
3114            assert!(
3115                matches!(&*prog[0].expr, Expr::And(_)),
3116                "{level:?}: 1 && . truthy lit must be dropped leaving And([.])"
3117            );
3118        }
3119    }
3120
3121    // ---- coalesce with both dynamic operands: stays as Call ----
3122    #[test]
3123    fn coalesce_both_dynamic_stays_as_call() {
3124        for level in [OptimizationLevel::Basic, OptimizationLevel::Full] {
3125            let prog = ast_with("coalesce(., .)", level);
3126            assert_eq!(prog.len(), 1, "{level:?}");
3127            assert!(
3128                matches!(&*prog[0].expr, Expr::Call(..)),
3129                "{level:?}: coalesce(., .) with both dynamic must stay as Call"
3130            );
3131        }
3132    }
3133
3134    // ---- def with default-param is not inlinable ----
3135    #[test]
3136    fn def_with_default_param_not_inlined() {
3137        let prog = ast_full("def greet(name, greeting = \"hello\"): greeting; | greet(\"world\")");
3138        // Has a default param → not inlineable.
3139        let last = prog.last().unwrap();
3140        assert!(
3141            matches!(&*last.expr, Expr::Call(..)),
3142            "Full: def with default param must not be inlined; expected Call"
3143        );
3144    }
3145}