Skip to main content

jsonata_core/
ast_transform.rs

1// Post-parse AST transformation pass.
2// Mirrors parser.js's processAST/seekParent/pushAncestry/resolveAncestry
3// (tests/jsonata-js/src/parser.js ~L937-1235), adapted to Rust's ownership
4// model: instead of mutating tree nodes in place, this consumes the raw
5// tree and rebuilds an enriched one with ancestor/tuple metadata resolved.
6
7use crate::ast::{AstNode, BinaryOp, PathStep, Stage};
8use std::collections::HashMap;
9use thiserror::Error;
10
11#[derive(Error, Debug)]
12pub enum AstTransformError {
13    #[error("{code}: {message}")]
14    Coded { code: &'static str, message: String },
15}
16
17fn coded(code: &'static str, message: impl Into<String>) -> AstTransformError {
18    AstTransformError::Coded {
19        code,
20        message: message.into(),
21    }
22}
23
24/// A `%` reference still seeking its ancestor step, mirroring jsonata-js's
25/// `slot` object (`{label, level, index}` -- we don't need `index`, since
26/// that's only used by jsonata-js to index into its global mutable
27/// `ancestry` array for the in-place relabeling trick; see `AncestryState`
28/// for how we get the same "reuse an existing label" behavior without it).
29#[derive(Debug, Clone)]
30struct PendingAncestor {
31    label: String,
32    /// Remaining backward steps needed before this reference resolves.
33    /// A fresh `%` starts at level 1 (its own immediately-preceding step);
34    /// walking backward over ANOTHER not-yet-resolved `%` step increments
35    /// this (mirrors seekParent's `case 'parent': slot.level++`).
36    level: usize,
37}
38
39/// Threaded through the whole pass: generates fresh synthetic ancestor
40/// labels ("!0", "!1", ...) and records label aliases.
41///
42/// Rust's immutable-rebuild model can't replicate jsonata-js's in-place
43/// mutation `ancestry[slot.index].slot.label = node.ancestor.label` (used
44/// when a *second* `%` resolves to a step some *earlier* `%` already
45/// tagged -- jsonata-js renames the second slot's label to match the first,
46/// by mutating a shared JS object referenced from both the `ancestry` array
47/// and the corresponding `AstNode::Parent` node already sitting in the
48/// tree). Since our tree nodes are owned values already moved by the time a
49/// later reuse is discovered, we can't reach back in and rewrite an
50/// already-built `Parent(label)` node in place. Instead: record the alias
51/// (`new_label -> canonical_label`) here as resolution proceeds, then run
52/// one final substitution pass (`substitute_labels`, called from
53/// `resolve_ancestry` after the whole tree is built) that rewrites every
54/// `AstNode::Parent(label)` to its canonical form. `PathStep.ancestor_label`
55/// itself never needs substitution: it's set at most once per step (the
56/// first `%` to resolve there), so it's always already canonical.
57struct AncestryState {
58    next_label: usize,
59    aliases: HashMap<String, String>,
60}
61
62impl AncestryState {
63    fn new() -> Self {
64        AncestryState {
65            next_label: 0,
66            aliases: HashMap::new(),
67        }
68    }
69
70    fn fresh_label(&mut self) -> String {
71        let label = format!("!{}", self.next_label);
72        self.next_label += 1;
73        label
74    }
75
76    /// Follow the alias chain to a label's canonical form. Chains longer
77    /// than one hop shouldn't arise (a step's `ancestor_label`, once set, is
78    /// never itself replaced -- only newcomers get aliased to it) but this
79    /// still follows the chain defensively rather than assuming depth 1.
80    fn canonical(&self, label: &str) -> String {
81        let mut cur = label;
82        while let Some(next) = self.aliases.get(cur) {
83            cur = next;
84        }
85        cur.to_string()
86    }
87}
88
89/// The result of transforming a node: the rebuilt node, plus any `%`
90/// references within it that are still seeking an ancestor step, bubbling
91/// up to whatever contains this node -- mirrors jsonata-js's `seekingParent`
92/// array property, attached to whatever node `pushAncestry` was called on.
93struct Transformed {
94    node: AstNode,
95    pending: Vec<PendingAncestor>,
96}
97
98impl Transformed {
99    fn leaf(node: AstNode) -> Self {
100        Transformed {
101            node,
102            pending: Vec::new(),
103        }
104    }
105}
106
107/// Entry point: resolve all ancestor references in a freshly-parsed AST.
108pub fn resolve_ancestry(ast: AstNode) -> Result<AstNode, AstTransformError> {
109    let mut state = AncestryState::new();
110    let transformed = transform_node(ast, &mut state)?;
111    // Mirrors jsonata-js's final check (parser.js ~L1404): a bare `%` as the
112    // WHOLE expression, or any dangling (never-resolved) pending ancestor
113    // reference that bubbled all the way to the top, means there was no
114    // enclosing path to derive an ancestor from.
115    if !transformed.pending.is_empty() || matches!(transformed.node, AstNode::Parent(_)) {
116        return Err(coded(
117            "S0217",
118            "The parent operator % cannot be used at this point in the expression",
119        ));
120    }
121    Ok(substitute_labels(transformed.node, &state))
122}
123
124/// Final pass: rewrite every `AstNode::Parent(label)` in the tree to its
125/// canonical (alias-resolved) label. See `AncestryState` for why this is a
126/// separate pass rather than done inline. Mirrors `transform_children`'s
127/// traversal shape exactly (every composite node type), since by this point
128/// there's no error case left to handle -- the tree is already fully valid.
129fn substitute_labels(node: AstNode, state: &AncestryState) -> AstNode {
130    match node {
131        AstNode::Parent(label) => AstNode::Parent(state.canonical(&label)),
132        AstNode::Path { steps } => AstNode::Path {
133            steps: steps
134                .into_iter()
135                .map(|s| PathStep {
136                    node: substitute_labels(s.node, state),
137                    // Stages (predicates) can contain `%` references whose
138                    // labels were aliased during resolution (e.g. a second
139                    // predicate reusing a step an earlier one already tagged),
140                    // so they must be substituted too -- otherwise the
141                    // pre-alias label survives and evaluates against the wrong
142                    // tuple key.
143                    stages: s
144                        .stages
145                        .into_iter()
146                        .map(|st| match st {
147                            Stage::Filter(e) => {
148                                Stage::Filter(Box::new(substitute_labels(*e, state)))
149                            }
150                            Stage::Index(v) => Stage::Index(v),
151                        })
152                        .collect(),
153                    ..s
154                })
155                .collect(),
156        },
157        AstNode::Block(exprs) => AstNode::Block(
158            exprs
159                .into_iter()
160                .map(|e| substitute_labels(e, state))
161                .collect(),
162        ),
163        AstNode::Binary { op, lhs, rhs } => AstNode::Binary {
164            op,
165            lhs: Box::new(substitute_labels(*lhs, state)),
166            rhs: Box::new(substitute_labels(*rhs, state)),
167        },
168        AstNode::Unary { op, operand } => AstNode::Unary {
169            op,
170            operand: Box::new(substitute_labels(*operand, state)),
171        },
172        AstNode::Array(elements) => AstNode::Array(
173            elements
174                .into_iter()
175                .map(|e| substitute_labels(e, state))
176                .collect(),
177        ),
178        AstNode::Function {
179            name,
180            args,
181            is_builtin,
182        } => AstNode::Function {
183            name,
184            args: args
185                .into_iter()
186                .map(|a| substitute_labels(a, state))
187                .collect(),
188            is_builtin,
189        },
190        AstNode::Call { procedure, args } => AstNode::Call {
191            procedure: Box::new(substitute_labels(*procedure, state)),
192            args: args
193                .into_iter()
194                .map(|a| substitute_labels(a, state))
195                .collect(),
196        },
197        AstNode::Lambda {
198            params,
199            body,
200            signature,
201            thunk,
202        } => AstNode::Lambda {
203            params,
204            body: Box::new(substitute_labels(*body, state)),
205            signature,
206            thunk,
207        },
208        AstNode::Object(pairs) => AstNode::Object(
209            pairs
210                .into_iter()
211                .map(|(k, v)| (substitute_labels(k, state), substitute_labels(v, state)))
212                .collect(),
213        ),
214        AstNode::ObjectTransform { input, pattern } => AstNode::ObjectTransform {
215            input: Box::new(substitute_labels(*input, state)),
216            pattern: pattern
217                .into_iter()
218                .map(|(k, v)| (substitute_labels(k, state), substitute_labels(v, state)))
219                .collect(),
220        },
221        AstNode::Conditional {
222            condition,
223            then_branch,
224            else_branch,
225        } => AstNode::Conditional {
226            condition: Box::new(substitute_labels(*condition, state)),
227            then_branch: Box::new(substitute_labels(*then_branch, state)),
228            else_branch: else_branch.map(|e| Box::new(substitute_labels(*e, state))),
229        },
230        AstNode::Sort { input, terms } => AstNode::Sort {
231            input: Box::new(substitute_labels(*input, state)),
232            terms: terms
233                .into_iter()
234                .map(|(e, asc)| (substitute_labels(e, state), asc))
235                .collect(),
236        },
237        AstNode::Transform {
238            location,
239            update,
240            delete,
241        } => AstNode::Transform {
242            location: Box::new(substitute_labels(*location, state)),
243            update: Box::new(substitute_labels(*update, state)),
244            delete: delete.map(|d| Box::new(substitute_labels(*d, state))),
245        },
246        AstNode::FunctionApplication(inner) => {
247            AstNode::FunctionApplication(Box::new(substitute_labels(*inner, state)))
248        }
249        AstNode::ArrayGroup(elements) => AstNode::ArrayGroup(
250            elements
251                .into_iter()
252                .map(|e| substitute_labels(e, state))
253                .collect(),
254        ),
255        AstNode::Predicate(inner) => AstNode::Predicate(Box::new(substitute_labels(*inner, state))),
256        // Leaf nodes and everything else pass through unchanged.
257        other => other,
258    }
259}
260
261/// A raw parse-time binding marker (`@$var` or `#$var`) that still needs to
262/// be migrated into `PathStep.focus`/`PathStep.index_var` + `is_tuple`.
263/// Shared between the "marker nested inside an existing `PathStep`" case
264/// (`migrate_binding_markers`) and the "marker is the top-level/raw node
265/// itself" case (`wrap_marker_as_path`), so the stamping logic itself lives
266/// in exactly one place: `apply_marker_to_step`.
267enum BindingMarker {
268    Focus(String),
269    Index(String),
270}
271
272/// Stamp a binding marker onto a step: sets `focus` or `index_var` (per the
273/// marker kind) and `is_tuple = true`. The single place that knows how a
274/// marker maps onto `PathStep` fields.
275fn apply_marker_to_step(step: &mut PathStep, marker: BindingMarker) {
276    match marker {
277        BindingMarker::Focus(var_name) => step.focus = Some(var_name),
278        BindingMarker::Index(var_name) => step.index_var = Some(var_name),
279    }
280    step.is_tuple = true;
281}
282
283/// Core shared logic for both call sites that need to migrate a `@$var`/
284/// `#$var` marker: given the already-`transform_node`-recursed `lhs`/`input`
285/// that the marker was parsed against, produce the flat sequence of
286/// `PathStep`s the marker should resolve to, plus whatever pending ancestor
287/// references bubbled up from transforming that `lhs`/`input`.
288///
289/// Mirrors jsonata-js's `processAST` `case '@'`/`case '#'`:
290/// `result = processAST(expr.lhs); step = result; if (result.type ===
291/// 'path') { step = result.steps[result.steps.length - 1]; }` -- `result`
292/// (the possibly-multi-step path) is always what gets kept/spliced in, and
293/// only `step` (the thing that gets the marker's flags stamped onto it) is
294/// reassigned to the LAST step of that path when `result` is itself a path.
295/// Note jsonata-js's `@`/`#` cases do NOT call `pushAncestry` on the lhs --
296/// we deviate slightly (forwarding the lhs's pending through as this
297/// marker's own pending) since dropping it silently seems more surprising
298/// than propagating it, and no test data combines `%` with `@`/`#` closely
299/// enough to distinguish the two choices.
300///
301/// - If `transformed` is a multi-step `Path`, the marker's flags land on its
302///   LAST step, and ALL of its steps are returned to be spliced into the
303///   caller's flat steps list (never wrapped in a new outer step).
304/// - Otherwise (e.g. a bare `Name` with no `.` at all), wrap it into a new
305///   single-step `Path` and stamp the marker onto that one step.
306///
307/// S0215/S0216 validation for `@` (focus binding only -- `#`/index binding
308/// has no such restriction in jsonata-js): the target must not already have
309/// predicates/stages attached, and must not itself be a `Sort` node.
310fn check_focus_bind_target(
311    marker: &BindingMarker,
312    target_stages: &[crate::ast::Stage],
313    target_node: &AstNode,
314) -> Result<(), AstTransformError> {
315    if !matches!(marker, BindingMarker::Focus(_)) {
316        return Ok(());
317    }
318    if !target_stages.is_empty() {
319        return Err(coded(
320            "S0215",
321            "A context variable binding must precede any predicates on a step",
322        ));
323    }
324    if matches!(target_node, AstNode::Sort { .. }) {
325        return Err(coded(
326            "S0216",
327            "A context variable binding must precede the 'order-by' clause on a step",
328        ));
329    }
330    Ok(())
331}
332///
333/// Fallible because `@` (focus binding) specifically -- not `#` -- rejects
334/// being applied to a step that already has predicates/stages (S0215) or
335/// that is itself a sort step (S0216), mirroring jsonata-js's `case '@'`
336/// checks (parser.js ~L1183-1199): `step = result; if (result.type ===
337/// 'path') { step = result.steps[...length-1]; }` -- note `step` can be the
338/// bare (non-Path) `result` itself, e.g. `Account.Order^(...)@$o.Product`
339/// parses `Account.Order^(...)` into a bare top-level `Sort` node (not
340/// wrapped in a Path) *before* `@$o` wraps around it, so the S0216 check
341/// must inspect the raw `other` node too, not just a `Path`'s last step.
342fn splice_marker_steps(
343    transformed: Transformed,
344    marker: BindingMarker,
345) -> Result<(Vec<PathStep>, Vec<PendingAncestor>), AstTransformError> {
346    let Transformed { node, pending } = transformed;
347    let steps = match node {
348        AstNode::Path { mut steps } => {
349            // Our parser encodes `$[[1..4]]` (and any `expr[pred]`) as a separate
350            // trailing `Predicate` step rather than a step carrying the predicate
351            // as a `stage` (as jsonata-js does). For an index marker, mirror
352            // jsonata's `#` case (parser.js ~L1206-1223: when the target step
353            // already has stages, PUSH an index stage) by folding those trailing
354            // predicate pseudo-steps into the preceding real step's stages, then
355            // stamping the index on that step. This makes `$[[1..4]]#$pos[$pos>=2]`
356            // apply the `[[1..4]]` filter, then number the survivors, then filter
357            // by `$pos` -- rather than crashing on a `Predicate` step node in
358            // create_tuple_stream.
359            if matches!(marker, BindingMarker::Index(_)) {
360                while steps.len() >= 2
361                    && matches!(steps.last().map(|s| &s.node), Some(AstNode::Predicate(_)))
362                {
363                    let pred = steps.pop().unwrap();
364                    if let AstNode::Predicate(inner) = pred.node {
365                        steps.last_mut().unwrap().stages.push(Stage::Filter(inner));
366                    }
367                }
368            }
369            if let Some(last) = steps.last_mut() {
370                check_focus_bind_target(&marker, &last.stages, &last.node)?;
371                // A SECOND index binding on the same step (e.g. `books#$ib[...]#$ib2`)
372                // must not overwrite the first: append it as an ordered index
373                // stage so it numbers the post-filter positions (jsonata's `#`
374                // case pushing an index stage when the step already has one).
375                if let (BindingMarker::Index(var), true) = (&marker, last.index_var.is_some()) {
376                    last.stages.push(Stage::Index(var.clone()));
377                    last.is_tuple = true;
378                } else {
379                    apply_marker_to_step(last, marker);
380                }
381            }
382            steps
383        }
384        other => {
385            check_focus_bind_target(&marker, &[], &other)?;
386            let mut step = PathStep::new(other);
387            apply_marker_to_step(&mut step, marker);
388            vec![step]
389        }
390    };
391    Ok((steps, pending))
392}
393
394/// Handle a `@$var`/`#$var` marker reaching `transform_node` as the raw node
395/// itself (not already nested inside a `PathStep`) -- e.g. `Order@$o` or
396/// `Account.Order@$o` where the parser's flat infix loop has already merged
397/// any preceding `.` steps into a `Path` (or, for a single bare name, left a
398/// non-Path leaf) *before* wrapping the whole thing in the marker node. At
399/// this (top-level) call site there's no outer steps list to splice into, so
400/// the spliced steps become the whole resulting `Path`.
401fn wrap_marker_as_path(
402    transformed: Transformed,
403    marker: BindingMarker,
404) -> Result<Transformed, AstTransformError> {
405    let (steps, pending) = splice_marker_steps(transformed, marker)?;
406    Ok(Transformed {
407        node: AstNode::Path { steps },
408        pending,
409    })
410}
411
412/// Recursively rebuild `node`, resolving any `%`/`@`/`#` found within.
413/// Mirrors jsonata-js's processAST's generic per-node-type dispatch.
414fn transform_node(
415    node: AstNode,
416    state: &mut AncestryState,
417) -> Result<Transformed, AstTransformError> {
418    match node {
419        AstNode::Path { steps } => {
420            let (transformed_steps, pending) = transform_path_steps(steps, state)?;
421            Ok(Transformed {
422                node: AstNode::Path {
423                    steps: transformed_steps,
424                },
425                pending,
426            })
427        }
428        AstNode::Block(exprs) => {
429            let mut pending = Vec::new();
430            let mut transformed_exprs = Vec::with_capacity(exprs.len());
431            for e in exprs {
432                let t = transform_node(e, state)?;
433                pending.extend(t.pending);
434                transformed_exprs.push(t.node);
435            }
436            Ok(Transformed {
437                node: AstNode::Block(transformed_exprs),
438                pending,
439            })
440        }
441        // A bare `%` -- mirrors jsonata-js's `case 'parent'`, which assigns
442        // a fresh slot the MOMENT any recursive processAST call first sees a
443        // 'parent'-type node (not just at the top of transform_node), i.e.
444        // eagerly, before any backward walk starts. The one pending
445        // reference this produces starts at level 1 (its own immediately
446        // preceding step); `%.%` chains extend the level as the backward
447        // walk crosses further `%` steps (see `seek_parent_step`).
448        AstNode::Parent(_) => {
449            let label = state.fresh_label();
450            Ok(Transformed {
451                node: AstNode::Parent(label.clone()),
452                pending: vec![PendingAncestor { label, level: 1 }],
453            })
454        }
455        // `@$var` reaching transform_node as the raw top-level node itself
456        // (not nested inside an existing PathStep) -- e.g. `Order@$o` or
457        // `Account.Order@$o`, where the parser's flat infix loop applies `@`
458        // to the already-built lhs (a Path, or a bare leaf if there was no
459        // `.` at all) rather than to a single step. See `wrap_marker_as_path`.
460        AstNode::Binary {
461            op: BinaryOp::FocusBind,
462            lhs,
463            rhs,
464        } => {
465            let var_name = match *rhs {
466                AstNode::Variable(name) => name,
467                _ => unreachable!("parser guarantees FocusBind's rhs is always Variable"),
468            };
469            let transformed_lhs = transform_node(*lhs, state)?;
470            wrap_marker_as_path(transformed_lhs, BindingMarker::Focus(var_name))
471        }
472        // Same story as FocusBind above, but for bare top-level `#$var`
473        // (now represented the same generic way as FocusBind -- see
474        // BinaryOp::IndexBind's doc comment in ast.rs).
475        AstNode::Binary {
476            op: BinaryOp::IndexBind,
477            lhs,
478            rhs,
479        } => {
480            let var_name = match *rhs {
481                AstNode::Variable(name) => name,
482                _ => unreachable!("parser guarantees IndexBind's rhs is always Variable"),
483            };
484            let transformed_lhs = transform_node(*lhs, state)?;
485            wrap_marker_as_path(transformed_lhs, BindingMarker::Index(var_name))
486        }
487        // Recurse into every other node's children unchanged (no ancestor
488        // resolution needed for nodes that aren't paths/blocks/parent refs).
489        other => transform_children(other, state),
490    }
491}
492
493/// Recurse into a node's child expressions without any path-specific
494/// ancestor logic (used for node types that can't themselves be paths),
495/// aggregating pending ancestor references from every child -- mirrors
496/// jsonata-js's per-case `pushAncestry` calls in processAST.
497///
498/// Two deliberate asymmetries with the generic "bubble everything" rule,
499/// both matching jsonata-js exactly:
500/// - `Call`'s `procedure` does NOT bubble (only `args` do) -- jsonata-js's
501///   function/partial case never calls `pushAncestry` on `result.procedure`.
502/// - `Lambda`'s `body` does NOT bubble at all -- jsonata-js's lambda case
503///   has no `pushAncestry` call for the body. A `%` inside a lambda body
504///   refers to that lambda's OWN invocation-time ancestry chain (irrelevant
505///   at definition/parse time), so it's correctly not resolved here; it
506///   simply remains an inert `AstNode::Parent(label)` in the body until the
507///   lambda is invoked (matching jsonata-js: `function(){%}` parses fine,
508///   with the raw `%` untouched inside the body).
509fn transform_children(
510    node: AstNode,
511    state: &mut AncestryState,
512) -> Result<Transformed, AstTransformError> {
513    match node {
514        AstNode::Binary { op, lhs, rhs } => {
515            let lhs_t = transform_node(*lhs, state)?;
516            let rhs_t = transform_node(*rhs, state)?;
517            let mut pending = lhs_t.pending;
518            pending.extend(rhs_t.pending);
519            Ok(Transformed {
520                node: AstNode::Binary {
521                    op,
522                    lhs: Box::new(lhs_t.node),
523                    rhs: Box::new(rhs_t.node),
524                },
525                pending,
526            })
527        }
528        AstNode::Unary { op, operand } => {
529            let t = transform_node(*operand, state)?;
530            Ok(Transformed {
531                node: AstNode::Unary {
532                    op,
533                    operand: Box::new(t.node),
534                },
535                pending: t.pending,
536            })
537        }
538        AstNode::Array(elements) => {
539            let mut pending = Vec::new();
540            let mut transformed = Vec::with_capacity(elements.len());
541            for e in elements {
542                let t = transform_node(e, state)?;
543                pending.extend(t.pending);
544                transformed.push(t.node);
545            }
546            Ok(Transformed {
547                node: AstNode::Array(transformed),
548                pending,
549            })
550        }
551        AstNode::Function {
552            name,
553            args,
554            is_builtin,
555        } => {
556            let mut pending = Vec::new();
557            let mut transformed = Vec::with_capacity(args.len());
558            for a in args {
559                let t = transform_node(a, state)?;
560                pending.extend(t.pending);
561                transformed.push(t.node);
562            }
563            Ok(Transformed {
564                node: AstNode::Function {
565                    name,
566                    args: transformed,
567                    is_builtin,
568                },
569                pending,
570            })
571        }
572        AstNode::Call { procedure, args } => {
573            // Only args bubble (see doc comment above) -- procedure is
574            // still structurally transformed, just doesn't contribute to
575            // this Call's own pending.
576            let procedure_t = transform_node(*procedure, state)?;
577            let mut pending = Vec::new();
578            let mut transformed_args = Vec::with_capacity(args.len());
579            for a in args {
580                let t = transform_node(a, state)?;
581                pending.extend(t.pending);
582                transformed_args.push(t.node);
583            }
584            Ok(Transformed {
585                node: AstNode::Call {
586                    procedure: Box::new(procedure_t.node),
587                    args: transformed_args,
588                },
589                pending,
590            })
591        }
592        AstNode::Lambda {
593            params,
594            body,
595            signature,
596            thunk,
597        } => {
598            // body's pending is deliberately dropped -- see doc comment above.
599            let body_t = transform_node(*body, state)?;
600            Ok(Transformed::leaf(AstNode::Lambda {
601                params,
602                body: Box::new(body_t.node),
603                signature,
604                thunk,
605            }))
606        }
607        AstNode::Object(pairs) => {
608            let mut pending = Vec::new();
609            let mut transformed = Vec::with_capacity(pairs.len());
610            for (k, v) in pairs {
611                let k_t = transform_node(k, state)?;
612                pending.extend(k_t.pending);
613                let v_t = transform_node(v, state)?;
614                pending.extend(v_t.pending);
615                transformed.push((k_t.node, v_t.node));
616            }
617            Ok(Transformed {
618                node: AstNode::Object(transformed),
619                pending,
620            })
621        }
622        AstNode::ObjectTransform { input, pattern } => {
623            let input_t = transform_node(*input, state)?;
624            let mut pending = input_t.pending;
625            let mut transformed_pattern = Vec::with_capacity(pattern.len());
626            for (k, v) in pattern {
627                let k_t = transform_node(k, state)?;
628                pending.extend(k_t.pending);
629                let v_t = transform_node(v, state)?;
630                pending.extend(v_t.pending);
631                transformed_pattern.push((k_t.node, v_t.node));
632            }
633            Ok(Transformed {
634                node: AstNode::ObjectTransform {
635                    input: Box::new(input_t.node),
636                    pattern: transformed_pattern,
637                },
638                pending,
639            })
640        }
641        AstNode::Conditional {
642            condition,
643            then_branch,
644            else_branch,
645        } => {
646            let condition_t = transform_node(*condition, state)?;
647            let then_t = transform_node(*then_branch, state)?;
648            let mut pending = condition_t.pending;
649            pending.extend(then_t.pending);
650            let else_t = match else_branch {
651                Some(e) => Some(transform_node(*e, state)?),
652                None => None,
653            };
654            let else_node = else_t.map(|t| {
655                pending.extend(t.pending);
656                Box::new(t.node)
657            });
658            Ok(Transformed {
659                node: AstNode::Conditional {
660                    condition: Box::new(condition_t.node),
661                    then_branch: Box::new(then_t.node),
662                    else_branch: else_node,
663                },
664                pending,
665            })
666        }
667        AstNode::Sort { input, terms } => {
668            // Mirrors jsonata-js's `case '^'` (parser.js ~L1151-1170): the
669            // sort is modeled as a synthetic `sort` step APPENDED to the
670            // input path, each term's own seeking `%` slots are bubbled onto
671            // it, then resolveAncestry walks them backward. Because the sort
672            // step sits after every input step, resolveAncestry starts at the
673            // step BEFORE it -- i.e. the LAST real input step -- so a level-1
674            // term slot resolves against the last input step (no predicate-
675            // style "resolve against the step itself" special case is needed;
676            // it's a plain uniform backward walk over the input steps).
677            let input_t = transform_node(*input, state)?;
678            let was_path = matches!(input_t.node, AstNode::Path { .. });
679            // jsonata wraps a non-path input into a single-step path so the
680            // sort step has something to walk back through. We do the same for
681            // the walk, then unwrap again if nothing tagged the wrapped step.
682            let mut steps = match input_t.node {
683                AstNode::Path { steps } => steps,
684                other => vec![PathStep::new(other)],
685            };
686            let mut pending = input_t.pending;
687            let mut transformed_terms = Vec::with_capacity(terms.len());
688            for (expr, asc) in terms {
689                let t = transform_node(expr, state)?;
690                for slot in t.pending {
691                    let remaining = walk_backward(&mut steps, &slot.label, slot.level, state)?;
692                    if remaining > 0 {
693                        pending.push(PendingAncestor {
694                            label: slot.label,
695                            level: remaining,
696                        });
697                    }
698                }
699                transformed_terms.push((t.node, asc));
700            }
701            let input_node = if was_path {
702                AstNode::Path { steps }
703            } else {
704                // Single-node input: keep it wrapped only if a sort term
705                // actually tagged it (so the ancestor label survives on a
706                // PathStep); otherwise restore the bare node unchanged.
707                let s = steps.pop().expect("single wrapped step");
708                if s.is_tuple || s.ancestor_label.is_some() {
709                    AstNode::Path { steps: vec![s] }
710                } else {
711                    s.node
712                }
713            };
714            Ok(Transformed {
715                node: AstNode::Sort {
716                    input: Box::new(input_node),
717                    terms: transformed_terms,
718                },
719                pending,
720            })
721        }
722        AstNode::Transform {
723            location,
724            update,
725            delete,
726        } => {
727            let location_t = transform_node(*location, state)?;
728            let update_t = transform_node(*update, state)?;
729            let mut pending = location_t.pending;
730            pending.extend(update_t.pending);
731            let delete_t = match delete {
732                Some(d) => Some(transform_node(*d, state)?),
733                None => None,
734            };
735            let delete_node = delete_t.map(|t| {
736                pending.extend(t.pending);
737                Box::new(t.node)
738            });
739            Ok(Transformed {
740                node: AstNode::Transform {
741                    location: Box::new(location_t.node),
742                    update: Box::new(update_t.node),
743                    delete: delete_node,
744                },
745                pending,
746            })
747        }
748        AstNode::FunctionApplication(inner) => {
749            let t = transform_node(*inner, state)?;
750            Ok(Transformed {
751                node: AstNode::FunctionApplication(Box::new(t.node)),
752                pending: t.pending,
753            })
754        }
755        AstNode::ArrayGroup(elements) => {
756            let mut pending = Vec::new();
757            let mut transformed = Vec::with_capacity(elements.len());
758            for e in elements {
759                let t = transform_node(e, state)?;
760                pending.extend(t.pending);
761                transformed.push(t.node);
762            }
763            Ok(Transformed {
764                node: AstNode::ArrayGroup(transformed),
765                pending,
766            })
767        }
768        AstNode::Predicate(inner) => {
769            let t = transform_node(*inner, state)?;
770            Ok(Transformed {
771                node: AstNode::Predicate(Box::new(t.node)),
772                pending: t.pending,
773            })
774        }
775        // Leaf nodes and everything else pass through unchanged.
776        other => Ok(Transformed::leaf(other)),
777    }
778}
779
780/// Resolve a path's steps: migrate `#`/`@` markers into step-level flags,
781/// then walk backward resolving any `%`/`%.%` references, left to right in
782/// step-encounter order. Mirrors resolveAncestry (parser.js ~L1002-1030),
783/// collapsed from jsonata-js's incremental per-'.' invocation into a single
784/// pass: our parser already flattens an entire dotted chain into one flat
785/// `steps` list up front (unlike jsonata-js's nested binary '.' AST nodes,
786/// processed one dot at a time), so resolving every step's own pending
787/// reference against the FULL flattened list-so-far in left-to-right order
788/// produces the same result as jsonata-js's incremental resolution.
789fn transform_path_steps(
790    steps: Vec<PathStep>,
791    state: &mut AncestryState,
792) -> Result<(Vec<PathStep>, Vec<PendingAncestor>), AstTransformError> {
793    // Pass 1: migrate #/@ into step flags, recursing into nested content
794    // (which may itself bubble up pending `%` references, e.g. an object
795    // constructor or array containing a `%`). `own_pending[i]` is whatever
796    // pending arose from producing `resolved[i]` -- attached to the LAST
797    // step of a marker's splice, since that's the step position pending
798    // ancestor resolution should walk backward from.
799    let mut resolved: Vec<PathStep> = Vec::with_capacity(steps.len());
800    let mut own_pending: Vec<Vec<PendingAncestor>> = Vec::with_capacity(steps.len());
801    // `pred_pending[i]` holds the seeking `%` slots bubbled up from step i's
802    // own filter predicate(s) (`Stage::Filter`), transformed here so a `%`
803    // inside `Product[%.OrderID=...]` is resolved (was previously left
804    // untouched, since stages weren't recursed into). Transformed AFTER the
805    // step's node so the step's OWN `%`-ness (if any) claims a label first,
806    // matching jsonata-js's slot-creation order.
807    let mut pred_pending: Vec<Vec<PendingAncestor>> = Vec::with_capacity(steps.len());
808    for step in steps {
809        let (spliced, pending) = migrate_binding_markers(step, state)?;
810        let last_idx = spliced.len().saturating_sub(1);
811        let mut pending_opt = Some(pending);
812        for (i, mut s) in spliced.into_iter().enumerate() {
813            let mut pp: Vec<PendingAncestor> = Vec::new();
814            let stages = std::mem::take(&mut s.stages);
815            let mut new_stages = Vec::with_capacity(stages.len());
816            for stage in stages {
817                match stage {
818                    Stage::Filter(expr) => {
819                        let t = transform_node(*expr, state)?;
820                        pp.extend(t.pending);
821                        new_stages.push(Stage::Filter(Box::new(t.node)));
822                    }
823                    // Index stages carry only a variable name -- nothing to
824                    // resolve/transform.
825                    Stage::Index(v) => new_stages.push(Stage::Index(v)),
826                }
827            }
828            s.stages = new_stages;
829            resolved.push(s);
830            pred_pending.push(pp);
831            if i == last_idx {
832                own_pending.push(pending_opt.take().unwrap_or_default());
833            } else {
834                own_pending.push(Vec::new());
835            }
836        }
837    }
838
839    // Pass 2: for each step (in ascending/encounter order), resolve first its
840    // predicate slots (mirroring jsonata-js pushing predicate slots onto the
841    // step's seekingParent BEFORE the step's own slot), then its own pending.
842    // Any reference that runs off the front of this path (never finding a
843    // target) bubbles up as this whole Path's own pending.
844    let mut path_pending: Vec<PendingAncestor> = Vec::new();
845    for i in 0..resolved.len() {
846        for pending in std::mem::take(&mut pred_pending[i]) {
847            let remaining =
848                resolve_predicate_slot(&mut resolved, i, &pending.label, pending.level, state)?;
849            if remaining > 0 {
850                path_pending.push(PendingAncestor {
851                    label: pending.label,
852                    level: remaining,
853                });
854            }
855        }
856        let pending_here = std::mem::take(&mut own_pending[i]);
857        for pending in pending_here {
858            let remaining =
859                walk_backward(&mut resolved[..i], &pending.label, pending.level, state)?;
860            if remaining > 0 {
861                path_pending.push(PendingAncestor {
862                    label: pending.label,
863                    level: remaining,
864                });
865            }
866        }
867    }
868
869    Ok((resolved, path_pending))
870}
871
872/// Resolve one seeking `%` slot that bubbled up out of a filter predicate
873/// attached to step `i`. Mirrors jsonata-js's `case '['` slot handling
874/// (parser.js ~L1119-1128):
875/// - a `level == 1` slot resolves against the attached step ITSELF first
876///   (`seekParent(step, slot)`): a `name`/`wildcard` step gets tagged; a `%`
877///   (parent) step instead bumps the level and the walk continues backward;
878/// - a `level > 1` slot is decremented (the attached step is skipped, never
879///   tagged) and resolved by walking backward through the steps BEFORE it.
880///
881/// Either way, whatever level remains unresolved is walked backward through
882/// `resolved[..i]`; the leftover (if the reference runs off the path front)
883/// is returned to bubble up as the enclosing path's own pending.
884fn resolve_predicate_slot(
885    resolved: &mut [PathStep],
886    i: usize,
887    label: &str,
888    level: usize,
889    state: &mut AncestryState,
890) -> Result<usize, AstTransformError> {
891    // Split so the attached step (`rest[0]`) and the steps before it
892    // (`prefix`) can be borrowed mutably at the same time.
893    let (prefix, rest) = resolved.split_at_mut(i);
894    let remaining = if level == 1 {
895        seek_parent_step(&mut rest[0], label, 1, state)?
896    } else {
897        level - 1
898    };
899    if remaining == 0 {
900        Ok(0)
901    } else {
902        walk_backward(prefix, label, remaining, state)
903    }
904}
905
906/// Walk backward through `steps` (from its last element) trying to resolve
907/// a single pending ancestor reference at `level`. Returns the remaining
908/// level: 0 means fully resolved (some step in `steps` was tagged); >0 means
909/// `steps` ran out before the reference resolved, so the caller must keep
910/// walking further back through whatever contains `steps` (or, if there is
911/// nothing further back, treat it as still-pending / bubble it up).
912fn walk_backward(
913    steps: &mut [PathStep],
914    label: &str,
915    mut level: usize,
916    state: &mut AncestryState,
917) -> Result<usize, AstTransformError> {
918    let mut index = steps.len();
919    while level > 0 {
920        if index == 0 {
921            return Ok(level);
922        }
923        index -= 1;
924        // Skip filter-predicate pseudo-steps: our parser encodes `@$v[pred]`
925        // and standalone `foo[pred]` chained after a marker as a separate
926        // `Predicate` step, whereas jsonata-js carries the predicate as a
927        // `stage` on the owning step (so it never appears as a distinct step in
928        // resolveAncestry). A predicate is a filter, never an ancestor target,
929        // so the backward ancestry walk steps over it -- without this, a `%`
930        // after `books@$B[$L.isbn=$B.isbn]` hits the predicate step and wrongly
931        // reports S0217.
932        //
933        // Then skip over a run of contiguous focus-bound (`@$var`) steps,
934        // treating them as a SINGLE ancestor hop -- mirrors jsonata-js
935        // resolveAncestry (parser.js ~L1023-1025): `while(index >= 0 &&
936        // step.focus && path.steps[index].focus) { step = path.steps[index--] }`.
937        // Because our extra `Predicate` steps sit between the focus steps (which
938        // in jsonata are adjacent, the predicates being stages), the
939        // focus-contiguity test must look through those predicate steps to the
940        // previous REAL navigation step. So in
941        // `library.loans@$L.books@$B[...].customers@$C[...].{ $keys(%.%) }` all
942        // three focus steps collapse into one hop and `%.%` reaches the root.
943        loop {
944            while index > 0 && matches!(steps[index].node, AstNode::Predicate(_)) {
945                index -= 1;
946            }
947            // Locate the previous non-predicate step (if any) to test contiguity.
948            let mut prev = None;
949            if index > 0 {
950                let mut j = index - 1;
951                loop {
952                    if !matches!(steps[j].node, AstNode::Predicate(_)) {
953                        prev = Some(j);
954                        break;
955                    }
956                    if j == 0 {
957                        break;
958                    }
959                    j -= 1;
960                }
961            }
962            match prev {
963                Some(p) if steps[index].focus.is_some() && steps[p].focus.is_some() => {
964                    index = p;
965                }
966                _ => break,
967            }
968        }
969        level = seek_parent_step(&mut steps[index], label, level, state)?;
970    }
971    Ok(0)
972}
973
974/// Try to resolve one level of a pending ancestor reference against a
975/// single candidate step. Returns the remaining level (0 = tagged here).
976/// Mirrors jsonata-js's seekParent (parser.js ~L941-986).
977fn seek_parent_step(
978    step: &mut PathStep,
979    label: &str,
980    level: usize,
981    state: &mut AncestryState,
982) -> Result<usize, AstTransformError> {
983    match &mut step.node {
984        AstNode::Name(_) | AstNode::Wildcard => {
985            let remaining = level - 1;
986            if remaining == 0 {
987                match &step.ancestor_label {
988                    // Reuse: an earlier `%` already tagged this exact step.
989                    // Record the alias instead of overwriting (see
990                    // AncestryState's doc comment).
991                    Some(existing) => {
992                        state.aliases.insert(label.to_string(), existing.clone());
993                    }
994                    None => {
995                        step.ancestor_label = Some(label.to_string());
996                    }
997                }
998                step.is_tuple = true;
999            }
1000            Ok(remaining)
1001        }
1002        // Chained %.%: this step is itself another (already independently
1003        // resolved-or-pending) `%` -- extend the level and keep walking
1004        // further back, exactly mirroring seekParent's `case 'parent':
1005        // slot.level++` (which notably does NOT set `.tuple` here).
1006        AstNode::Parent(_) => Ok(level + 1),
1007        // Parenthesized sub-path as a path step (e.g. `Account.(Order.Product).%`
1008        // parses `(Order.Product)` as `FunctionApplication(Path{...})`) --
1009        // mirrors seekParent's 'block'/'path' cases layered together: this
1010        // outer step becomes tuple-producing regardless of where inside the
1011        // parens the actual ancestor tag lands, and we recurse inward to
1012        // find it.
1013        AstNode::FunctionApplication(inner) => {
1014            step.is_tuple = true;
1015            seek_parent_wrapped(inner.as_mut(), label, level, state)
1016        }
1017        // A parenthesized block reached directly as a path step (e.g. a
1018        // leading `(Account.Order)` with no `.` before it, or a multi-
1019        // statement `(...)`) -- mirrors seekParent's 'block' case: recurse
1020        // into the LAST expression.
1021        AstNode::Block(exprs) => match exprs.last_mut() {
1022            Some(last) => {
1023                step.is_tuple = true;
1024                seek_parent_wrapped(last, label, level, state)
1025            }
1026            // An empty block `()` produces no ancestor and no tuple; the walk
1027            // simply steps over it with the level unchanged (mirrors jsonata-js
1028            // seekParent's `if(node.expressions.length > 0)` guard, which leaves
1029            // the slot untouched for an empty block). Lets `Account.Order.().%`
1030            // resolve `%` against `Order` rather than raising S0217.
1031            None => Ok(level),
1032        },
1033        _ => Err(coded(
1034            "S0217",
1035            "The parent operator % cannot derive an ancestor from this kind of path step",
1036        )),
1037    }
1038}
1039
1040/// Recurse into a "wrapped" target (a `FunctionApplication`'s sole inner
1041/// expression, or a `Block`'s last expression) that must itself resolve to
1042/// a nested `Path` for us to walk backward through it -- mirrors how
1043/// jsonata-js's block/path seekParent cases can be layered on top of each
1044/// other for doubly-nested parens (e.g. `Account.(Order.(Product)).%`).
1045/// Anything else (a literal, a function call, ...) can't derive an
1046/// ancestor: S0217.
1047fn seek_parent_wrapped(
1048    node: &mut AstNode,
1049    label: &str,
1050    level: usize,
1051    state: &mut AncestryState,
1052) -> Result<usize, AstTransformError> {
1053    match node {
1054        AstNode::Path { steps } => walk_backward(steps, label, level, state),
1055        // A nested block (e.g. the inner `()` of `.()`, or `(a; b)`): recurse
1056        // into its last expression, or -- for an empty block -- step over it
1057        // leaving the level unchanged (jsonata-js seekParent's block guard).
1058        AstNode::Block(exprs) => match exprs.last_mut() {
1059            Some(last) => seek_parent_wrapped(last, label, level, state),
1060            None => Ok(level),
1061        },
1062        _ => Err(coded(
1063            "S0217",
1064            "The parent operator % cannot derive an ancestor from this kind of expression",
1065        )),
1066    }
1067}
1068
1069/// Convert a step's raw-parse-time binding marker (if any) into the unified
1070/// PathStep flags, recursing into the step's own node first (a step's node
1071/// can itself be a Block/nested Path containing `%`/`@`/`#`).
1072///
1073/// Returns a `Vec` (not a single `PathStep`) because a marker's `lhs`/`input`
1074/// can itself turn out to be a multi-step `Path` -- see `splice_marker_steps`
1075/// -- in which case ALL of those steps must be spliced into the caller's
1076/// flat list in place of this one input step, with the marker's flags
1077/// stamped onto the LAST of them (not onto a step wrapping the whole thing).
1078/// Also returns whatever pending ancestor references bubbled up from
1079/// transforming this step's content.
1080fn migrate_binding_markers(
1081    mut step: PathStep,
1082    state: &mut AncestryState,
1083) -> Result<(Vec<PathStep>, Vec<PendingAncestor>), AstTransformError> {
1084    match step.node {
1085        AstNode::Binary {
1086            op: BinaryOp::FocusBind,
1087            lhs,
1088            rhs,
1089        } => {
1090            let var_name = match *rhs {
1091                AstNode::Variable(name) => name,
1092                _ => unreachable!("parser guarantees FocusBind's rhs is always Variable"),
1093            };
1094            let transformed_lhs = transform_node(*lhs, state)?;
1095            splice_marker_steps(transformed_lhs, BindingMarker::Focus(var_name))
1096        }
1097        AstNode::Binary {
1098            op: BinaryOp::IndexBind,
1099            lhs,
1100            rhs,
1101        } => {
1102            let var_name = match *rhs {
1103                AstNode::Variable(name) => name,
1104                _ => unreachable!("parser guarantees IndexBind's rhs is always Variable"),
1105            };
1106            let transformed_lhs = transform_node(*lhs, state)?;
1107            splice_marker_steps(transformed_lhs, BindingMarker::Index(var_name))
1108        }
1109        other => {
1110            let t = transform_node(other, state)?;
1111            step.node = t.node;
1112            Ok((vec![step], t.pending))
1113        }
1114    }
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use super::*;
1120
1121    // --- Task 6: `%` inside filter predicates and sort terms ---
1122    //
1123    // Mechanism ported from jsonata-js processAST (parser.js). Ground truth
1124    // for every tag target below was dumped from jsonata-js's own `.ast()`
1125    // (via `node -e 'jsonata(expr).ast()'` in tests/jsonata-js).
1126    //
1127    // PREDICATE (`case '['`, parser.js ~L1097-1130): each slot the predicate
1128    // is still seeking is examined -- a level-1 slot resolves against the
1129    // STEP the predicate is attached to (`seekParent(step, slot)`, which tags
1130    // that step, or bumps the level if the step is itself a `%`); a level>N>1
1131    // slot is decremented and then resolved by walking backward through the
1132    // steps BEFORE the attached step. In our flat-path model this is: for a
1133    // predicate slot on step i, level==1 -> seek_parent_step(resolved[i]);
1134    // level>1 -> walk_backward(resolved[..i], level-1).
1135    //
1136    // SORT (`case '^'`, parser.js ~L1151-1170): jsonata appends a synthetic
1137    // `sort` step to the input path, bubbles every term's own seeking slots
1138    // onto it, then runs resolveAncestry -- which walks backward starting at
1139    // the step BEFORE the sort step, i.e. the LAST real input step. So a
1140    // level-1 sort-term slot resolves against the last input step (no
1141    // predicate-style "attach to the step itself" special case is needed;
1142    // it's a uniform backward walk over the input steps).
1143
1144    // Helper: locate the ancestor_label a resolved path assigns to a given
1145    // step index, panicking with context if the shape is wrong.
1146    fn resolve_path(expr: &str) -> Vec<PathStep> {
1147        let ast = crate::parser::Parser::new(expr.to_string())
1148            .unwrap()
1149            .parse()
1150            .unwrap();
1151        match resolve_ancestry(ast).unwrap() {
1152            AstNode::Path { steps } => steps,
1153            other => panic!("expected Path, got {:?}", other),
1154        }
1155    }
1156
1157    #[test]
1158    fn test_parent_inside_predicate_resolves_against_enclosing_step() {
1159        // Account.Order.Product[%.OrderID='order104'].SKU
1160        // Ground truth (jsonata-js .ast()): the `%` inside the predicate
1161        // tags the Product step (steps[2]) -- i.e. `%` resolves to Product's
1162        // own input (Order), and Product itself carries the ancestor label.
1163        let steps = resolve_path("Account.Order.Product[%.OrderID='order104'].SKU");
1164        assert_eq!(steps.len(), 4);
1165        assert!(matches!(steps[2].node, AstNode::Name(ref n) if n == "Product"));
1166        let product_label = steps[2].ancestor_label.clone();
1167        assert!(product_label.is_some(), "Product must be tagged");
1168        assert!(steps[2].is_tuple);
1169        assert!(
1170            steps[1].ancestor_label.is_none(),
1171            "Order must NOT be tagged"
1172        );
1173        // The `%` inside the predicate must carry Product's label.
1174        match &steps[2].stages[0] {
1175            Stage::Index(_) => unreachable!("no index stage in this test"),
1176            Stage::Filter(expr) => match expr.as_ref() {
1177                AstNode::Binary { lhs, .. } => match lhs.as_ref() {
1178                    AstNode::Path { steps: inner } => match &inner[0].node {
1179                        AstNode::Parent(label) => {
1180                            assert_eq!(Some(label.clone()), product_label)
1181                        }
1182                        other => panic!("expected Parent, got {:?}", other),
1183                    },
1184                    other => panic!("expected inner Path, got {:?}", other),
1185                },
1186                other => panic!("expected Binary, got {:?}", other),
1187            },
1188        }
1189    }
1190
1191    #[test]
1192    fn test_parent_chain_inside_predicate_resolves_two_levels() {
1193        // Account.Order.Product[%.%.`Account Name`='Firefly'].SKU
1194        // Ground truth: first `%` tags Product (steps[2]), second `%` tags
1195        // Order (steps[1]).
1196        let steps = resolve_path("Account.Order.Product[%.%.`Account Name`='Firefly'].SKU");
1197        assert_eq!(steps.len(), 4);
1198        let product_label = steps[2].ancestor_label.clone();
1199        let order_label = steps[1].ancestor_label.clone();
1200        assert!(product_label.is_some(), "Product must be tagged");
1201        assert!(order_label.is_some(), "Order must be tagged");
1202        assert_ne!(product_label, order_label);
1203        match &steps[2].stages[0] {
1204            Stage::Index(_) => unreachable!("no index stage in this test"),
1205            Stage::Filter(expr) => match expr.as_ref() {
1206                AstNode::Binary { lhs, .. } => match lhs.as_ref() {
1207                    AstNode::Path { steps: inner } => {
1208                        // inner = [Parent, Parent, Name("Account Name")]
1209                        match &inner[0].node {
1210                            AstNode::Parent(l) => assert_eq!(Some(l.clone()), product_label),
1211                            other => panic!("expected Parent, got {:?}", other),
1212                        }
1213                        match &inner[1].node {
1214                            AstNode::Parent(l) => assert_eq!(Some(l.clone()), order_label),
1215                            other => panic!("expected Parent, got {:?}", other),
1216                        }
1217                    }
1218                    other => panic!("expected inner Path, got {:?}", other),
1219                },
1220                other => panic!("expected Binary, got {:?}", other),
1221            },
1222        }
1223    }
1224
1225    #[test]
1226    fn test_parent_predicate_on_parent_step_itself() {
1227        // Account.Order.Product.Price.%[%.OrderID='order103'].SKU
1228        // Ground truth: the trailing `.%` step's own reference tags Price
1229        // (steps[3]); the predicate's `%` (attached to a `%` step, so bumped
1230        // one level) tags Product (steps[2]).
1231        let steps = resolve_path("Account.Order.Product.Price.%[%.OrderID='order103'].SKU");
1232        // [Account, Order, Product, Price, %(stages), SKU]
1233        assert_eq!(steps.len(), 6);
1234        assert!(matches!(steps[4].node, AstNode::Parent(_)));
1235        let price_label = steps[3].ancestor_label.clone();
1236        let product_label = steps[2].ancestor_label.clone();
1237        assert!(
1238            price_label.is_some(),
1239            "Price must be tagged (by the % step)"
1240        );
1241        assert!(
1242            product_label.is_some(),
1243            "Product must be tagged (by the predicate %)"
1244        );
1245        assert_ne!(price_label, product_label);
1246    }
1247
1248    #[test]
1249    fn test_two_predicates_share_and_differ_labels() {
1250        // Account.Order.Product[%.OrderID='order104'][%.%.`Account Name`='Firefly'].SKU
1251        // Ground truth: first predicate's `%` -> Product; second predicate's
1252        // first `%` -> Product (REUSE same label); second `%` -> Order.
1253        let steps = resolve_path(
1254            "Account.Order.Product[%.OrderID='order104'][%.%.`Account Name`='Firefly'].SKU",
1255        );
1256        assert_eq!(steps.len(), 4);
1257        assert_eq!(steps[2].stages.len(), 2);
1258        let product_label = steps[2].ancestor_label.clone();
1259        let order_label = steps[1].ancestor_label.clone();
1260        assert!(product_label.is_some());
1261        assert!(order_label.is_some());
1262        assert_ne!(product_label, order_label);
1263        // first predicate: %  -> Product
1264        match &steps[2].stages[0] {
1265            Stage::Index(_) => unreachable!("no index stage in this test"),
1266            Stage::Filter(expr) => match expr.as_ref() {
1267                AstNode::Binary { lhs, .. } => match lhs.as_ref() {
1268                    AstNode::Path { steps: inner } => match &inner[0].node {
1269                        AstNode::Parent(l) => assert_eq!(Some(l.clone()), product_label),
1270                        other => panic!("{:?}", other),
1271                    },
1272                    other => panic!("{:?}", other),
1273                },
1274                other => panic!("{:?}", other),
1275            },
1276        }
1277        // second predicate: %.% -> Product (reuse), Order
1278        match &steps[2].stages[1] {
1279            Stage::Index(_) => unreachable!("no index stage in this test"),
1280            Stage::Filter(expr) => match expr.as_ref() {
1281                AstNode::Binary { lhs, .. } => match lhs.as_ref() {
1282                    AstNode::Path { steps: inner } => {
1283                        match &inner[0].node {
1284                            AstNode::Parent(l) => assert_eq!(Some(l.clone()), product_label),
1285                            other => panic!("{:?}", other),
1286                        }
1287                        match &inner[1].node {
1288                            AstNode::Parent(l) => assert_eq!(Some(l.clone()), order_label),
1289                            other => panic!("{:?}", other),
1290                        }
1291                    }
1292                    other => panic!("{:?}", other),
1293                },
1294                other => panic!("{:?}", other),
1295            },
1296        }
1297    }
1298
1299    #[test]
1300    fn test_parent_inside_sort_term_resolves_to_last_input_step() {
1301        // Account.Order.Product.SKU^(%.Price)
1302        // Ground truth: the sort term's `%` tags SKU (the last input step).
1303        let ast = crate::parser::Parser::new("Account.Order.Product.SKU^(%.Price)".to_string())
1304            .unwrap()
1305            .parse()
1306            .unwrap();
1307        match resolve_ancestry(ast).unwrap() {
1308            AstNode::Sort { input, terms } => {
1309                let steps = match input.as_ref() {
1310                    AstNode::Path { steps } => steps,
1311                    other => panic!("expected Path input, got {:?}", other),
1312                };
1313                assert_eq!(steps.len(), 4);
1314                assert!(matches!(steps[3].node, AstNode::Name(ref n) if n == "SKU"));
1315                let sku_label = steps[3].ancestor_label.clone();
1316                assert!(sku_label.is_some(), "SKU must be tagged");
1317                // term = (Path[Parent, Name("Price")], asc)
1318                match &terms[0].0 {
1319                    AstNode::Path { steps: inner } => match &inner[0].node {
1320                        AstNode::Parent(l) => assert_eq!(Some(l.clone()), sku_label),
1321                        other => panic!("{:?}", other),
1322                    },
1323                    other => panic!("{:?}", other),
1324                }
1325            }
1326            other => panic!("expected Sort, got {:?}", other),
1327        }
1328    }
1329
1330    #[test]
1331    fn test_two_sort_terms_share_and_differ_labels() {
1332        // Account.Order.Product.SKU^(%.Price, >%.%.OrderID)
1333        // Ground truth: term1 `%` -> SKU; term2 `%.%` -> SKU (reuse), Product.
1334        let ast = crate::parser::Parser::new(
1335            "Account.Order.Product.SKU^(%.Price, >%.%.OrderID)".to_string(),
1336        )
1337        .unwrap()
1338        .parse()
1339        .unwrap();
1340        match resolve_ancestry(ast).unwrap() {
1341            AstNode::Sort { input, terms } => {
1342                let steps = match input.as_ref() {
1343                    AstNode::Path { steps } => steps,
1344                    other => panic!("{:?}", other),
1345                };
1346                let sku_label = steps[3].ancestor_label.clone();
1347                let product_label = steps[2].ancestor_label.clone();
1348                assert!(sku_label.is_some());
1349                assert!(product_label.is_some());
1350                assert_ne!(sku_label, product_label);
1351                assert_eq!(terms.len(), 2);
1352                // term2 = %.%.OrderID
1353                match &terms[1].0 {
1354                    AstNode::Path { steps: inner } => {
1355                        match &inner[0].node {
1356                            AstNode::Parent(l) => assert_eq!(Some(l.clone()), sku_label),
1357                            other => panic!("{:?}", other),
1358                        }
1359                        match &inner[1].node {
1360                            AstNode::Parent(l) => assert_eq!(Some(l.clone()), product_label),
1361                            other => panic!("{:?}", other),
1362                        }
1363                    }
1364                    other => panic!("{:?}", other),
1365                }
1366            }
1367            other => panic!("expected Sort, got {:?}", other),
1368        }
1369    }
1370
1371    #[test]
1372    fn test_focus_bind_becomes_step_flag() {
1373        // Order@$o  -->  Path{steps: [Name("Order") with focus=Some("o"), is_tuple=true]}
1374        let ast = AstNode::Path {
1375            steps: vec![PathStep::new(AstNode::Binary {
1376                op: BinaryOp::FocusBind,
1377                lhs: Box::new(AstNode::Name("Order".to_string())),
1378                rhs: Box::new(AstNode::Variable("o".to_string())),
1379            })],
1380        };
1381        let result = resolve_ancestry(ast).unwrap();
1382        match result {
1383            AstNode::Path { steps } => {
1384                assert_eq!(steps.len(), 1);
1385                assert!(matches!(steps[0].node, AstNode::Name(ref n) if n == "Order"));
1386                assert_eq!(steps[0].focus, Some("o".to_string()));
1387                assert!(steps[0].is_tuple);
1388            }
1389            other => panic!("expected Path, got {:?}", other),
1390        }
1391    }
1392
1393    #[test]
1394    fn test_index_bind_becomes_step_flag() {
1395        // arr#$i  -->  Path{steps: [Name("arr") with index_var=Some("i"), is_tuple=true]}
1396        let ast = AstNode::Path {
1397            steps: vec![PathStep::new(AstNode::Binary {
1398                op: BinaryOp::IndexBind,
1399                lhs: Box::new(AstNode::Name("arr".to_string())),
1400                rhs: Box::new(AstNode::Variable("i".to_string())),
1401            })],
1402        };
1403        let result = resolve_ancestry(ast).unwrap();
1404        match result {
1405            AstNode::Path { steps } => {
1406                assert_eq!(steps.len(), 1);
1407                assert!(matches!(steps[0].node, AstNode::Name(ref n) if n == "arr"));
1408                assert_eq!(steps[0].index_var, Some("i".to_string()));
1409                assert!(steps[0].is_tuple);
1410            }
1411            other => panic!("expected Path, got {:?}", other),
1412        }
1413    }
1414
1415    #[test]
1416    fn test_bare_parent_at_top_level_is_s0217() {
1417        let err = resolve_ancestry(AstNode::Parent(String::new())).unwrap_err();
1418        assert!(err.to_string().starts_with("S0217"));
1419    }
1420
1421    #[test]
1422    fn test_path_step_with_stages_preserved() {
1423        // Ensure stages (predicates) survive the transform unchanged when
1424        // there's no binding marker involved.
1425        let ast = AstNode::Path {
1426            steps: vec![PathStep::with_stages(
1427                AstNode::Name("Order".to_string()),
1428                vec![Stage::Filter(Box::new(AstNode::Boolean(true)))],
1429            )],
1430        };
1431        let result = resolve_ancestry(ast).unwrap();
1432        match result {
1433            AstNode::Path { steps } => {
1434                assert_eq!(steps[0].stages.len(), 1);
1435            }
1436            other => panic!("expected Path, got {:?}", other),
1437        }
1438    }
1439
1440    // --- Regression tests using the REAL parser (Task 3 review findings) ---
1441    //
1442    // Hand-built synthetic ASTs only exercise the shapes that happen to
1443    // already work. These tests go through `crate::parser::parse()` on real
1444    // source text, which is what surfaced two root-cause bugs in Task 3:
1445    // (1) transform_children not recursing into most composite node types,
1446    // and (2) `@$var`/`#$var` never being migrated when the marker is the
1447    // TOP-LEVEL node reaching transform_node (only when already nested
1448    // inside a PathStep). The same discipline applies to Task 4's `%`
1449    // resolution below: expected label/level assertions are checked for
1450    // internal consistency (same target step -> same label; different
1451    // targets -> different labels) rather than against jsonata-js's exact
1452    // "!0"/"!1"/... strings, since those are implementation-internal and
1453    // arbitrary -- but the STEPS that get tagged are cross-checked against
1454    // jsonata-js's actual `.ast()` output (see comments below).
1455
1456    #[test]
1457    fn test_real_parser_bare_focus_bind_no_dot() {
1458        // "Order@$o" -- bare single-step, no dot anywhere. The parser
1459        // produces Binary{FocusBind, lhs: Name("Order"), rhs: Variable("o")}
1460        // at the top level (no Path at all, since there's no `.`).
1461        let ast = crate::parser::Parser::new("Order@$o".to_string())
1462            .unwrap()
1463            .parse()
1464            .unwrap();
1465        let result = resolve_ancestry(ast).unwrap();
1466        match result {
1467            AstNode::Path { steps } => {
1468                assert_eq!(steps.len(), 1);
1469                assert!(matches!(steps[0].node, AstNode::Name(ref n) if n == "Order"));
1470                assert_eq!(steps[0].focus, Some("o".to_string()));
1471                assert!(steps[0].is_tuple);
1472            }
1473            other => panic!("expected Path, got {:?}", other),
1474        }
1475    }
1476
1477    #[test]
1478    fn test_real_parser_focus_bind_on_final_step_of_multistep_path() {
1479        // "Account.Order@$o" -- 2-step path, marker on the final step, no
1480        // trailing dot. Previously: `@` wrapped the whole 2-step Path in a
1481        // top-level Binary{FocusBind,...} that was never migrated (Bug 2).
1482        let ast = crate::parser::Parser::new("Account.Order@$o".to_string())
1483            .unwrap()
1484            .parse()
1485            .unwrap();
1486        let result = resolve_ancestry(ast).unwrap();
1487        match result {
1488            AstNode::Path { steps } => {
1489                assert_eq!(steps.len(), 2);
1490                assert!(matches!(steps[0].node, AstNode::Name(ref n) if n == "Account"));
1491                assert!(steps[0].focus.is_none());
1492                assert!(!steps[0].is_tuple);
1493                assert!(matches!(steps[1].node, AstNode::Name(ref n) if n == "Order"));
1494                assert_eq!(steps[1].focus, Some("o".to_string()));
1495                assert!(steps[1].is_tuple);
1496            }
1497            other => panic!("expected Path, got {:?}", other),
1498        }
1499    }
1500
1501    #[test]
1502    fn test_real_parser_bare_index_bind() {
1503        // "arr#$i" -- bare index bind, no dot. Previously never migrated
1504        // when reaching transform_node as the raw top-level IndexBind node.
1505        let ast = crate::parser::Parser::new("arr#$i".to_string())
1506            .unwrap()
1507            .parse()
1508            .unwrap();
1509        let result = resolve_ancestry(ast).unwrap();
1510        match result {
1511            AstNode::Path { steps } => {
1512                assert_eq!(steps.len(), 1);
1513                assert!(matches!(steps[0].node, AstNode::Name(ref n) if n == "arr"));
1514                assert_eq!(steps[0].index_var, Some("i".to_string()));
1515                assert!(steps[0].is_tuple);
1516            }
1517            other => panic!("expected Path, got {:?}", other),
1518        }
1519    }
1520
1521    #[test]
1522    fn test_real_parser_bare_parent_inside_function_args_is_s0217() {
1523        // "$count(%)" -- a bare `%` nested inside a Function call's args.
1524        // Previously transform_children didn't recurse into Function args
1525        // at all (Bug 1), so this silently returned Ok(unchanged) instead
1526        // of raising S0217.
1527        let ast = crate::parser::Parser::new("$count(%)".to_string())
1528            .unwrap()
1529            .parse()
1530            .unwrap();
1531        let err = resolve_ancestry(ast).unwrap_err();
1532        assert!(err.to_string().starts_with("S0217"));
1533    }
1534
1535    // --- Regression tests for the "nested Path from multi-step @/# marker"
1536    // finding (Task 3, second review round) ---
1537
1538    #[test]
1539    fn test_real_parser_focus_bind_multistep_prefix_and_suffix_is_flat() {
1540        // "Account.Order@$o.Product" must produce a FLAT 3-step path, not a
1541        // 2-step path whose first step's node is itself a nested 2-step Path.
1542        let ast = crate::parser::Parser::new("Account.Order@$o.Product".to_string())
1543            .unwrap()
1544            .parse()
1545            .unwrap();
1546        let result = resolve_ancestry(ast).unwrap();
1547        match result {
1548            AstNode::Path { steps } => {
1549                assert_eq!(steps.len(), 3, "expected a flat 3-step path");
1550                assert!(matches!(steps[0].node, AstNode::Name(ref n) if n == "Account"));
1551                assert!(steps[0].focus.is_none());
1552                assert!(!steps[0].is_tuple);
1553                assert!(matches!(steps[1].node, AstNode::Name(ref n) if n == "Order"));
1554                assert_eq!(steps[1].focus, Some("o".to_string()));
1555                assert!(steps[1].is_tuple);
1556                assert!(matches!(steps[2].node, AstNode::Name(ref n) if n == "Product"));
1557                assert!(steps[2].focus.is_none());
1558            }
1559            other => panic!("expected flat Path, got {:?}", other),
1560        }
1561    }
1562
1563    #[test]
1564    fn test_real_parser_index_bind_multistep_prefix_and_suffix_is_flat() {
1565        // Same shape as above but for `#$i` (IndexBind) instead of `@$o`.
1566        let ast = crate::parser::Parser::new("Account.Order#$i.Product".to_string())
1567            .unwrap()
1568            .parse()
1569            .unwrap();
1570        let result = resolve_ancestry(ast).unwrap();
1571        match result {
1572            AstNode::Path { steps } => {
1573                assert_eq!(steps.len(), 3, "expected a flat 3-step path");
1574                assert!(matches!(steps[0].node, AstNode::Name(ref n) if n == "Account"));
1575                assert!(steps[0].index_var.is_none());
1576                assert!(!steps[0].is_tuple);
1577                assert!(matches!(steps[1].node, AstNode::Name(ref n) if n == "Order"));
1578                assert_eq!(steps[1].index_var, Some("i".to_string()));
1579                assert!(steps[1].is_tuple);
1580                assert!(matches!(steps[2].node, AstNode::Name(ref n) if n == "Product"));
1581                assert!(steps[2].index_var.is_none());
1582            }
1583            other => panic!("expected flat Path, got {:?}", other),
1584        }
1585    }
1586
1587    // --- Task 4: `%`/`%.%` ancestor resolution, real-parser-based ---
1588    //
1589    // Ground truth for every test below was independently verified against
1590    // jsonata-js's OWN `.ast()` output (`node -e 'jsonata(expr).ast()'` in
1591    // tests/jsonata-js), not derived by hand. This is what caught the task
1592    // brief's off-by-one (it asserted the wrong target steps for a `%.%`
1593    // chain) before any code was written against it.
1594
1595    #[test]
1596    fn test_real_parser_single_level_parent_resolves_to_previous_step() {
1597        // "Account.Order.%" -- jsonata-js tags `Order` (steps[1]), and the
1598        // trailing `%` step (steps[2]) carries the matching label. `%`
1599        // refers to Order's own INPUT (i.e. what produced it, Account) --
1600        // confirmed by live evaluation: Account.Order.% evaluates to the
1601        // Account object, not the Order object.
1602        let ast = crate::parser::Parser::new("Account.Order.%".to_string())
1603            .unwrap()
1604            .parse()
1605            .unwrap();
1606        let result = resolve_ancestry(ast).unwrap();
1607        match result {
1608            AstNode::Path { steps } => {
1609                assert_eq!(steps.len(), 3);
1610                assert!(matches!(steps[0].node, AstNode::Name(ref n) if n == "Account"));
1611                assert!(steps[0].ancestor_label.is_none());
1612                assert!(matches!(steps[1].node, AstNode::Name(ref n) if n == "Order"));
1613                assert!(steps[1].ancestor_label.is_some());
1614                assert!(steps[1].is_tuple);
1615                match &steps[2].node {
1616                    AstNode::Parent(label) => {
1617                        assert_eq!(Some(label.clone()), steps[1].ancestor_label);
1618                    }
1619                    other => panic!("expected Parent(label), got {:?}", other),
1620                }
1621            }
1622            other => panic!("expected Path, got {:?}", other),
1623        }
1624    }
1625
1626    #[test]
1627    fn test_real_parser_chained_parent_resolves_two_levels_back() {
1628        // "Account.Order.Product.%.%" (mirrors parent002.jsonata's shape).
1629        // Ground truth from jsonata-js: the FIRST `%` tags Product
1630        // (steps[2]), the SECOND `%` tags Order (steps[1]) -- NOT Order and
1631        // Account as a naive reading might suggest. Each `%` targets the
1632        // step whose INPUT it refers to: the first `%`'s target is Product
1633        // (whose input is Order), the second `%` walks one step further
1634        // back to Order (whose input is Account).
1635        let ast = crate::parser::Parser::new("Account.Order.Product.%.%".to_string())
1636            .unwrap()
1637            .parse()
1638            .unwrap();
1639        let result = resolve_ancestry(ast).unwrap();
1640        match result {
1641            AstNode::Path { steps } => {
1642                assert_eq!(steps.len(), 5);
1643                assert!(steps[0].ancestor_label.is_none(), "Account untagged");
1644                let order_label = steps[1].ancestor_label.clone();
1645                let product_label = steps[2].ancestor_label.clone();
1646                assert!(order_label.is_some(), "Order must be tagged");
1647                assert!(product_label.is_some(), "Product must be tagged");
1648                assert_ne!(
1649                    order_label, product_label,
1650                    "two distinct % chains must get distinct labels"
1651                );
1652                match &steps[3].node {
1653                    AstNode::Parent(label) => assert_eq!(Some(label.clone()), product_label),
1654                    other => panic!("expected Parent(label), got {:?}", other),
1655                }
1656                match &steps[4].node {
1657                    AstNode::Parent(label) => assert_eq!(Some(label.clone()), order_label),
1658                    other => panic!("expected Parent(label), got {:?}", other),
1659                }
1660            }
1661            other => panic!("expected Path, got {:?}", other),
1662        }
1663    }
1664
1665    #[test]
1666    fn test_real_parser_object_constructor_percent_tags_preceding_step() {
1667        // parent000.jsonata's shape: "Account.Order.Product.{'order': %.OrderID}"
1668        // -- % lives INSIDE the object constructor's value, not as its own
1669        // trailing path step. Ground truth (jsonata-js): Product (steps[2])
1670        // gets tagged, and the nested %'s label matches.
1671        let ast =
1672            crate::parser::Parser::new("Account.Order.Product.{'order': %.OrderID}".to_string())
1673                .unwrap()
1674                .parse()
1675                .unwrap();
1676        let result = resolve_ancestry(ast).unwrap();
1677        match result {
1678            AstNode::Path { steps } => {
1679                assert_eq!(steps.len(), 4);
1680                assert!(matches!(steps[2].node, AstNode::Name(ref n) if n == "Product"));
1681                let product_label = steps[2].ancestor_label.clone();
1682                assert!(product_label.is_some());
1683                assert!(steps[2].is_tuple);
1684                match &steps[3].node {
1685                    AstNode::Object(pairs) => {
1686                        assert_eq!(pairs.len(), 1);
1687                        match &pairs[0].1 {
1688                            AstNode::Path { steps: inner } => {
1689                                assert_eq!(inner.len(), 2);
1690                                match &inner[0].node {
1691                                    AstNode::Parent(label) => {
1692                                        assert_eq!(Some(label.clone()), product_label)
1693                                    }
1694                                    other => panic!("expected Parent(label), got {:?}", other),
1695                                }
1696                            }
1697                            other => panic!("expected inner Path, got {:?}", other),
1698                        }
1699                    }
1700                    other => panic!("expected Object, got {:?}", other),
1701                }
1702            }
1703            other => panic!("expected Path, got {:?}", other),
1704        }
1705    }
1706
1707    #[test]
1708    fn test_real_parser_object_constructor_two_percent_chains_share_and_differ() {
1709        // parent002.jsonata's actual shape:
1710        // "Account.Order.Product.{'Product':`Product Name`,'Order':%.OrderID,'Account':%.%.`Account Name`}"
1711        // Ground truth (jsonata-js, verified via live .ast() dump): the
1712        // 'Order' value's single `%` and the 'Account' value's FIRST `%`
1713        // (of its `%.%` chain) both resolve to Product -- i.e. they share
1714        // ONE label (the "reuse an existing label" mechanic) -- while the
1715        // 'Account' value's SECOND `%` resolves to Order, getting a
1716        // DIFFERENT label.
1717        let ast = crate::parser::Parser::new(
1718            "Account.Order.Product.{'Product':`Product Name`,'Order':%.OrderID,'Account':%.%.`Account Name`}"
1719                .to_string(),
1720        )
1721        .unwrap()
1722        .parse()
1723        .unwrap();
1724        let result = resolve_ancestry(ast).unwrap();
1725        match result {
1726            AstNode::Path { steps } => {
1727                assert_eq!(steps.len(), 4);
1728                let product_label = steps[2].ancestor_label.clone();
1729                let order_label = steps[1].ancestor_label.clone();
1730                assert!(product_label.is_some(), "Product must be tagged");
1731                assert!(order_label.is_some(), "Order must be tagged");
1732                assert_ne!(product_label, order_label);
1733
1734                match &steps[3].node {
1735                    AstNode::Object(pairs) => {
1736                        assert_eq!(pairs.len(), 3);
1737                        // pairs[1] = 'Order': %.OrderID
1738                        match &pairs[1].1 {
1739                            AstNode::Path { steps: inner } => match &inner[0].node {
1740                                AstNode::Parent(label) => {
1741                                    assert_eq!(Some(label.clone()), product_label)
1742                                }
1743                                other => panic!("expected Parent(label), got {:?}", other),
1744                            },
1745                            other => panic!("expected inner Path, got {:?}", other),
1746                        }
1747                        // pairs[2] = 'Account': %.%.`Account Name`
1748                        match &pairs[2].1 {
1749                            AstNode::Path { steps: inner } => {
1750                                assert_eq!(inner.len(), 3);
1751                                match &inner[0].node {
1752                                    AstNode::Parent(label) => {
1753                                        // Reuse: same label as the 'Order'
1754                                        // value's % (both target Product).
1755                                        assert_eq!(Some(label.clone()), product_label)
1756                                    }
1757                                    other => panic!("expected Parent(label), got {:?}", other),
1758                                }
1759                                match &inner[1].node {
1760                                    AstNode::Parent(label) => {
1761                                        assert_eq!(Some(label.clone()), order_label)
1762                                    }
1763                                    other => panic!("expected Parent(label), got {:?}", other),
1764                                }
1765                            }
1766                            other => panic!("expected inner Path, got {:?}", other),
1767                        }
1768                    }
1769                    other => panic!("expected Object, got {:?}", other),
1770                }
1771            }
1772            other => panic!("expected Path, got {:?}", other),
1773        }
1774    }
1775
1776    #[test]
1777    fn test_real_parser_percent_through_parenthesized_step_function_application() {
1778        // parent001.jsonata's shape: "Account.(Order.Product).%" -- parens
1779        // around a multi-step sub-path parse as a FunctionApplication step
1780        // wrapping a nested Path. `%` must walk INTO that nested path to
1781        // find Product (its last step) as the target, exactly as if the
1782        // parens weren't there.
1783        let ast = crate::parser::Parser::new("Account.(Order.Product).%".to_string())
1784            .unwrap()
1785            .parse()
1786            .unwrap();
1787        let result = resolve_ancestry(ast).unwrap();
1788        match result {
1789            AstNode::Path { steps } => {
1790                assert_eq!(steps.len(), 3);
1791                assert!(steps[1].is_tuple, "the wrapping step must be flagged tuple");
1792                match &steps[1].node {
1793                    AstNode::FunctionApplication(inner) => match inner.as_ref() {
1794                        AstNode::Path { steps: inner_steps } => {
1795                            assert_eq!(inner_steps.len(), 2);
1796                            assert!(
1797                                matches!(inner_steps[0].node, AstNode::Name(ref n) if n == "Order")
1798                            );
1799                            assert!(
1800                                matches!(inner_steps[1].node, AstNode::Name(ref n) if n == "Product")
1801                            );
1802                            let product_label = inner_steps[1].ancestor_label.clone();
1803                            assert!(product_label.is_some(), "Product must be tagged");
1804                            match &steps[2].node {
1805                                AstNode::Parent(label) => {
1806                                    assert_eq!(Some(label.clone()), product_label)
1807                                }
1808                                other => panic!("expected Parent(label), got {:?}", other),
1809                            }
1810                        }
1811                        other => panic!("expected inner Path, got {:?}", other),
1812                    },
1813                    other => panic!("expected FunctionApplication, got {:?}", other),
1814                }
1815            }
1816            other => panic!("expected Path, got {:?}", other),
1817        }
1818    }
1819
1820    #[test]
1821    fn test_real_parser_percent_through_leading_paren_block() {
1822        // parent006.jsonata's shape: "(Account.Order).(Product).{...}" -- a
1823        // LEADING bare paren (not preceded by `.`) parses as a generic
1824        // `Block` (not FunctionApplication), then becomes the first step of
1825        // the outer path via the normal-dot fallback; `.(Product)` becomes a
1826        // second, `FunctionApplication`-wrapped step.
1827        //
1828        // Ground truth from jsonata-js (verified via live `.ast()` dump,
1829        // NOT hand-derived -- an earlier draft of this test wrongly assumed
1830        // % must walk past the `(Product)` step into the `(Account.Order)`
1831        // step to find Order; jsonata-js instead resolves it in ONE level,
1832        // same as the un-parenthesized `Account.Order.Product.%` case):
1833        // `%` (level 1) resolves entirely WITHIN the immediately preceding
1834        // step -- the `(Product)` FunctionApplication -- tagging Product
1835        // itself. The `(Account.Order)` block is never even visited.
1836        let ast =
1837            crate::parser::Parser::new("(Account.Order).(Product).{'x': %.OrderID}".to_string())
1838                .unwrap()
1839                .parse()
1840                .unwrap();
1841        let result = resolve_ancestry(ast).unwrap();
1842        match result {
1843            AstNode::Path { steps } => {
1844                assert_eq!(steps.len(), 3);
1845                // steps[0]: the untouched (Account.Order) block.
1846                match &steps[0].node {
1847                    AstNode::Block(exprs) => {
1848                        assert_eq!(exprs.len(), 1);
1849                        match &exprs[0] {
1850                            AstNode::Path { steps: inner } => {
1851                                assert_eq!(inner.len(), 2);
1852                                assert!(
1853                                    matches!(inner[0].node, AstNode::Name(ref n) if n == "Account")
1854                                );
1855                                assert!(
1856                                    matches!(inner[1].node, AstNode::Name(ref n) if n == "Order")
1857                                );
1858                                assert!(
1859                                    inner[1].ancestor_label.is_none(),
1860                                    "Order must NOT be tagged -- % resolves one level back, at Product"
1861                                );
1862                            }
1863                            other => panic!("expected inner Path, got {:?}", other),
1864                        }
1865                    }
1866                    other => panic!("expected Block, got {:?}", other),
1867                }
1868                assert!(!steps[0].is_tuple);
1869                // steps[1]: the (Product) FunctionApplication -- this is
1870                // where % actually resolves.
1871                assert!(steps[1].is_tuple, "the wrapping step must be flagged tuple");
1872                match &steps[1].node {
1873                    AstNode::FunctionApplication(inner) => match inner.as_ref() {
1874                        AstNode::Path { steps: inner_steps } => {
1875                            assert_eq!(inner_steps.len(), 1);
1876                            assert!(
1877                                matches!(inner_steps[0].node, AstNode::Name(ref n) if n == "Product")
1878                            );
1879                            let product_label = inner_steps[0].ancestor_label.clone();
1880                            assert!(product_label.is_some(), "Product must be tagged");
1881                            match &steps[2].node {
1882                                AstNode::Object(pairs) => match &pairs[0].1 {
1883                                    AstNode::Path { steps: value_steps } => {
1884                                        match &value_steps[0].node {
1885                                            AstNode::Parent(label) => {
1886                                                assert_eq!(Some(label.clone()), product_label)
1887                                            }
1888                                            other => {
1889                                                panic!("expected Parent(label), got {:?}", other)
1890                                            }
1891                                        }
1892                                    }
1893                                    other => panic!("expected value Path, got {:?}", other),
1894                                },
1895                                other => panic!("expected Object, got {:?}", other),
1896                            }
1897                        }
1898                        other => panic!("expected inner Path, got {:?}", other),
1899                    },
1900                    other => panic!("expected FunctionApplication, got {:?}", other),
1901                }
1902            }
1903            other => panic!("expected Path, got {:?}", other),
1904        }
1905    }
1906
1907    #[test]
1908    fn test_real_parser_percent_cannot_derive_ancestor_from_literal() {
1909        // A `%` immediately after a step that isn't name/wildcard/block/path
1910        // (here, a string literal step is folded to a Name by the parser's
1911        // own S0213-adjacent handling, so use a case that stays non-
1912        // resolvable: % with nothing at all before it in an enclosing path).
1913        let ast = crate::parser::Parser::new("%.OrderID".to_string())
1914            .unwrap()
1915            .parse()
1916            .unwrap();
1917        let err = resolve_ancestry(ast).unwrap_err();
1918        assert!(err.to_string().starts_with("S0217"));
1919    }
1920
1921    #[test]
1922    fn test_real_parser_lambda_body_percent_does_not_bubble_or_error() {
1923        // "function(){ % }" -- jsonata-js parses this successfully (the raw
1924        // `%` is left untouched inside the lambda body, only failing at
1925        // runtime when/if the lambda is invoked). Confirms Lambda bodies
1926        // don't bubble their pending to the enclosing scope.
1927        let ast = crate::parser::Parser::new("function(){ % }".to_string())
1928            .unwrap()
1929            .parse()
1930            .unwrap();
1931        let result = resolve_ancestry(ast).unwrap();
1932        match result {
1933            AstNode::Lambda { body, .. } => {
1934                assert!(matches!(*body, AstNode::Parent(_)));
1935            }
1936            other => panic!("expected Lambda, got {:?}", other),
1937        }
1938    }
1939
1940    // --- S0215/S0216: `@` (focus binding) rejects a step that already has
1941    // predicates or is a sort step. These checks were a pre-existing gap
1942    // (never implemented, not even by Task 3) that only surfaced once
1943    // ast_transform started running unconditionally via parser::parse():
1944    // previously an unresolved `@`/`#` reaching the evaluator always threw
1945    // (for the WRONG reason -- "must be resolved by ast_transform pass"),
1946    // and the reference-suite harness lenient-accepts any error without an
1947    // extractable code, masking the missing S0215/S0216 checks entirely.
1948    // Ground truth: tests/jsonata-js/test/test-suite/groups/joins/errors.json.
1949
1950    #[test]
1951    fn test_real_parser_focus_bind_after_predicate_is_s0215() {
1952        let ast = crate::parser::Parser::new("Account.Order[1]@$o.Product".to_string())
1953            .unwrap()
1954            .parse()
1955            .unwrap();
1956        let err = resolve_ancestry(ast).unwrap_err();
1957        assert!(err.to_string().starts_with("S0215"), "got: {}", err);
1958    }
1959
1960    #[test]
1961    fn test_real_parser_focus_bind_after_sort_is_s0216() {
1962        let ast = crate::parser::Parser::new(
1963            "Account.Order^(>OrderID)@$o.Product.{ 'name':`Product Name`, 'orderid':$o.OrderID }"
1964                .to_string(),
1965        )
1966        .unwrap()
1967        .parse()
1968        .unwrap();
1969        let err = resolve_ancestry(ast).unwrap_err();
1970        assert!(err.to_string().starts_with("S0216"), "got: {}", err);
1971    }
1972
1973    #[test]
1974    fn test_real_parser_index_bind_after_predicate_is_not_an_error() {
1975        // Unlike `@`, `#` has NO S0215-equivalent restriction in jsonata-js
1976        // -- it's allowed after predicates (it just appends an index stage
1977        // rather than setting a plain `index` field when stages already
1978        // exist). Confirms `check_focus_bind_target`'s marker-kind guard
1979        // correctly only fires for Focus, not Index.
1980        let ast = crate::parser::Parser::new("Account.Order[1]#$o.Product".to_string())
1981            .unwrap()
1982            .parse()
1983            .unwrap();
1984        assert!(resolve_ancestry(ast).is_ok());
1985    }
1986}