From 290e3f0b8b8d61e07aa62912917258af2bc2df33 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Wed, 7 Jan 2026 18:42:25 -0300 Subject: [PATCH] fix: Bypass Up navigation when Down fails for childless nodes --- crates/plotnik-lib/src/compile/expressions.rs | 2 +- crates/plotnik-lib/src/compile/quantifier.rs | 21 ++++-- ...mit_tests__alternations_in_quantifier.snap | 31 ++++---- ...mit_tests__definitions_nested_capture.snap | 31 ++++---- ...mit__emit_tests__optional_first_child.snap | 17 ++--- ...__emit_tests__optional_null_injection.snap | 17 ++--- ..._tests__quantifiers_first_child_array.snap | 2 +- ...mit__emit_tests__quantifiers_optional.snap | 17 ++--- ...tests__quantifiers_optional_nongreedy.snap | 17 ++--- ..._tests__quantifiers_repeat_navigation.snap | 21 +++--- ..._emit_tests__quantifiers_struct_array.snap | 27 +++---- ...__emit_tests__sequences_in_quantifier.snap | 23 +++--- crates/plotnik-lib/src/engine/engine_tests.rs | 38 ++++++++++ ...on_childless_node_with_inner_optional.snap | 70 +++++++++++++++++++ ...ession_childless_node_with_inner_star.snap | 70 +++++++++++++++++++ 15 files changed, 302 insertions(+), 102 deletions(-) create mode 100644 crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__regression_childless_node_with_inner_optional.snap create mode 100644 crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__regression_childless_node_with_inner_star.snap diff --git a/crates/plotnik-lib/src/compile/expressions.rs b/crates/plotnik-lib/src/compile/expressions.rs index 563a39b..3e0d00f 100644 --- a/crates/plotnik-lib/src/compile/expressions.rs +++ b/crates/plotnik-lib/src/compile/expressions.rs @@ -104,7 +104,7 @@ impl Compiler<'_> { true, None, CaptureEffects::default(), - None, // No skip_exit bypass - all paths need Up + Some(final_exit), // Skip exit bypasses Up when Down fails (childless node) ); self.instructions diff --git a/crates/plotnik-lib/src/compile/quantifier.rs b/crates/plotnik-lib/src/compile/quantifier.rs index 6ef3de7..0389bf6 100644 --- a/crates/plotnik-lib/src/compile/quantifier.rs +++ b/crates/plotnik-lib/src/compile/quantifier.rs @@ -445,12 +445,23 @@ impl Compiler<'_> { self.emit_null_for_internal_captures(null_exit, inner) }; - // Skip-retry iteration leading to null exit + // Skip-retry iteration: + // For Optional, both skip paths (entry-level and retry-exhaust) need null. + // - Entry-level (Down fails): skip_with_null (bypass Up, has null from caller) + // - Retry exhaust (Down succeeded but pattern fails): need Up, then null + // When needs_split_exits, create a retry exhaust path with null → match_exit. let first_nav_mode = first_nav.unwrap_or(Nav::Down); + let iterate_exit = if needs_split_exits { + // Retry exhaust needs null injection then goes to match_exit (for Up) + let null_exit = self.emit_null_for_skip_path(match_exit, &element_capture); + self.emit_null_for_internal_captures(null_exit, inner) + } else { + skip_with_null + }; let iterate = self.compile_skip_retry_iteration( first_nav_mode, body_entry, - skip_with_null, + iterate_exit, is_greedy, ); @@ -554,10 +565,12 @@ impl Compiler<'_> { self.compile_expr_inner(inner, loop_entry, Some(Nav::StayExact), capture) }; - // First iteration: skip-retry with skip_exit as fallback + // First iteration: skip-retry with match_exit as internal fallback. + // When retry exhausts (Down succeeded but pattern fails), we need Up via match_exit. + // The entry-level epsilon (below) handles Down failure → skip_exit (bypass Up). let first_nav_mode = nav_override.unwrap_or(Nav::Down); let first_iterate = - self.compile_skip_retry_iteration(first_nav_mode, body_entry, skip_exit, is_greedy); + self.compile_skip_retry_iteration(first_nav_mode, body_entry, match_exit, is_greedy); // Repeat iteration: skip-retry with match_exit as fallback let repeat_iterate = diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__alternations_in_quantifier.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__alternations_in_quantifier.snap index b7da51b..0388a54 100644 --- a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__alternations_in_quantifier.snap +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__alternations_in_quantifier.snap @@ -53,22 +53,23 @@ Test: 06 ε 07 07 ! (object) 08 08 ε [Arr] 10 - 10 ε 37, 12 + 10 ε 39, 20 12 ε [EndArr Set(M5)] 14 14 △ _ 19 15 ε [EndObj Push] 17 - 17 ε 43, 12 + 17 ε 45, 12 19 ▶ - 20 ε [Set(M4)] 15 - 22 ! [Enum(M2)] (pair) [Node Set(M0) EndEnum] 20 - 25 ! [Enum(M3)] (shorthand_property_identifier) [Node Set(M1) EndEnum] 20 - 28 ε 22, 25 - 30 ε [Obj] 28 - 32 ▷ _ 35 - 33 ε 32, 12 - 35 ε 30, 33 - 37 ▽ _ 35 - 38 ▷ _ 41 - 39 ε 38, 12 - 41 ε 30, 39 - 43 ▷ _ 41 + 20 ε [EndArr Set(M5)] 19 + 22 ε [Set(M4)] 15 + 24 ! [Enum(M2)] (pair) [Node Set(M0) EndEnum] 22 + 27 ! [Enum(M3)] (shorthand_property_identifier) [Node Set(M1) EndEnum] 22 + 30 ε 24, 27 + 32 ε [Obj] 30 + 34 ▷ _ 37 + 35 ε 34, 12 + 37 ε 32, 35 + 39 ▽ _ 37 + 40 ▷ _ 43 + 41 ε 40, 12 + 43 ε 32, 41 + 45 ▷ _ 43 diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__definitions_nested_capture.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__definitions_nested_capture.snap index e65e3fb..fe8c55c 100644 --- a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__definitions_nested_capture.snap +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__definitions_nested_capture.snap @@ -56,22 +56,23 @@ Outer: 12 ε 13 13 ! (parent) 14 14 ε [Arr] 16 - 16 ε 40, 18 + 16 ε 42, 26 18 ε [EndArr Set(M2)] 20 20 △ _ 25 21 ε [EndObj Push] 23 - 23 ε 46, 18 + 23 ε 48, 18 25 ▶ - 26 ε [Set(M1)] 21 - 28 ε [EndObj] 26 - 30 ! (Inner) 06 : 28 - 31 ε [Obj] 30 - 33 ε [Obj] 31 - 35 ▷ _ 38 - 36 ε 35, 18 - 38 ε 33, 36 - 40 ▽ _ 38 - 41 ▷ _ 44 - 42 ε 41, 18 - 44 ε 33, 42 - 46 ▷ _ 44 + 26 ε [EndArr Set(M2)] 25 + 28 ε [Set(M1)] 21 + 30 ε [EndObj] 28 + 32 ! (Inner) 06 : 30 + 33 ε [Obj] 32 + 35 ε [Obj] 33 + 37 ▷ _ 40 + 38 ε 37, 18 + 40 ε 35, 38 + 42 ▽ _ 40 + 43 ▷ _ 46 + 44 ε 43, 18 + 46 ε 35, 44 + 48 ▷ _ 46 diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__optional_first_child.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__optional_first_child.snap index 76e026e..2109012 100644 --- a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__optional_first_child.snap +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__optional_first_child.snap @@ -44,14 +44,15 @@ _ObjWrap: Test: 06 ε 07 07 ! (program) 08 - 08 ε 24, 15 + 08 ε 26, 15 10 ▶ - 11 ▽ (number) [Node Set(M1)] 25 - 13 ▷ (number) [Node Set(M1)] 25 + 11 ▽ (number) [Node Set(M1)] 27 + 13 ▷ (number) [Node Set(M1)] 27 15 ε [Null Set(M0)] 11 17 ! (identifier) [Node Set(M0)] 13 - 19 ▷ _ 22 - 20 ε 19, 15 - 22 ε 17, 20 - 24 ▽ _ 22 - 25 △ _ 10 + 19 ε [Null Set(M0)] 13 + 21 ▷ _ 24 + 22 ε 21, 19 + 24 ε 17, 22 + 26 ▽ _ 24 + 27 △ _ 10 diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__optional_null_injection.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__optional_null_injection.snap index 153ef62..f58677d 100644 --- a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__optional_null_injection.snap +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__optional_null_injection.snap @@ -37,12 +37,13 @@ _ObjWrap: Test: 06 ε 07 07 ! (function_declaration) 08 - 08 ε 20, 13 + 08 ε 22, 11 10 ▶ - 11 ! (decorator) [Node Set(M0)] 21 - 13 ε [Null Set(M0)] 21 - 15 ▷ _ 18 - 16 ε 15, 13 - 18 ε 11, 16 - 20 ▽ _ 18 - 21 △ _ 10 + 11 ε [Null Set(M0)] 10 + 13 ! (decorator) [Node Set(M0)] 23 + 15 ε [Null Set(M0)] 23 + 17 ▷ _ 20 + 18 ε 17, 15 + 20 ε 13, 18 + 22 ▽ _ 20 + 23 △ _ 10 diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_first_child_array.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_first_child_array.snap index f737741..2cb2174 100644 --- a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_first_child_array.snap +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_first_child_array.snap @@ -54,7 +54,7 @@ Test: 21 ε [EndArr Set(M0)] 19 23 ε [EndArr Set(M0)] 17 25 ▷ _ 28 - 26 ε 25, 23 + 26 ε 25, 21 28 ε 12, 26 30 ▽ _ 28 31 ▷ _ 34 diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_optional.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_optional.snap index 153ef62..f58677d 100644 --- a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_optional.snap +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_optional.snap @@ -37,12 +37,13 @@ _ObjWrap: Test: 06 ε 07 07 ! (function_declaration) 08 - 08 ε 20, 13 + 08 ε 22, 11 10 ▶ - 11 ! (decorator) [Node Set(M0)] 21 - 13 ε [Null Set(M0)] 21 - 15 ▷ _ 18 - 16 ε 15, 13 - 18 ε 11, 16 - 20 ▽ _ 18 - 21 △ _ 10 + 11 ε [Null Set(M0)] 10 + 13 ! (decorator) [Node Set(M0)] 23 + 15 ε [Null Set(M0)] 23 + 17 ▷ _ 20 + 18 ε 17, 15 + 20 ε 13, 18 + 22 ▽ _ 20 + 23 △ _ 10 diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_optional_nongreedy.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_optional_nongreedy.snap index a01fa06..b6f7a94 100644 --- a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_optional_nongreedy.snap +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_optional_nongreedy.snap @@ -37,12 +37,13 @@ _ObjWrap: Test: 06 ε 07 07 ! (function_declaration) 08 - 08 ε 13, 20 + 08 ε 11, 22 10 ▶ - 11 ! (decorator) [Node Set(M0)] 21 - 13 ε [Null Set(M0)] 21 - 15 ▷ _ 18 - 16 ε 13, 15 - 18 ε 16, 11 - 20 ▽ _ 18 - 21 △ _ 10 + 11 ε [Null Set(M0)] 10 + 13 ! (decorator) [Node Set(M0)] 23 + 15 ε [Null Set(M0)] 23 + 17 ▷ _ 20 + 18 ε 15, 17 + 20 ε 18, 13 + 22 ▽ _ 20 + 23 △ _ 10 diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_repeat_navigation.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_repeat_navigation.snap index 194b307..9b524cb 100644 --- a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_repeat_navigation.snap +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_repeat_navigation.snap @@ -38,17 +38,18 @@ Test: 06 ε 07 07 ! (function_declaration) 08 08 ε [Arr] 10 - 10 ε 25, 12 + 10 ε 27, 20 12 ε [EndArr Set(M0)] 14 14 △ _ 19 15 ! (decorator) [Node Push] 17 - 17 ε 31, 12 + 17 ε 33, 12 19 ▶ - 20 ▷ _ 23 - 21 ε 20, 12 - 23 ε 15, 21 - 25 ▽ _ 23 - 26 ▷ _ 29 - 27 ε 26, 12 - 29 ε 15, 27 - 31 ▷ _ 29 + 20 ε [EndArr Set(M0)] 19 + 22 ▷ _ 25 + 23 ε 22, 12 + 25 ε 15, 23 + 27 ▽ _ 25 + 28 ▷ _ 31 + 29 ε 28, 12 + 31 ε 15, 29 + 33 ▷ _ 31 diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_struct_array.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_struct_array.snap index 5285a3f..6d3c4a2 100644 --- a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_struct_array.snap +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__quantifiers_struct_array.snap @@ -48,20 +48,21 @@ Test: 06 ε 07 07 ! (array) 08 08 ε [Arr] 10 - 10 ε 31, 12 + 10 ε 33, 20 12 ε [EndArr Set(M2)] 14 14 △ _ 19 15 ε [EndObj Push] 17 - 17 ε 37, 12 + 17 ε 39, 12 19 ▶ - 20 ▷ (number) [Node Set(M1)] 15 - 22 ! (identifier) [Node Set(M0)] 20 - 24 ε [Obj] 22 - 26 ▷ _ 29 - 27 ε 26, 12 - 29 ε 24, 27 - 31 ▽ _ 29 - 32 ▷ _ 35 - 33 ε 32, 12 - 35 ε 24, 33 - 37 ▷ _ 35 + 20 ε [EndArr Set(M2)] 19 + 22 ▷ (number) [Node Set(M1)] 15 + 24 ! (identifier) [Node Set(M0)] 22 + 26 ε [Obj] 24 + 28 ▷ _ 31 + 29 ε 28, 12 + 31 ε 26, 29 + 33 ▽ _ 31 + 34 ▷ _ 37 + 35 ε 34, 12 + 37 ε 26, 35 + 39 ▷ _ 37 diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__sequences_in_quantifier.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__sequences_in_quantifier.snap index 10c7bbf..1a0d770 100644 --- a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__sequences_in_quantifier.snap +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__emit_tests__sequences_in_quantifier.snap @@ -39,18 +39,19 @@ Test: 06 ε 07 07 ! (parent) 08 08 ε [Arr] 10 - 10 ε 26, 12 + 10 ε 28, 20 12 ε [EndArr Set(M0)] 14 14 △ _ 19 15 ▷ (b) [Node Push] 17 - 17 ε 32, 12 + 17 ε 34, 12 19 ▶ - 20 ! (a) 15 - 21 ▷ _ 24 - 22 ε 21, 12 - 24 ε 20, 22 - 26 ▽ _ 24 - 27 ▷ _ 30 - 28 ε 27, 12 - 30 ε 20, 28 - 32 ▷ _ 30 + 20 ε [EndArr Set(M0)] 19 + 22 ! (a) 15 + 23 ▷ _ 26 + 24 ε 23, 12 + 26 ε 22, 24 + 28 ▽ _ 26 + 29 ▷ _ 32 + 30 ε 29, 12 + 32 ε 22, 30 + 34 ▷ _ 32 diff --git a/crates/plotnik-lib/src/engine/engine_tests.rs b/crates/plotnik-lib/src/engine/engine_tests.rs index c344378..7e66901 100644 --- a/crates/plotnik-lib/src/engine/engine_tests.rs +++ b/crates/plotnik-lib/src/engine/engine_tests.rs @@ -634,3 +634,41 @@ fn regression_nested_quantifiers_struct_captures_mixed() { "class Empty { } class One { foo() {} } class Two { bar() {} baz() {} }" ); } + +/// BUG #9: Childless nodes with inner quantifiers caused outer quantifier to miss matches. +/// +/// For `(identifier (_)* @items)` inside a star quantifier, if `identifier` has zero +/// children at runtime (tree-sitter identifiers are terminal nodes with no children), +/// the Down navigation fails. The skip path should NOT execute Up since we never +/// descended, but currently it does, causing the cursor to ascend one level too high. +/// +/// This breaks the outer quantifier's iteration: after processing the first match, +/// the cursor is at the grandparent level instead of sibling level, so Next fails. +/// +/// Fix: When Down fails immediately (childless node), the skip path should bypass +/// the Up instruction. Only execute Up when we actually descended into children. +#[test] +fn regression_childless_node_with_inner_star() { + snap!( + indoc! {r#" + Q = (program {(expression_statement + (identifier (_)* @items) @id + ) @stmt}* @stmts) + "#}, + "foo; bar; baz" + ); +} + +/// Same bug with optional quantifier instead of star. +/// `(identifier (_)? @item)` should also handle childless nodes correctly. +#[test] +fn regression_childless_node_with_inner_optional() { + snap!( + indoc! {r#" + Q = (program {(expression_statement + (identifier (_)? @item) @id + ) @stmt}* @stmts) + "#}, + "foo; bar; baz" + ); +} diff --git a/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__regression_childless_node_with_inner_optional.snap b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__regression_childless_node_with_inner_optional.snap new file mode 100644 index 0000000..6b972d3 --- /dev/null +++ b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__regression_childless_node_with_inner_optional.snap @@ -0,0 +1,70 @@ +--- +source: crates/plotnik-lib/src/engine/engine_tests.rs +--- +Q = (program {(expression_statement + (identifier (_)? @item) @id +) @stmt}* @stmts) +--- +foo; bar; baz +--- +{ + "stmts": [ + { + "stmt": { + "kind": "expression_statement", + "text": "foo;", + "span": [ + 0, + 4 + ] + }, + "id": { + "kind": "identifier", + "text": "foo", + "span": [ + 0, + 3 + ] + }, + "item": null + }, + { + "stmt": { + "kind": "expression_statement", + "text": "bar;", + "span": [ + 5, + 9 + ] + }, + "id": { + "kind": "identifier", + "text": "bar", + "span": [ + 5, + 8 + ] + }, + "item": null + }, + { + "stmt": { + "kind": "expression_statement", + "text": "baz", + "span": [ + 10, + 13 + ] + }, + "id": { + "kind": "identifier", + "text": "baz", + "span": [ + 10, + 13 + ] + }, + "item": null + } + ] +} diff --git a/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__regression_childless_node_with_inner_star.snap b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__regression_childless_node_with_inner_star.snap new file mode 100644 index 0000000..59f8b17 --- /dev/null +++ b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__regression_childless_node_with_inner_star.snap @@ -0,0 +1,70 @@ +--- +source: crates/plotnik-lib/src/engine/engine_tests.rs +--- +Q = (program {(expression_statement + (identifier (_)* @items) @id +) @stmt}* @stmts) +--- +foo; bar; baz +--- +{ + "stmts": [ + { + "stmt": { + "kind": "expression_statement", + "text": "foo;", + "span": [ + 0, + 4 + ] + }, + "id": { + "kind": "identifier", + "text": "foo", + "span": [ + 0, + 3 + ] + }, + "items": [] + }, + { + "stmt": { + "kind": "expression_statement", + "text": "bar;", + "span": [ + 5, + 9 + ] + }, + "id": { + "kind": "identifier", + "text": "bar", + "span": [ + 5, + 8 + ] + }, + "items": [] + }, + { + "stmt": { + "kind": "expression_statement", + "text": "baz", + "span": [ + 10, + 13 + ] + }, + "id": { + "kind": "identifier", + "text": "baz", + "span": [ + 10, + 13 + ] + }, + "items": [] + } + ] +}