fix(query): apply a trailing anchor when its optional node is skipped

A trailing `.` after an optional node, e.g. `(p (a)+ @a . (b)? @b .)`, sets
`is_last_child` on the `(b)?` step. When `(b)?` matched zero, the zero-skip
jumped past that step to completion, so the last-child requirement was never
enforced.

When a zero-skip would bypass a step carrying `is_last_child`, require that
the last matched node really is the last named child. Gated on the
`alternative_is_skip` edge marker.
This commit is contained in:
Will Lillis 2026-06-29 01:49:15 -04:00
parent 7e7f2584d1
commit a8486ca239
No known key found for this signature in database
2 changed files with 50 additions and 0 deletions

View file

@ -1258,6 +1258,45 @@ function foo() {}
});
}
#[test]
fn test_query_matches_with_last_child_anchor_after_optional() {
allocations::record(|| {
let language = get_language("c");
let query = Query::new(
&language,
"(preproc_if (preproc_def)+ @def . (preproc_else)? @else .)",
)
.unwrap();
// The optional `(preproc_else)?` is absent, so the trailing anchor's
// last-child requirement transfers to the last `preproc_def`. A trailing
// comment means the def is not the last child, so nothing matches.
assert_query_matches(
&language,
&query,
"
#if X
#define A
// c
#endif
",
&[],
);
// With the def as the last child, the (else-less) match is allowed.
assert_query_matches(
&language,
&query,
"
#if X
#define A
#endif
",
&[(0, vec![("def", "#define A\n")])],
);
});
}
#[test]
fn test_query_matches_with_last_named_child() {
allocations::record(|| {

View file

@ -4450,6 +4450,17 @@ static inline bool ts_query_cursor__advance(
k--;
}
// A `?`/`*` zero-skip past a step that carries a trailing last-child
// anchor transfers that requirement to the last matched node. The
// skip is only valid if that node really is the last named child.
if (
child_step->alternative_is_skip &&
child_step->is_last_child &&
has_later_named_siblings
) {
continue;
}
QueryState *copy = ts_query_cursor__copy_state(self, &child_state);
if (copy) {
LOG(