From fcb38e9dcf1ad024eb84dbc75281f99b8091c6d1 Mon Sep 17 00:00:00 2001 From: Will Lillis Date: Thu, 16 Jul 2026 23:44:38 -0400 Subject: [PATCH] fix(query): keep the trailing anchor when a zero-matched quantifier is anchored on both sides In `A . Q* . B`, a zero-matched `Q` made both anchors vacuous, so `A` and `B` were no longer required to be immediate siblings. Only relax the following anchor on a `?`/`*` zero-skip when the skipped step has no leading anchor of its own; otherwise the adjacency transfers through the empty run. (cherry picked from commit dfcf73921c0618f40f4b2670acd931cf6df1f0e5) --- crates/cli/src/tests/query_test.rs | 40 +++++++++++++++++++ docs/src/using-parsers/queries/2-operators.md | 15 +++++++ lib/src/query.c | 8 +++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/tests/query_test.rs b/crates/cli/src/tests/query_test.rs index dbdeaf100..8f457decc 100644 --- a/crates/cli/src/tests/query_test.rs +++ b/crates/cli/src/tests/query_test.rs @@ -1296,6 +1296,46 @@ fn test_query_matches_with_last_child_anchor_after_optional() { }); } +#[test] +fn test_query_matches_with_anchors_on_both_sides_of_zero_quantifier() { + allocations::record(|| { + let language = get_language("javascript"); + let query = Query::new( + &language, + "(program (lexical_declaration) @a . (comment)* . (function_declaration) @b)", + ) + .unwrap(); + + // Anchors on both sides of a zero-matched quantifier collapse into a single + // adjacency constraint: with no comments, the declaration must be immediately + // followed by the function. + assert_query_matches( + &language, + &query, + " +const a = 1; +const b = 2; +function foo() {} +", + &[(0, vec![("a", "const b = 2;"), ("b", "function foo() {}")])], + ); + + // With a comment present the quantifier is non-zero, so the anchors apply + // normally: the comment must sit immediately between the declaration and the + // function. + assert_query_matches( + &language, + &query, + " +const b = 2; +// c +function foo() {} +", + &[(0, vec![("a", "const b = 2;"), ("b", "function foo() {}")])], + ); + }); +} + #[test] fn test_query_matches_with_last_named_child() { allocations::record(|| { diff --git a/docs/src/using-parsers/queries/2-operators.md b/docs/src/using-parsers/queries/2-operators.md index 23ec3652f..cc5eb1966 100644 --- a/docs/src/using-parsers/queries/2-operators.md +++ b/docs/src/using-parsers/queries/2-operators.md @@ -239,6 +239,21 @@ nearest node that the pattern _does_ match. For example, given the trailing anchor requires the last matched node to be the parent's last named child: when a `preproc_else` is present it must be last. When it is absent, the last `preproc_def` must be last. +Similarly, if an optionally quantified node is anchored between two siblings and matches zero nodes, +both sibling anchors collapse into one, constraining the outer nodes together. For example, given + +```query +(translation_unit + (declaration) @a + . + (comment)* + . + (function_definition) @b) +``` + +If there are no comments, `(declaration)` and `(function_definition)` must be immediate siblings +in order for the query to match. + An anchor may not appear at the first or last position inside a group `(...)` or an alternation `[...]`. A group or alternation is not a node, so it has no first or last child to anchor against, and there is no sibling on that side to anchor to. For example, write `(comment)* @doc . (function)` diff --git a/lib/src/query.c b/lib/src/query.c index 4fc54f440..ecb3a0803 100644 --- a/lib/src/query.c +++ b/lib/src/query.c @@ -4445,8 +4445,12 @@ static inline bool ts_query_cursor__advance( copy->seeking_immediate_match = true; } // Taking a `?`/`*` zero-skip means the quantified subpattern matched - // nothing, so an immediately-following anchor is vacuous for this copy. - if (child_step->alternative_is_skip) { + // nothing, so an immediately-following anchor is vacuous for this copy, + // UNLESS the skipped step carried a leading anchor of its own. In that + // case the adjacency transfers through the empty run to the step we skip + // to, so `A . Q* . B` with zero `Q` still requires `A` and `B` to be + // immediate siblings. + if (child_step->alternative_is_skip && !child_step->is_immediate) { copy->skipped_quantifier = true; } }