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.
This commit is contained in:
Will Lillis 2026-07-16 23:44:38 -04:00
parent 8fe4f218fb
commit dfcf73921c
3 changed files with 61 additions and 2 deletions

View file

@ -1297,6 +1297,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(|| {

View file

@ -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)`

View file

@ -4478,8 +4478,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;
}
}