Skip to main content

jpx_engine/
explain.rs

1//! Expression explanation via AST analysis.
2//!
3//! Walks a parsed JMESPath AST and produces a structured breakdown
4//! of each step in the expression, suitable for human or agent consumption.
5
6use crate::JpxEngine;
7use crate::error::{EngineError, Result};
8use jpx_core::ast::{Ast, Comparator};
9use serde::{Deserialize, Serialize};
10
11/// Step-by-step breakdown of a JMESPath expression.
12///
13/// Returned by [`JpxEngine::explain`].
14///
15/// # Example
16///
17/// ```rust
18/// use jpx_engine::JpxEngine;
19///
20/// let engine = JpxEngine::new();
21/// let result = engine.explain("users[?age > `30`].name | sort(@)").unwrap();
22///
23/// assert!(!result.steps.is_empty());
24/// assert!(result.functions_used.contains(&"sort".to_string()));
25/// ```
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ExplainResult {
28    /// The original expression.
29    pub expression: String,
30    /// Ordered steps describing what the expression does.
31    pub steps: Vec<ExplainStep>,
32    /// All function names used in the expression.
33    pub functions_used: Vec<String>,
34    /// Rough complexity label: "simple", "moderate", or "complex".
35    pub complexity: String,
36}
37
38/// A single step in the expression breakdown.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ExplainStep {
41    /// The AST node type (e.g. "field", "filter", "function").
42    pub node_type: String,
43    /// Human-readable description of what this step does.
44    pub description: String,
45    /// Nested children steps (for compound nodes).
46    #[serde(skip_serializing_if = "Vec::is_empty")]
47    pub children: Vec<ExplainStep>,
48}
49
50impl JpxEngine {
51    /// Explain a JMESPath expression by walking its AST.
52    ///
53    /// Returns a structured breakdown with step-by-step descriptions,
54    /// a list of functions used, and a complexity rating.
55    ///
56    /// For invalid expressions, returns an `InvalidExpression` error
57    /// containing the parse error message.
58    ///
59    /// # Example
60    ///
61    /// ```rust
62    /// use jpx_engine::JpxEngine;
63    ///
64    /// let engine = JpxEngine::new();
65    ///
66    /// // Valid expression
67    /// let result = engine.explain("users[*].name").unwrap();
68    /// assert_eq!(result.steps.len(), 1); // top-level projection
69    /// assert!(result.functions_used.is_empty());
70    ///
71    /// // Invalid expression returns an error
72    /// let err = engine.explain("users[*.name");
73    /// assert!(err.is_err());
74    /// ```
75    pub fn explain(&self, expression: &str) -> Result<ExplainResult> {
76        let ast = jpx_core::parse(expression)
77            .map_err(|e| EngineError::InvalidExpression(e.to_string()))?;
78
79        let mut functions = Vec::new();
80        let steps = vec![walk_ast(&ast, &mut functions)];
81
82        functions.sort();
83        functions.dedup();
84
85        let complexity = classify_complexity(&ast);
86
87        Ok(ExplainResult {
88            expression: expression.to_string(),
89            steps,
90            functions_used: functions,
91            complexity,
92        })
93    }
94}
95
96/// Recursively walk the AST and produce explain steps.
97fn walk_ast(node: &Ast, functions: &mut Vec<String>) -> ExplainStep {
98    match node {
99        Ast::Identity { .. } => ExplainStep {
100            node_type: "identity".into(),
101            description: "Reference the current node (@)".into(),
102            children: vec![],
103        },
104        Ast::Field { name, .. } => ExplainStep {
105            node_type: "field".into(),
106            description: format!("Select the '{}' field", name),
107            children: vec![],
108        },
109        Ast::Index { idx, .. } => ExplainStep {
110            node_type: "index".into(),
111            description: if *idx < 0 {
112                format!("Select element at index {} (from end)", idx)
113            } else {
114                format!("Select element at index {}", idx)
115            },
116            children: vec![],
117        },
118        Ast::Slice {
119            start, stop, step, ..
120        } => {
121            let start_s = start.map_or(String::new(), |s| s.to_string());
122            let stop_s = stop.map_or(String::new(), |s| s.to_string());
123            let desc = if *step == 1 {
124                format!("Slice array [{}:{}]", start_s, stop_s)
125            } else {
126                format!("Slice array [{}:{}:{}]", start_s, stop_s, step)
127            };
128            ExplainStep {
129                node_type: "slice".into(),
130                description: desc,
131                children: vec![],
132            }
133        }
134        Ast::Subexpr { lhs, rhs, .. } => {
135            let left = walk_ast(lhs, functions);
136            let right = walk_ast(rhs, functions);
137
138            // Check if this is a pipe expression (rhs starts at a new scope)
139            // Subexpr is the general a.b form; show both sides
140            ExplainStep {
141                node_type: "subexpression".into(),
142                description: "Chain two expressions (left.right)".into(),
143                children: vec![left, right],
144            }
145        }
146        Ast::Projection { lhs, rhs, .. } => {
147            let source = walk_ast(lhs, functions);
148            let project = walk_ast(rhs, functions);
149            ExplainStep {
150                node_type: "projection".into(),
151                description: "Project: evaluate right side for each element of left side".into(),
152                children: vec![source, project],
153            }
154        }
155        Ast::Function { name, args, .. } => {
156            functions.push(name.clone());
157            let arg_steps: Vec<ExplainStep> = args.iter().map(|a| walk_ast(a, functions)).collect();
158            let desc = if args.is_empty() {
159                format!("Call function {}()", name)
160            } else {
161                format!("Call function {}() with {} argument(s)", name, args.len())
162            };
163            ExplainStep {
164                node_type: "function".into(),
165                description: desc,
166                children: arg_steps,
167            }
168        }
169        Ast::Literal { value, .. } => {
170            let json = serde_json::to_string(value).unwrap_or_else(|_| "?".into());
171            ExplainStep {
172                node_type: "literal".into(),
173                description: format!("Literal value: {}", json),
174                children: vec![],
175            }
176        }
177        Ast::Comparison {
178            comparator,
179            lhs,
180            rhs,
181            ..
182        } => {
183            let op = match comparator {
184                Comparator::Equal => "==",
185                Comparator::NotEqual => "!=",
186                Comparator::LessThan => "<",
187                Comparator::LessThanEqual => "<=",
188                Comparator::GreaterThan => ">",
189                Comparator::GreaterThanEqual => ">=",
190            };
191            let left = walk_ast(lhs, functions);
192            let right = walk_ast(rhs, functions);
193            ExplainStep {
194                node_type: "comparison".into(),
195                description: format!("Compare using {}", op),
196                children: vec![left, right],
197            }
198        }
199        Ast::And { lhs, rhs, .. } => {
200            let left = walk_ast(lhs, functions);
201            let right = walk_ast(rhs, functions);
202            ExplainStep {
203                node_type: "and".into(),
204                description: "Logical AND: both sides must be truthy".into(),
205                children: vec![left, right],
206            }
207        }
208        Ast::Or { lhs, rhs, .. } => {
209            let left = walk_ast(lhs, functions);
210            let right = walk_ast(rhs, functions);
211            ExplainStep {
212                node_type: "or".into(),
213                description: "Logical OR: return left if truthy, else right".into(),
214                children: vec![left, right],
215            }
216        }
217        Ast::Not { node, .. } => {
218            let inner = walk_ast(node, functions);
219            ExplainStep {
220                node_type: "not".into(),
221                description: "Logical NOT: negate the result".into(),
222                children: vec![inner],
223            }
224        }
225        Ast::Condition {
226            predicate, then, ..
227        } => {
228            let pred = walk_ast(predicate, functions);
229            let body = walk_ast(then, functions);
230            ExplainStep {
231                node_type: "filter".into(),
232                description: "Filter elements matching a condition".into(),
233                children: vec![pred, body],
234            }
235        }
236        Ast::Flatten { node, .. } => {
237            let inner = walk_ast(node, functions);
238            ExplainStep {
239                node_type: "flatten".into(),
240                description: "Flatten nested arrays by one level".into(),
241                children: vec![inner],
242            }
243        }
244        Ast::ObjectValues { node, .. } => {
245            let inner = walk_ast(node, functions);
246            ExplainStep {
247                node_type: "object_values".into(),
248                description: "Extract all values from an object".into(),
249                children: vec![inner],
250            }
251        }
252        Ast::MultiList { elements, .. } => {
253            let children: Vec<ExplainStep> =
254                elements.iter().map(|e| walk_ast(e, functions)).collect();
255            ExplainStep {
256                node_type: "multi_select_list".into(),
257                description: format!("Create a list of {} evaluated expressions", elements.len()),
258                children,
259            }
260        }
261        Ast::MultiHash { elements, .. } => {
262            let children: Vec<ExplainStep> = elements
263                .iter()
264                .map(|kvp| {
265                    let mut step = walk_ast(&kvp.value, functions);
266                    step.description = format!("'{}': {}", kvp.key, step.description);
267                    step
268                })
269                .collect();
270            ExplainStep {
271                node_type: "multi_select_hash".into(),
272                description: format!("Create an object with {} key(s)", elements.len()),
273                children,
274            }
275        }
276        Ast::Expref { ast, .. } => {
277            let inner = walk_ast(ast, functions);
278            ExplainStep {
279                node_type: "expression_reference".into(),
280                description: "Pass expression as argument (used by sort_by, map, etc.)".into(),
281                children: vec![inner],
282            }
283        }
284        Ast::VariableRef { name, .. } => ExplainStep {
285            node_type: "variable_ref".into(),
286            description: format!("Reference variable ${}", name),
287            children: vec![],
288        },
289        Ast::Let { bindings, expr, .. } => {
290            let mut children: Vec<ExplainStep> = bindings
291                .iter()
292                .map(|(name, ast)| {
293                    let mut step = walk_ast(ast, functions);
294                    step.description = format!("${} = {}", name, step.description);
295                    step
296                })
297                .collect();
298            children.push(walk_ast(expr, functions));
299            ExplainStep {
300                node_type: "let".into(),
301                description: format!("Bind {} variable(s) and evaluate body", bindings.len()),
302                children,
303            }
304        }
305    }
306}
307
308/// Rate the expression complexity based on AST depth and features used.
309fn classify_complexity(ast: &Ast) -> String {
310    let depth = ast_depth(ast);
311    let func_count = count_functions(ast);
312    let has_filter = uses_filter(ast);
313
314    if depth <= 2 && func_count == 0 && !has_filter {
315        "simple".into()
316    } else if depth <= 5 && func_count <= 2 {
317        "moderate".into()
318    } else {
319        "complex".into()
320    }
321}
322
323fn ast_depth(node: &Ast) -> usize {
324    match node {
325        Ast::Identity { .. }
326        | Ast::Field { .. }
327        | Ast::Index { .. }
328        | Ast::Slice { .. }
329        | Ast::Literal { .. } => 1,
330        Ast::Subexpr { lhs, rhs, .. }
331        | Ast::Projection { lhs, rhs, .. }
332        | Ast::And { lhs, rhs, .. }
333        | Ast::Or { lhs, rhs, .. }
334        | Ast::Comparison { lhs, rhs, .. } => 1 + ast_depth(lhs).max(ast_depth(rhs)),
335        Ast::Condition {
336            predicate, then, ..
337        } => 1 + ast_depth(predicate).max(ast_depth(then)),
338        Ast::Not { node, .. } | Ast::Flatten { node, .. } | Ast::ObjectValues { node, .. } => {
339            1 + ast_depth(node)
340        }
341        Ast::Function { args, .. } => 1 + args.iter().map(ast_depth).max().unwrap_or(0),
342        Ast::MultiList { elements, .. } => 1 + elements.iter().map(ast_depth).max().unwrap_or(0),
343        Ast::MultiHash { elements, .. } => {
344            1 + elements
345                .iter()
346                .map(|kvp| ast_depth(&kvp.value))
347                .max()
348                .unwrap_or(0)
349        }
350        Ast::Expref { ast, .. } => 1 + ast_depth(ast),
351        Ast::VariableRef { .. } => 1,
352        Ast::Let { bindings, expr, .. } => {
353            let binding_depth = bindings
354                .iter()
355                .map(|(_, ast)| ast_depth(ast))
356                .max()
357                .unwrap_or(0);
358            1 + binding_depth.max(ast_depth(expr))
359        }
360    }
361}
362
363fn count_functions(node: &Ast) -> usize {
364    match node {
365        Ast::Identity { .. }
366        | Ast::Field { .. }
367        | Ast::Index { .. }
368        | Ast::Slice { .. }
369        | Ast::Literal { .. } => 0,
370        Ast::Subexpr { lhs, rhs, .. }
371        | Ast::Projection { lhs, rhs, .. }
372        | Ast::And { lhs, rhs, .. }
373        | Ast::Or { lhs, rhs, .. }
374        | Ast::Comparison { lhs, rhs, .. } => count_functions(lhs) + count_functions(rhs),
375        Ast::Condition {
376            predicate, then, ..
377        } => count_functions(predicate) + count_functions(then),
378        Ast::Not { node, .. } | Ast::Flatten { node, .. } | Ast::ObjectValues { node, .. } => {
379            count_functions(node)
380        }
381        Ast::Function { args, .. } => 1 + args.iter().map(count_functions).sum::<usize>(),
382        Ast::MultiList { elements, .. } => elements.iter().map(count_functions).sum(),
383        Ast::MultiHash { elements, .. } => {
384            elements.iter().map(|kvp| count_functions(&kvp.value)).sum()
385        }
386        Ast::Expref { ast, .. } => count_functions(ast),
387        Ast::VariableRef { .. } => 0,
388        Ast::Let { bindings, expr, .. } => {
389            bindings
390                .iter()
391                .map(|(_, ast)| count_functions(ast))
392                .sum::<usize>()
393                + count_functions(expr)
394        }
395    }
396}
397
398fn uses_filter(node: &Ast) -> bool {
399    match node {
400        Ast::Condition { .. } => true,
401        Ast::Identity { .. }
402        | Ast::Field { .. }
403        | Ast::Index { .. }
404        | Ast::Slice { .. }
405        | Ast::Literal { .. } => false,
406        Ast::Subexpr { lhs, rhs, .. }
407        | Ast::Projection { lhs, rhs, .. }
408        | Ast::And { lhs, rhs, .. }
409        | Ast::Or { lhs, rhs, .. }
410        | Ast::Comparison { lhs, rhs, .. } => uses_filter(lhs) || uses_filter(rhs),
411        Ast::Not { node, .. } | Ast::Flatten { node, .. } | Ast::ObjectValues { node, .. } => {
412            uses_filter(node)
413        }
414        Ast::Function { args, .. } => args.iter().any(uses_filter),
415        Ast::MultiList { elements, .. } => elements.iter().any(uses_filter),
416        Ast::MultiHash { elements, .. } => elements.iter().any(|kvp| uses_filter(&kvp.value)),
417        Ast::Expref { ast, .. } => uses_filter(ast),
418        Ast::VariableRef { .. } => false,
419        Ast::Let { bindings, expr, .. } => {
420            bindings.iter().any(|(_, ast)| uses_filter(ast)) || uses_filter(expr)
421        }
422    }
423}
424
425/// Returns `true` if the AST contains any `Let` or `VariableRef` nodes.
426///
427/// Used by strict mode to reject JEP-18 let expressions, which are not
428/// part of the standard JMESPath specification.
429pub fn has_let_nodes(node: &Ast) -> bool {
430    match node {
431        Ast::VariableRef { .. } | Ast::Let { .. } => true,
432        Ast::Identity { .. }
433        | Ast::Field { .. }
434        | Ast::Index { .. }
435        | Ast::Slice { .. }
436        | Ast::Literal { .. } => false,
437        Ast::Subexpr { lhs, rhs, .. }
438        | Ast::Projection { lhs, rhs, .. }
439        | Ast::And { lhs, rhs, .. }
440        | Ast::Or { lhs, rhs, .. }
441        | Ast::Comparison { lhs, rhs, .. } => has_let_nodes(lhs) || has_let_nodes(rhs),
442        Ast::Condition {
443            predicate, then, ..
444        } => has_let_nodes(predicate) || has_let_nodes(then),
445        Ast::Not { node, .. } | Ast::Flatten { node, .. } | Ast::ObjectValues { node, .. } => {
446            has_let_nodes(node)
447        }
448        Ast::Function { args, .. } => args.iter().any(has_let_nodes),
449        Ast::MultiList { elements, .. } => elements.iter().any(has_let_nodes),
450        Ast::MultiHash { elements, .. } => elements.iter().any(|kvp| has_let_nodes(&kvp.value)),
451        Ast::Expref { ast, .. } => has_let_nodes(ast),
452    }
453}
454
455/// Collect all function names referenced in the AST.
456///
457/// Walks the entire AST and returns a deduplicated, sorted list of
458/// every function name that appears in a `Function` node. Useful for
459/// checking whether an expression uses extension functions.
460pub fn collect_function_names(node: &Ast) -> Vec<String> {
461    let mut names = Vec::new();
462    collect_functions_recursive(node, &mut names);
463    names.sort();
464    names.dedup();
465    names
466}
467
468fn collect_functions_recursive(node: &Ast, names: &mut Vec<String>) {
469    match node {
470        Ast::Identity { .. }
471        | Ast::Field { .. }
472        | Ast::Index { .. }
473        | Ast::Slice { .. }
474        | Ast::Literal { .. }
475        | Ast::VariableRef { .. } => {}
476        Ast::Subexpr { lhs, rhs, .. }
477        | Ast::Projection { lhs, rhs, .. }
478        | Ast::And { lhs, rhs, .. }
479        | Ast::Or { lhs, rhs, .. }
480        | Ast::Comparison { lhs, rhs, .. } => {
481            collect_functions_recursive(lhs, names);
482            collect_functions_recursive(rhs, names);
483        }
484        Ast::Condition {
485            predicate, then, ..
486        } => {
487            collect_functions_recursive(predicate, names);
488            collect_functions_recursive(then, names);
489        }
490        Ast::Not { node, .. } | Ast::Flatten { node, .. } | Ast::ObjectValues { node, .. } => {
491            collect_functions_recursive(node, names);
492        }
493        Ast::Function { name, args, .. } => {
494            names.push(name.clone());
495            for arg in args {
496                collect_functions_recursive(arg, names);
497            }
498        }
499        Ast::MultiList { elements, .. } => {
500            for e in elements {
501                collect_functions_recursive(e, names);
502            }
503        }
504        Ast::MultiHash { elements, .. } => {
505            for kvp in elements {
506                collect_functions_recursive(&kvp.value, names);
507            }
508        }
509        Ast::Expref { ast, .. } => collect_functions_recursive(ast, names),
510        Ast::Let { bindings, expr, .. } => {
511            for (_, ast) in bindings {
512                collect_functions_recursive(ast, names);
513            }
514            collect_functions_recursive(expr, names);
515        }
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    fn engine() -> JpxEngine {
524        JpxEngine::new()
525    }
526
527    #[test]
528    fn test_simple_field() {
529        let result = engine().explain("name").unwrap();
530        assert_eq!(result.steps[0].node_type, "field");
531        assert!(result.steps[0].description.contains("name"));
532        assert!(result.functions_used.is_empty());
533        assert_eq!(result.complexity, "simple");
534    }
535
536    #[test]
537    fn test_filter_expression() {
538        let result = engine().explain("users[?age > `30`]").unwrap();
539        // Top-level is a projection with a filter condition
540        assert!(!result.steps.is_empty());
541        assert_eq!(result.complexity, "moderate");
542    }
543
544    #[test]
545    fn test_projection() {
546        let result = engine().explain("users[*].name").unwrap();
547        assert_eq!(result.steps[0].node_type, "projection");
548        assert!(result.functions_used.is_empty());
549    }
550
551    #[test]
552    fn test_pipe_with_function() {
553        let result = engine().explain("users[*].name | sort(@)").unwrap();
554        assert!(result.functions_used.contains(&"sort".to_string()));
555        assert_eq!(result.complexity, "moderate");
556    }
557
558    #[test]
559    fn test_multi_select() {
560        let result = engine().explain("{name: name, age: age}").unwrap();
561        assert_eq!(result.steps[0].node_type, "multi_select_hash");
562        assert_eq!(result.steps[0].children.len(), 2);
563    }
564
565    #[test]
566    fn test_complex_expression() {
567        let result = engine()
568            .explain("users[?active].addresses[*].city | sort(@) | join(', ', @)")
569            .unwrap();
570        assert!(result.functions_used.contains(&"sort".to_string()));
571        assert!(result.functions_used.contains(&"join".to_string()));
572        assert_eq!(result.complexity, "complex");
573    }
574
575    #[test]
576    fn test_invalid_expression() {
577        let err = engine().explain("users[*.name");
578        assert!(err.is_err());
579    }
580
581    #[test]
582    fn test_identity() {
583        let result = engine().explain("@").unwrap();
584        assert_eq!(result.steps[0].node_type, "identity");
585        assert_eq!(result.complexity, "simple");
586    }
587
588    #[test]
589    fn test_index() {
590        let result = engine().explain("[0]").unwrap();
591        assert_eq!(result.steps[0].node_type, "index");
592    }
593
594    #[test]
595    fn test_flatten() {
596        let result = engine().explain("items[]").unwrap();
597        // flatten wraps a field access
598        assert!(!result.steps.is_empty());
599    }
600
601    /// Recursively search for a node_type anywhere in the step tree.
602    fn contains_node_type(step: &ExplainStep, target: &str) -> bool {
603        if step.node_type == target {
604            return true;
605        }
606        step.children.iter().any(|c| contains_node_type(c, target))
607    }
608
609    // --- Missing AST node coverage ---
610
611    #[test]
612    fn test_variable_ref() {
613        let result = engine().explain("let $x = name in $x").unwrap();
614        // The let body references $x, which produces a variable_ref node
615        assert!(
616            result
617                .steps
618                .iter()
619                .any(|s| contains_node_type(s, "variable_ref"))
620        );
621    }
622
623    #[test]
624    fn test_let_expression() {
625        let result = engine().explain("let $x = name in upper($x)").unwrap();
626        let top = &result.steps[0];
627        assert_eq!(top.node_type, "let");
628        assert!(top.description.contains("1 variable"));
629        // Children: one binding + the body
630        assert_eq!(top.children.len(), 2);
631        assert!(result.functions_used.contains(&"upper".to_string()));
632    }
633
634    #[test]
635    fn test_expref() {
636        let result = engine().explain("sort_by(users, &age)").unwrap();
637        assert!(
638            result
639                .steps
640                .iter()
641                .any(|s| contains_node_type(s, "expression_reference"))
642        );
643        assert!(result.functions_used.contains(&"sort_by".to_string()));
644    }
645
646    #[test]
647    fn test_object_values() {
648        // `*` on its own produces ObjectValues
649        let result = engine().explain("*").unwrap();
650        assert!(
651            result
652                .steps
653                .iter()
654                .any(|s| contains_node_type(s, "object_values"))
655        );
656    }
657
658    #[test]
659    fn test_not_expression() {
660        let result = engine().explain("!active").unwrap();
661        assert_eq!(result.steps[0].node_type, "not");
662        assert!(result.steps[0].description.contains("NOT"));
663        assert_eq!(result.steps[0].children.len(), 1);
664    }
665
666    #[test]
667    fn test_and_expression() {
668        let result = engine().explain("a && b").unwrap();
669        assert_eq!(result.steps[0].node_type, "and");
670        assert!(result.steps[0].description.contains("AND"));
671        assert_eq!(result.steps[0].children.len(), 2);
672        assert_eq!(result.steps[0].children[0].node_type, "field");
673        assert_eq!(result.steps[0].children[1].node_type, "field");
674    }
675
676    #[test]
677    fn test_or_expression() {
678        let result = engine().explain("a || b").unwrap();
679        assert_eq!(result.steps[0].node_type, "or");
680        assert!(result.steps[0].description.contains("OR"));
681        assert_eq!(result.steps[0].children.len(), 2);
682        assert_eq!(result.steps[0].children[0].node_type, "field");
683        assert_eq!(result.steps[0].children[1].node_type, "field");
684    }
685
686    // --- has_let_nodes tests ---
687
688    #[test]
689    fn test_has_let_nodes_simple_field() {
690        let ast = jpx_core::parse("foo.bar").unwrap();
691        assert!(!has_let_nodes(&ast));
692    }
693
694    #[test]
695    fn test_has_let_nodes_with_let() {
696        let ast = jpx_core::parse("let $x = name in $x").unwrap();
697        assert!(has_let_nodes(&ast));
698    }
699
700    #[test]
701    fn test_has_let_nodes_nested_in_function() {
702        let ast = jpx_core::parse("length(people)").unwrap();
703        assert!(!has_let_nodes(&ast));
704    }
705
706    #[test]
707    fn test_has_let_nodes_variable_in_filter() {
708        let ast = jpx_core::parse("let $min = `30` in people[?age > $min]").unwrap();
709        assert!(has_let_nodes(&ast));
710    }
711
712    // --- Complexity boundary tests ---
713
714    #[test]
715    fn test_complexity_simple_field() {
716        let result = engine().explain("name").unwrap();
717        assert_eq!(result.complexity, "simple");
718    }
719
720    #[test]
721    fn test_complexity_simple_identity() {
722        let result = engine().explain("@").unwrap();
723        assert_eq!(result.complexity, "simple");
724    }
725
726    #[test]
727    fn test_complexity_moderate_with_function() {
728        let result = engine().explain("length(@)").unwrap();
729        // Has a function (count=1) but low depth, so "moderate"
730        assert_eq!(result.complexity, "moderate");
731        assert!(result.functions_used.contains(&"length".to_string()));
732    }
733
734    #[test]
735    fn test_complexity_moderate_with_filter() {
736        let result = engine().explain("users[?active]").unwrap();
737        // Has a filter condition, which makes it at least moderate
738        assert_eq!(result.complexity, "moderate");
739    }
740
741    #[test]
742    fn test_complexity_complex_multi_function() {
743        // Three functions pushes func_count > 2, triggering "complex"
744        let result = engine().explain("sort(keys(@)) | join(', ', @)").unwrap();
745        assert_eq!(result.functions_used.len(), 3);
746        assert_eq!(result.complexity, "complex");
747    }
748
749    // --- Other tests ---
750
751    #[test]
752    fn test_explain_comparison_operators() {
753        let result = engine().explain("a > b").unwrap();
754        assert_eq!(result.steps[0].node_type, "comparison");
755        assert!(result.steps[0].description.contains(">"));
756        assert_eq!(result.steps[0].children.len(), 2);
757    }
758
759    #[test]
760    fn test_explain_slice() {
761        let result = engine().explain("items[1:3]").unwrap();
762        assert!(result.steps.iter().any(|s| contains_node_type(s, "slice")));
763    }
764
765    #[test]
766    fn test_explain_literal() {
767        let result = engine().explain("`42`").unwrap();
768        assert_eq!(result.steps[0].node_type, "literal");
769        assert!(result.steps[0].description.contains("42"));
770    }
771
772    // --- collect_function_names tests ---
773
774    #[test]
775    fn test_collect_function_names_empty() {
776        let ast = jpx_core::parse("foo.bar").unwrap();
777        assert!(collect_function_names(&ast).is_empty());
778    }
779
780    #[test]
781    fn test_collect_function_names_standard() {
782        let ast = jpx_core::parse("length(sort(@))").unwrap();
783        let names = collect_function_names(&ast);
784        assert_eq!(names, vec!["length", "sort"]);
785    }
786
787    #[test]
788    fn test_collect_function_names_nested() {
789        let ast = jpx_core::parse("users[?contains(name, 'a')] | sort_by(@, &age) | [0]").unwrap();
790        let names = collect_function_names(&ast);
791        assert_eq!(names, vec!["contains", "sort_by"]);
792    }
793}