From 429aafd030818f5bd601af22863cd95208be2308 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Mon, 5 Jan 2026 14:54:46 -0300 Subject: [PATCH] fix: trailing anchor constraints trigger backtracking --- crates/plotnik-lib/src/compile/expressions.rs | 93 +++++++++++++++++++ ...it__codegen_tests__anchors_last_child.snap | 9 +- crates/plotnik-lib/src/engine/engine_tests.rs | 38 ++++++++ ...gine__engine_tests__anchor_last_child.snap | 17 ++++ ...e_tests__anchor_last_child_multi_item.snap | 25 +++++ ...ngine_tests__anchor_last_child_single.snap | 17 ++++ ..._tests__anchor_last_child_with_trivia.snap | 17 ++++ 7 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__anchor_last_child.snap create mode 100644 crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__anchor_last_child_multi_item.snap create mode 100644 crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__anchor_last_child_single.snap create mode 100644 crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__anchor_last_child_with_trivia.snap diff --git a/crates/plotnik-lib/src/compile/expressions.rs b/crates/plotnik-lib/src/compile/expressions.rs index 4c0317d0..b96c0cc6 100644 --- a/crates/plotnik-lib/src/compile/expressions.rs +++ b/crates/plotnik-lib/src/compile/expressions.rs @@ -66,6 +66,15 @@ impl Compiler<'_> { Nav::Up(1) }; + // Trailing anchor requires skip-retry pattern for backtracking. + // When the anchor check fails (matched node is not last), we need to + // retry with the next sibling until we find one that IS last. + if has_trailing_anchor { + return self.compile_named_node_with_trailing_anchor( + entry, exit, nav, node_type, neg_fields, &items, up_nav, capture, + ); + } + // Check if first item is skippable - its skip path should bypass the Up. // When a zero-match quantifier (? or *) is the first child with Down navigation, // the skip path never descends, so executing Up would ascend too far. @@ -113,6 +122,90 @@ impl Compiler<'_> { entry } + /// Compile a named node with trailing anchor using skip-retry pattern. + /// + /// Structure: + /// ```text + /// entry: Match(nav, node_type) → down_wildcard + /// down_wildcard: Match(Down, wildcard) → try + /// try: epsilon → [body, retry_nav] + /// body: items (StayExact) → up_check + /// up_check: Match(up_nav, None) → exit + /// retry_nav: Match(Next, wildcard) → try + /// ``` + /// + /// When items match but the trailing anchor check fails, we backtrack to `try`, + /// which falls through to `retry_nav`, advances to next sibling, and retries. + /// Only when siblings are exhausted does backtracking propagate to the caller. + #[allow(clippy::too_many_arguments)] + fn compile_named_node_with_trailing_anchor( + &mut self, + entry: Label, + exit: Label, + nav: Nav, + node_type: Option, + neg_fields: Vec, + items: &[ast::SeqItem], + up_nav: Nav, + capture: CaptureEffects, + ) -> Label { + // up_check: Match(up_nav) → exit + let up_check = self.fresh_label(); + self.instructions.push(Instruction::Match(MatchIR { + label: up_check, + nav: up_nav, + node_type: None, + node_field: None, + pre_effects: vec![], + neg_fields: vec![], + post_effects: vec![], + successors: vec![exit], + })); + + // body: items with StayExact navigation → up_check + // Items are compiled with StayExact because the skip-retry loop handles + // advancement; the body should match at the current position only. + let body = self.compile_seq_items_inner( + items, + up_check, + true, + Some(Nav::StayExact), // First item uses StayExact (we're already at position) + CaptureEffects::default(), + None, + ); + + // Build skip-retry structure: + // try: epsilon → [body, retry_nav] + // retry_nav: Match(Next, wildcard) → try + let try_label = self.fresh_label(); + let retry_nav = self.fresh_label(); + + // retry_nav: advance to next sibling and loop back + self.emit_wildcard_nav(retry_nav, Nav::Next, try_label); + + // try: branch to body (prefer) or retry (fallback) + // Greedy: try body first, then retry on failure + self.emit_branch_epsilon_at(try_label, body, retry_nav, true); + + // down_wildcard: navigate to first child → try + let down_wildcard = self.fresh_label(); + self.emit_wildcard_nav(down_wildcard, Nav::Down, try_label); + + // entry: match parent node → down_wildcard + self.instructions.push(Instruction::Match(MatchIR { + label: entry, + nav, + node_type, + node_field: None, + pre_effects: vec![], + neg_fields, + post_effects: capture.post, + successors: vec![down_wildcard], + })); + + entry + } + /// Compile an anonymous node with capture effects. pub(super) fn compile_anonymous_node_inner( &mut self, diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__anchors_last_child.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__anchors_last_child.snap index 124f0f06..90643aa1 100644 --- a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__anchors_last_child.snap +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__anchors_last_child.snap @@ -33,6 +33,9 @@ _ObjWrap: Test: 06 ε 07 07 ! (parent) 08 - 08 ▽ (last) 09 - 09 !△ 10 - 10 ▶ + 08 ▽ 13 + 09 ▶ + 10 !△ 09 + 11 ! (last) 10 + 12 ▷ 13 + 13 ε 11, 12 diff --git a/crates/plotnik-lib/src/engine/engine_tests.rs b/crates/plotnik-lib/src/engine/engine_tests.rs index 263cbc20..844258f3 100644 --- a/crates/plotnik-lib/src/engine/engine_tests.rs +++ b/crates/plotnik-lib/src/engine/engine_tests.rs @@ -312,6 +312,44 @@ fn anchor_adjacency() { ); } +/// Trailing anchor requires backtracking when first match isn't last. +/// The skip-retry pattern retries with next sibling until finding the last one. +#[test] +fn anchor_last_child() { + snap!( + "Q = (program (function_declaration) @last .)", + "function first() {} function second() {}" + ); +} + +/// Trailing anchor with only one sibling - trivial case. +#[test] +fn anchor_last_child_single() { + snap!( + "Q = (program (function_declaration) @only .)", + "function only() {}" + ); +} + +/// Trailing anchor skips trivia (comments) when checking last position. +#[test] +fn anchor_last_child_with_trivia() { + snap!( + "Q = (program (function_declaration) @last .)", + "function first() {} function second() {} /* trailing comment */" + ); +} + +/// Multi-item sequence with trailing anchor. +/// Should find (b, c) not (a, b) because c must be last. +#[test] +fn anchor_last_child_multi_item() { + snap!( + "Q = (program {(function_declaration) @a (function_declaration) @b .})", + "function a() {} function b() {} function c() {}" + ); +} + // ============================================================================ // 6. FIELDS // ============================================================================ diff --git a/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__anchor_last_child.snap b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__anchor_last_child.snap new file mode 100644 index 00000000..3ff82c24 --- /dev/null +++ b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__anchor_last_child.snap @@ -0,0 +1,17 @@ +--- +source: crates/plotnik-lib/src/engine/engine_tests.rs +--- +Q = (program (function_declaration) @last .) +--- +function first() {} function second() {} +--- +{ + "last": { + "kind": "function_declaration", + "text": "function second() {}", + "span": [ + 20, + 40 + ] + } +} diff --git a/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__anchor_last_child_multi_item.snap b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__anchor_last_child_multi_item.snap new file mode 100644 index 00000000..898d1657 --- /dev/null +++ b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__anchor_last_child_multi_item.snap @@ -0,0 +1,25 @@ +--- +source: crates/plotnik-lib/src/engine/engine_tests.rs +--- +Q = (program {(function_declaration) @a (function_declaration) @b .}) +--- +function a() {} function b() {} function c() {} +--- +{ + "a": { + "kind": "function_declaration", + "text": "function b() {}", + "span": [ + 16, + 31 + ] + }, + "b": { + "kind": "function_declaration", + "text": "function c() {}", + "span": [ + 32, + 47 + ] + } +} diff --git a/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__anchor_last_child_single.snap b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__anchor_last_child_single.snap new file mode 100644 index 00000000..b9cfcfd9 --- /dev/null +++ b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__anchor_last_child_single.snap @@ -0,0 +1,17 @@ +--- +source: crates/plotnik-lib/src/engine/engine_tests.rs +--- +Q = (program (function_declaration) @only .) +--- +function only() {} +--- +{ + "only": { + "kind": "function_declaration", + "text": "function only() {}", + "span": [ + 0, + 18 + ] + } +} diff --git a/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__anchor_last_child_with_trivia.snap b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__anchor_last_child_with_trivia.snap new file mode 100644 index 00000000..d32fcb37 --- /dev/null +++ b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__anchor_last_child_with_trivia.snap @@ -0,0 +1,17 @@ +--- +source: crates/plotnik-lib/src/engine/engine_tests.rs +--- +Q = (program (function_declaration) @last .) +--- +function first() {} function second() {} /* trailing comment */ +--- +{ + "last": { + "kind": "function_declaration", + "text": "function second() {} /* trailing comment */", + "span": [ + 20, + 63 + ] + } +}