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}