lanekeep_query/capture_sites.rs
1//! Where each capture in a query's *text* is bound: the pattern it decorates and the field
2//! slot that pattern fills in its parent.
3//!
4//! Lexical on purpose. Neither this crate's [`CompiledQuery`](crate::CompiledQuery) nor
5//! tree-sitter's own `Query` can say which node a capture binds — the API exposes capture
6//! *names*, pattern byte ranges and predicates, and nothing about the pattern under a
7//! capture — so the text is the only place the answer exists. That keeps this usable with no
8//! grammar in hand, which is what `lanekeep-config` needs: it validates a rule's `flow`
9//! queries at load, before any language has been chosen to compile them against.
10
11/// One capture bound in a query's text, and the slot it is bound in.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct CaptureSite {
14 /// The capture's name, without its `@`.
15 pub name: String,
16 /// The field label on the pattern this capture decorates — `function` for the `@s` in
17 /// `(call_expression function: (identifier) @s)` — or `None` when that pattern is an
18 /// unlabeled child or a top-level pattern.
19 ///
20 /// Only the decorated pattern's own slot: a capture nested inside a labeled child reports
21 /// the slot *its* pattern fills, not its ancestor's, so the `@x` in `(a b: (c (d) @x))`
22 /// has no field.
23 pub field: Option<String>,
24}
25
26/// Every capture bound in `query`, in text order, each with the field slot of the pattern it
27/// decorates.
28///
29/// A capture named inside a predicate — the `@x` of `(#eq? @x "y")` — is a reference, not a
30/// binding, and is not listed. Quantifiers between a pattern and its captures are transparent
31/// (`(c)* @x` binds `@x` to `(c)`); an alternation, an anonymous `"token"` and a wildcard `_`
32/// are patterns like any other; a `!field` negation and a `.` anchor label nothing.
33///
34/// Total over any text: a malformed query yields whatever sites its text does bind, nesting
35/// past a fixed depth (`MAX_DEPTH`, 512 levels) stops being attributed rather than
36/// overflowing the stack, and
37/// [`CompiledQuery::compile`](crate::CompiledQuery::compile) is where the syntax error is
38/// reported. This function's only obligation on the way there is to neither lose nor invent a
39/// site.
40#[must_use]
41pub fn capture_sites(query: &str) -> Vec<CaptureSite> {
42 let mut scanner = Scanner {
43 text: query.as_bytes(),
44 pos: 0,
45 sites: Vec::new(),
46 };
47 scanner.sequence(None, None, 0);
48 scanner.sites
49}
50
51/// A cursor over the query's bytes. Byte-wise rather than char-wise so an unknown byte can be
52/// stepped over without ever slicing inside a multi-byte character: the only slices taken are
53/// identifiers, which are ASCII.
54struct Scanner<'a> {
55 text: &'a [u8],
56 pos: usize,
57 sites: Vec<CaptureSite>,
58}
59
60/// A byte that may appear in a node kind, a field name, a supertype path or a capture name.
61///
62/// tree-sitter's own identifier scanner admits alphanumerics, `_`, `-` and `.`, classifying
63/// whole characters; this one reads bytes, so every non-ASCII byte is admitted too and a name
64/// like `@sinké` is read whole rather than cut at its first multi-byte character — cut, it
65/// would read as `sink` and refuse a legitimate query. `/` is added for a supertype path,
66/// which the real scanner handles outside the identifier. `.` is also the anchor token, which
67/// is why [`Scanner::sequence`] checks for an anchor before it reads an identifier. `?` and
68/// `!` are not identifier characters there either, and keeping them out here is what lets
69/// `@x?` bind `x` and then read `?` as the quantifier it was meant as.
70const fn is_ident(byte: u8) -> bool {
71 byte.is_ascii_alphanumeric() || byte >= 0x80 || matches!(byte, b'_' | b'-' | b'.' | b'/')
72}
73
74/// Nesting past this depth is no longer attributed: an opener is stepped over like any other
75/// byte, so pathological text degrades to unattributed sites rather than to a stack overflow.
76/// No query a compiler accepts comes anywhere near it.
77const MAX_DEPTH: usize = 512;
78
79impl Scanner<'_> {
80 fn peek(&self) -> Option<u8> {
81 self.text.get(self.pos).copied()
82 }
83
84 /// Parse items until `close` — or the end of the text when `None` — consuming the closer.
85 ///
86 /// A `field:` label is held until the next pattern at this level and handed to it; a
87 /// pattern is a `(…)` group, a `[…]` alternation, a `"token"` or a bare `_`, and each
88 /// takes its trailing quantifiers and captures on its way out. `inherited` is the slot an
89 /// enclosing alternation fills: tree-sitter writes a field onto every branch of `[…]`, so
90 /// each pattern directly inside one is bound in that slot unless a label of its own says
91 /// otherwise. `depth` is how many openers are on the stack, against [`MAX_DEPTH`].
92 fn sequence(&mut self, close: Option<u8>, inherited: Option<&str>, depth: usize) {
93 let mut pending_field: Option<String> = None;
94 loop {
95 self.skip_trivia();
96 let Some(byte) = self.peek() else {
97 return;
98 };
99 match byte {
100 b')' | b']' => {
101 self.pos += 1;
102 // A closer this level did not open is a malformed query. Step over it
103 // and keep scanning; the compiler names the fault.
104 if Some(byte) == close {
105 return;
106 }
107 }
108 b'(' | b'[' if depth >= MAX_DEPTH => self.pos += 1,
109 b'(' => {
110 self.pos += 1;
111 self.skip_trivia();
112 // `(#…)`, and the legacy `(.…)` spelling, are predicates: the captures
113 // they name are references, not bindings.
114 if matches!(self.peek(), Some(b'#' | b'.')) {
115 self.skip_predicate();
116 continue;
117 }
118 // A node pattern `(kind …)`, a wildcard `(_ …)`, or a bare grouping
119 // `((a) (b))`. The kind, when present, does not decide where a capture
120 // binds, so it is read and dropped.
121 self.ident();
122 self.sequence(Some(b')'), None, depth + 1);
123 let field = Self::slot(&mut pending_field, inherited);
124 self.trailing(field.as_deref());
125 }
126 b'[' => {
127 self.pos += 1;
128 let field = Self::slot(&mut pending_field, inherited);
129 self.sequence(Some(b']'), field.as_deref(), depth + 1);
130 self.trailing(field.as_deref());
131 }
132 b'"' => {
133 self.skip_string();
134 let field = Self::slot(&mut pending_field, inherited);
135 self.trailing(field.as_deref());
136 }
137 // An anchor, or a quantifier with no pattern to attach to. The anchor is
138 // checked before the identifier arm because `.` is an identifier byte too.
139 b'.' | b'*' | b'+' | b'?' => self.pos += 1,
140 b'!' => {
141 self.pos += 1;
142 self.ident();
143 pending_field = None;
144 }
145 // A capture with no pattern in front of it binds nothing this scanner can
146 // name; recorded without a slot rather than dropped, so a count of sites
147 // still matches a count of `@`s.
148 b'@' => {
149 self.pos += 1;
150 let name = self.ident();
151 self.push(name, None);
152 }
153 _ if is_ident(byte) => {
154 let ident = self.ident();
155 if self.peek() == Some(b':') {
156 self.pos += 1;
157 pending_field = Some(ident);
158 } else {
159 // A bare `_` wildcard — or, in malformed text, a bare word — stands
160 // as a pattern of its own.
161 let field = Self::slot(&mut pending_field, inherited);
162 self.trailing(field.as_deref());
163 }
164 }
165 _ => self.pos += 1,
166 }
167 }
168 }
169
170 /// The slot the pattern that just closed fills: its own pending label when one was
171 /// written, else the slot of the alternation it is a branch of.
172 fn slot(pending: &mut Option<String>, inherited: Option<&str>) -> Option<String> {
173 pending.take().or_else(|| inherited.map(str::to_owned))
174 }
175
176 /// Consume the quantifiers and captures that follow a pattern, binding each capture to
177 /// `field` — the slot the pattern just closed fills.
178 fn trailing(&mut self, field: Option<&str>) {
179 loop {
180 self.skip_trivia();
181 match self.peek() {
182 Some(b'@') => {
183 self.pos += 1;
184 let name = self.ident();
185 self.push(name, field.map(str::to_owned));
186 }
187 Some(b'*' | b'+' | b'?') => self.pos += 1,
188 _ => return,
189 }
190 }
191 }
192
193 fn push(&mut self, name: String, field: Option<String>) {
194 if !name.is_empty() {
195 self.sites.push(CaptureSite { name, field });
196 }
197 }
198
199 /// Read an identifier at the cursor; empty when there is none.
200 fn ident(&mut self) -> String {
201 let start = self.pos;
202 while self.peek().is_some_and(is_ident) {
203 self.pos += 1;
204 }
205 String::from_utf8_lossy(&self.text[start..self.pos]).into_owned()
206 }
207
208 /// Skip whitespace and `;` comments, which run to the end of their line.
209 fn skip_trivia(&mut self) {
210 while let Some(byte) = self.peek() {
211 match byte {
212 b';' => {
213 while self.peek().is_some_and(|b| b != b'\n') {
214 self.pos += 1;
215 }
216 }
217 _ if byte.is_ascii_whitespace() => self.pos += 1,
218 _ => return,
219 }
220 }
221 }
222
223 /// Skip a `"…"` literal at the cursor, honoring `\"` escapes.
224 fn skip_string(&mut self) {
225 self.pos += 1;
226 while let Some(byte) = self.peek() {
227 self.pos += 1;
228 match byte {
229 b'\\' => self.pos += 1,
230 b'"' => return,
231 _ => {}
232 }
233 }
234 }
235
236 /// Skip a predicate whose `(` is already consumed and whose `#` is at the cursor, up to
237 /// and including its closing `)`. Parentheses inside its string arguments do not count.
238 fn skip_predicate(&mut self) {
239 let mut depth = 1_usize;
240 while let Some(byte) = self.peek() {
241 match byte {
242 b'"' => self.skip_string(),
243 b'(' => {
244 depth += 1;
245 self.pos += 1;
246 }
247 b')' => {
248 depth -= 1;
249 self.pos += 1;
250 if depth == 0 {
251 return;
252 }
253 }
254 _ => self.pos += 1,
255 }
256 }
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::{CaptureSite, capture_sites};
263
264 fn sites(query: &str) -> Vec<(String, Option<String>)> {
265 capture_sites(query)
266 .into_iter()
267 .map(|CaptureSite { name, field }| (name, field))
268 .collect()
269 }
270
271 fn site(name: &str, field: Option<&str>) -> (String, Option<String>) {
272 (name.to_owned(), field.map(str::to_owned))
273 }
274
275 #[test]
276 fn a_capture_on_a_labeled_child_records_the_field() {
277 assert_eq!(
278 sites("(call_expression function: (identifier) @s)"),
279 vec![site("s", Some("function"))]
280 );
281 }
282
283 #[test]
284 fn a_capture_on_an_unlabeled_child_has_no_field() {
285 assert_eq!(
286 sites("(call_expression (arguments) @a)"),
287 vec![site("a", None)]
288 );
289 }
290
291 #[test]
292 fn a_capture_on_the_whole_pattern_has_no_field() {
293 assert_eq!(
294 sites("(call_expression function: (identifier) @fn) @call"),
295 vec![site("fn", Some("function")), site("call", None)]
296 );
297 }
298
299 #[test]
300 fn two_captures_on_one_pattern_share_its_slot() {
301 assert_eq!(
302 sites("(a b: (c) @x @y)"),
303 vec![site("x", Some("b")), site("y", Some("b"))]
304 );
305 }
306
307 #[test]
308 fn the_field_reaches_only_the_pattern_that_fills_it() {
309 assert_eq!(sites("(a b: (c) (d) @x)"), vec![site("x", None)]);
310 }
311
312 #[test]
313 fn a_capture_nested_inside_a_labeled_child_reports_its_own_slot() {
314 assert_eq!(sites("(a b: (c (d) @x))"), vec![site("x", None)]);
315 }
316
317 #[test]
318 fn an_alternation_in_a_slot_carries_the_field() {
319 assert_eq!(sites("(a b: [(c) (d)] @x)"), vec![site("x", Some("b"))]);
320 }
321
322 /// tree-sitter writes a field onto every branch of an alternation, so a capture on a
323 /// branch is bound in the slot — `function: [(identifier) @s (member_expression) @s]` is
324 /// the callee-slot shape twice over.
325 #[test]
326 fn an_alternation_in_a_slot_labels_each_branch() {
327 assert_eq!(
328 sites("(a b: [(c) @x (d) @y] @z)"),
329 vec![
330 site("x", Some("b")),
331 site("y", Some("b")),
332 site("z", Some("b"))
333 ]
334 );
335 assert_eq!(sites("(a b: [[(c) @x]])"), vec![site("x", Some("b"))]);
336 // A capture nested inside a branch's own children is that child's, not the slot's.
337 assert_eq!(sites("(a b: [(c (e) @x)])"), vec![site("x", None)]);
338 }
339
340 /// The legacy `(.eq? …)` spelling is a predicate too — `query.c` opens a predicate on
341 /// either `(#` or `(.`.
342 #[test]
343 fn a_legacy_dot_predicate_is_not_a_binding_site() {
344 assert_eq!(
345 sites(r#"(a b: (c) @x (.eq? @x "y"))"#),
346 vec![site("x", Some("b"))]
347 );
348 }
349
350 /// A capture name is any run of identifier characters, ASCII or not; truncating at the
351 /// first non-ASCII byte would turn `@sinké` into `sink` and refuse a legitimate query.
352 #[test]
353 fn a_non_ascii_capture_name_is_read_whole() {
354 assert_eq!(sites("(a b: (c) @sinké)"), vec![site("sinké", Some("b"))]);
355 }
356
357 /// Nesting past the cap stops attributing slots instead of recursing, so no text can
358 /// overflow the stack on the way to the compiler's own syntax error.
359 #[test]
360 fn nesting_beyond_the_cap_does_not_recurse() {
361 let deep = "(".repeat(100_000);
362 assert_eq!(sites(&deep), Vec::new());
363 let deep_capture = format!("{}(x) @y{}", "(".repeat(100_000), ")".repeat(100_000));
364 assert_eq!(sites(&deep_capture), vec![site("y", None)]);
365 }
366
367 #[test]
368 fn a_quantifier_between_pattern_and_capture_is_transparent() {
369 assert_eq!(sites("(a b: (c)* @x)"), vec![site("x", Some("b"))]);
370 assert_eq!(sites("(a b: (c)+ @x)"), vec![site("x", Some("b"))]);
371 assert_eq!(sites("(a b: (c)? @x)"), vec![site("x", Some("b"))]);
372 }
373
374 #[test]
375 fn an_anonymous_node_and_a_wildcard_fill_a_slot_too() {
376 assert_eq!(
377 sites(r#"(a b: "tok" @x c: _ @y)"#),
378 vec![site("x", Some("b")), site("y", Some("c"))]
379 );
380 }
381
382 #[test]
383 fn a_negated_field_labels_nothing() {
384 assert_eq!(sites("(a !b (c) @x)"), vec![site("x", None)]);
385 }
386
387 #[test]
388 fn an_anchor_labels_nothing() {
389 assert_eq!(sites("(a . (c) @x)"), vec![site("x", None)]);
390 assert_eq!(sites("(a b: (c) . (d) @x)"), vec![site("x", None)]);
391 }
392
393 #[test]
394 fn a_predicate_is_not_a_binding_site() {
395 // `@x` appears three times; only the first is a binding.
396 assert_eq!(
397 sites(r#"(a b: (c) @x (#eq? @x "y") (#match? @x "\\)"))"#),
398 vec![site("x", Some("b"))]
399 );
400 }
401
402 #[test]
403 fn a_comment_is_skipped() {
404 assert_eq!(
405 sites("; (z: (q) @not)\n(a (b) @x) ; @nope\n"),
406 vec![site("x", None)]
407 );
408 }
409
410 #[test]
411 fn a_string_containing_a_paren_does_not_unbalance() {
412 assert_eq!(sites(r#"(a b: "(" @x)"#), vec![site("x", Some("b"))]);
413 assert_eq!(
414 sites(r#"(a b: "\"" @x c: (d) @y)"#),
415 vec![site("x", Some("b")), site("y", Some("c"))]
416 );
417 }
418
419 #[test]
420 fn a_grouped_pattern_records_the_inner_capture() {
421 assert_eq!(sites(r#"((a) @x (#eq? @x "y"))"#), vec![site("x", None)]);
422 }
423
424 #[test]
425 fn two_top_level_patterns_are_scanned_in_order() {
426 assert_eq!(
427 sites("(a) @x\n(b c: (d) @y)"),
428 vec![site("x", None), site("y", Some("c"))]
429 );
430 }
431
432 #[test]
433 fn a_supertype_pattern_is_one_pattern() {
434 assert_eq!(sites("(expression/identifier) @x"), vec![site("x", None)]);
435 assert_eq!(
436 sites("(a b: (expression/identifier) @x)"),
437 vec![site("x", Some("b"))]
438 );
439 }
440
441 #[test]
442 fn a_capture_name_may_carry_dots() {
443 assert_eq!(sites("(a) @x.y"), vec![site("x.y", None)]);
444 }
445
446 #[test]
447 fn malformed_text_still_returns_what_was_found() {
448 // The grammar's compiler reports the syntax error; this just must not lose or invent
449 // a site on the way there.
450 assert_eq!(sites("(a b: (c) @x"), vec![site("x", Some("b"))]);
451 assert_eq!(sites(") @x"), vec![site("x", None)]);
452 assert_eq!(sites(""), Vec::new());
453 }
454}