Skip to main content

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}