fix(query): transfer a leading boundary anchor across a zero-matched quantifier

`(P . Q* Y)` with zero `Q` dropped the leading `.`, so `Y` matched at any
position instead of being pinned to the parent's first named child. On a `?`/`*`
zero-skip, when the skipped step is the parent's first child and carries a
leading anchor, transfer that first-child requirement to the skip target.
This commit is contained in:
Will Lillis 2026-07-17 00:42:35 -04:00
parent dfcf73921c
commit 1ffd612be5
2 changed files with 66 additions and 7 deletions

View file

@ -1337,6 +1337,52 @@ function foo() {}
});
}
#[test]
fn test_query_matches_with_leading_anchor_before_zero_quantifier() {
allocations::record(|| {
let language = get_language("c");
let query = Query::new(
&language,
"(translation_unit . (comment)* (function_definition) @f)",
)
.unwrap();
// The leading `.` anchors the comment run to the parent's first child. When the
// run matches zero comments, that first-child requirement transfers to the
// function, so it matches only when it is itself the first child.
assert_query_matches(
&language,
&query,
"
int main() {}
",
&[(0, vec![("f", "int main() {}")])],
);
// The function is the second child, so with no leading comments it must not match.
assert_query_matches(
&language,
&query,
"
int a;
int main() {}
",
&[],
);
// With a leading comment the run starts at the first child and the function follows.
assert_query_matches(
&language,
&query,
"
// c
int main() {}
",
&[(0, vec![("f", "int main() {}")])],
);
});
}
#[test]
fn test_query_matches_with_last_named_child() {
allocations::record(|| {

View file

@ -4478,13 +4478,26 @@ 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,
// 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;
// nothing. How an adjacent anchor behaves then depends on where it sat:
if (child_step->alternative_is_skip) {
if (!child_step->is_immediate) {
// No leading anchor on the skipped step, so an immediately-following
// anchor on the skip target is vacuous (`Q* . B` with zero `Q` lets
// `B` match anywhere).
copy->skipped_quantifier = true;
} else if (
array_get(&self->query->steps, child_state->step_index - 1)->depth <
child_step->depth
) {
// The skipped step was the parent's first child pattern and carried a
// leading *boundary* anchor (`(P . Q* Y)`). Transfer the first-child
// requirement to the skip target so it survives the empty run: `Y`
// must still be the parent's first named child.
copy->seeking_immediate_match = true;
}
// Otherwise the skipped step carried a leading *between* anchor
// (`A . Q* ...`): with zero `Q` that adjacency vanishes, while the skip
// target's own anchor, if any, still applies (`A . Q* . B` stays adjacent).
}
}
}