From a8486ca23915be41174dfd22b0d251d324483daa Mon Sep 17 00:00:00 2001 From: Will Lillis Date: Mon, 29 Jun 2026 01:49:15 -0400 Subject: [PATCH] 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. --- crates/cli/src/tests/query_test.rs | 39 ++++++++++++++++++++++++++++++ lib/src/query.c | 11 +++++++++ 2 files changed, 50 insertions(+) diff --git a/crates/cli/src/tests/query_test.rs b/crates/cli/src/tests/query_test.rs index 1baf1e471..abba409da 100644 --- a/crates/cli/src/tests/query_test.rs +++ b/crates/cli/src/tests/query_test.rs @@ -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(|| { diff --git a/lib/src/query.c b/lib/src/query.c index 23db6ca64..c47101dd9 100644 --- a/lib/src/query.c +++ b/lib/src/query.c @@ -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(