Skip to main content

lanekeep_query/
lib.rs

1//! tree-sitter query parsing and compilation for lanekeep.
2//!
3//! Parses and compiles the tree-sitter queries that gate rule execution.
4//!
5//! Queries are the gate, not the rule language: they select which nodes reach a
6//! Turing-complete TypeScript handler. That is what keeps JavaScript execution proportional
7//! to matches rather than to nodes.
8//!
9//! # Why the error type is the bulk of this crate
10//!
11//! S-expression queries are the least approachable part of authoring a rule, and
12//! tree-sitter's own diagnostics are close to unusable on their own — an unknown node kind
13//! reports a message consisting of the quoted node name and nothing else. Since a rule
14//! author's first several attempts will fail to compile, the quality of that failure is
15//! most of what makes the query language tractable.
16//!
17//! What [`CompileError`] renders instead:
18//!
19//! ```text
20//! query error: no such node kind in this grammar
21//!   the typescript grammar has no node kind `nonexistent_node`
22//!   --> query:3:11
23//!    |
24//!  3 |   value: (nonexistent_node) @v) @m
25//!    |           ^
26//! ```
27//!
28//! The line and caret matter more than they look. A real rule's query runs to a dozen
29//! lines, and an error naming "the query" rather than a position in it leaves the author
30//! bisecting by deletion.
31
32use std::fmt;
33
34use lanekeep_core::Position;
35use lanekeep_lang::{Language, LanguageId};
36use streaming_iterator::StreamingIterator;
37use thiserror::Error;
38use tree_sitter::{Node, Query, QueryCursor, QueryErrorKind, Tree};
39
40/// What is wrong with a query.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum CompileErrorKind {
43    /// The query is not well-formed S-expression syntax.
44    Syntax,
45    /// A node kind the grammar does not define.
46    UnknownNodeKind,
47    /// A field name the grammar does not define.
48    UnknownField,
49    /// A predicate referenced a capture that the pattern does not bind.
50    UnknownCapture,
51    /// The pattern is syntactically valid but structurally impossible.
52    ImpossiblePattern,
53    /// The query binds no captures, so a handler has nothing to reference.
54    NoCaptures,
55    /// The grammar rejected the query for a reason lanekeep does not model.
56    Other,
57}
58
59impl CompileErrorKind {
60    /// A one-line explanation, phrased as what went wrong rather than what tree-sitter
61    /// called it.
62    const fn describe(self) -> &'static str {
63        match self {
64            Self::Syntax => "the query is not valid s-expression syntax",
65            Self::UnknownNodeKind => "no such node kind in this grammar",
66            Self::UnknownField => "no such field in this grammar",
67            Self::UnknownCapture => "the query refers to a capture it never binds",
68            Self::ImpossiblePattern => "this pattern can never match",
69            Self::NoCaptures => "the query binds no captures",
70            Self::Other => "the grammar rejected this query",
71        }
72    }
73}
74
75/// A query that failed to compile.
76///
77/// Carries enough to point at the problem: the kind, the position within the query source,
78/// and the offending line with a caret. Rendering all of that is the whole point — a rule
79/// author who cannot see *where* a 12-line pattern went wrong will rewrite it by guessing.
80#[derive(Debug, Clone, PartialEq, Eq, Error)]
81pub struct CompileError {
82    /// What kind of problem.
83    pub kind: CompileErrorKind,
84    /// Which language's grammar rejected it.
85    pub language: LanguageId,
86    /// One-based position within the query source.
87    pub position: Position,
88    /// Byte offset within the query source.
89    pub offset: usize,
90    /// The specific token or name at fault, when tree-sitter identified one.
91    pub detail: String,
92    /// The offending line of the query, without its trailing newline.
93    pub line: String,
94}
95
96impl fmt::Display for CompileError {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        writeln!(f, "query error: {}", self.kind.describe())?;
99
100        if !self.detail.is_empty() {
101            match self.kind {
102                CompileErrorKind::UnknownNodeKind => writeln!(
103                    f,
104                    "  the {} grammar has no node kind `{}`",
105                    self.language, self.detail
106                )?,
107                CompileErrorKind::UnknownField => writeln!(
108                    f,
109                    "  the {} grammar has no field `{}`",
110                    self.language, self.detail
111                )?,
112                _ => writeln!(f, "  {}", self.detail)?,
113            }
114        }
115
116        if !self.line.is_empty() {
117            let gutter = format!("{}", self.position.line);
118            let pad = " ".repeat(gutter.len());
119            writeln!(
120                f,
121                "{pad} --> query:{}:{}",
122                self.position.line, self.position.column
123            )?;
124            writeln!(f, "{pad}  |")?;
125            writeln!(f, "{gutter} | {}", self.line)?;
126            // The caret column is a character offset into the line, so it lines up under
127            // the token even when earlier characters are multi-byte.
128            let caret_pad = " ".repeat(self.position.column.saturating_sub(1) as usize);
129            writeln!(f, "{pad}  | {caret_pad}^")?;
130        }
131
132        Ok(())
133    }
134}
135
136/// A query compiled against one language's grammar.
137#[derive(Debug)]
138pub struct CompiledQuery {
139    query: Query,
140    language: LanguageId,
141    capture_names: Vec<String>,
142}
143
144impl CompiledQuery {
145    /// Compile query source against a language.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`CompileError`] for malformed syntax, unknown node kinds or fields, and for
150    /// a query that binds no captures.
151    pub fn compile(language: &dyn Language, source: &str) -> Result<Self, CompileError> {
152        let grammar = language.grammar();
153        let id = language.id();
154
155        let query = Query::new(&grammar, source).map_err(|err| {
156            let kind = match err.kind {
157                QueryErrorKind::Syntax => CompileErrorKind::Syntax,
158                QueryErrorKind::NodeType => CompileErrorKind::UnknownNodeKind,
159                QueryErrorKind::Field => CompileErrorKind::UnknownField,
160                QueryErrorKind::Capture => CompileErrorKind::UnknownCapture,
161                QueryErrorKind::Structure => CompileErrorKind::ImpossiblePattern,
162                _ => CompileErrorKind::Other,
163            };
164
165            // Syntax errors arrive with a caret diagram already baked into the message,
166            // which would be rendered a second time by Display. Everything else arrives as
167            // a bare quoted token.
168            let detail = match kind {
169                CompileErrorKind::Syntax => String::new(),
170                _ => err.message.trim_matches('"').to_owned(),
171            };
172
173            CompileError {
174                kind,
175                language: id,
176                position: Position::new(
177                    u32::try_from(err.row).unwrap_or(u32::MAX).saturating_add(1),
178                    u32::try_from(err.column)
179                        .unwrap_or(u32::MAX)
180                        .saturating_add(1),
181                ),
182                offset: err.offset,
183                detail,
184                line: source.lines().nth(err.row).unwrap_or_default().to_owned(),
185            }
186        })?;
187
188        let capture_names: Vec<String> = query
189            .capture_names()
190            .iter()
191            .map(|name| (*name).to_owned())
192            .collect();
193
194        // A pattern with no captures still matches, but the handler receives nothing it can
195        // report at or inspect. Catching it here turns a rule that silently never reports
196        // into a compile error naming the cause.
197        if capture_names.is_empty() {
198            return Err(CompileError {
199                kind: CompileErrorKind::NoCaptures,
200                language: id,
201                position: Position::START,
202                offset: 0,
203                detail: "add a capture such as `@match` so the handler can reference the node"
204                    .to_owned(),
205                line: source.lines().next().unwrap_or_default().to_owned(),
206            });
207        }
208
209        Ok(Self {
210            query,
211            language: id,
212            capture_names,
213        })
214    }
215
216    /// The language this query was compiled against.
217    #[must_use]
218    pub const fn language(&self) -> LanguageId {
219        self.language
220    }
221
222    /// Capture names, in capture-index order.
223    #[must_use]
224    pub fn capture_names(&self) -> &[String] {
225        &self.capture_names
226    }
227
228    /// How many alternative patterns the query contains.
229    #[must_use]
230    pub fn pattern_count(&self) -> usize {
231        self.query.pattern_count()
232    }
233
234    /// Run the query over a tree, invoking `visit` once per match.
235    ///
236    /// A callback rather than an iterator, and rather than returning a `Vec`: matches
237    /// borrow the tree, and collecting them would allocate for every match on the hot path
238    /// this crate exists to keep cheap.
239    ///
240    /// Matches arrive in the order tree-sitter walks the tree, which is deterministic for a
241    /// given tree and query. Nothing downstream may depend on that order beyond determinism
242    /// itself — violations are sorted before reporting regardless.
243    pub fn for_each_match<'tree>(
244        &self,
245        tree: &'tree Tree,
246        source: &[u8],
247        visit: impl FnMut(QueryMatch<'_, 'tree>),
248    ) {
249        self.for_each_match_in(tree.root_node(), source, visit);
250    }
251
252    /// The same, scoped to one node's subtree.
253    ///
254    /// What `ctx.querySubtree` is built on: a rule that has already matched a function and
255    /// wants to look inside it should not have to filter the whole file's matches by
256    /// position, which is both slower and easy to get subtly wrong at boundaries.
257    pub fn for_each_match_in<'tree>(
258        &self,
259        node: Node<'tree>,
260        source: &[u8],
261        mut visit: impl FnMut(QueryMatch<'_, 'tree>),
262    ) {
263        let mut cursor = QueryCursor::new();
264        let mut matches = cursor.matches(&self.query, node, source);
265
266        while let Some(m) = matches.next() {
267            let captures = m
268                .captures
269                .iter()
270                .map(|capture| {
271                    let name = self
272                        .capture_names
273                        .get(capture.index as usize)
274                        .map_or("", String::as_str);
275                    (name, capture.node)
276                })
277                .collect();
278
279            visit(QueryMatch {
280                pattern_index: m.pattern_index,
281                captures,
282            });
283        }
284    }
285}
286
287/// One match, with its captured nodes.
288#[derive(Debug, Clone)]
289pub struct QueryMatch<'q, 'tree> {
290    /// Which alternative pattern matched.
291    pub pattern_index: usize,
292    /// Captured nodes, in the order tree-sitter reported them.
293    pub captures: Vec<(&'q str, Node<'tree>)>,
294}
295
296impl<'tree> QueryMatch<'_, 'tree> {
297    /// The node bound to a capture name, if the pattern bound one.
298    ///
299    /// Returns the first when a name is bound more than once — which happens under
300    /// quantifiers, where a single capture name legitimately matches repeatedly.
301    #[must_use]
302    pub fn get(&self, name: &str) -> Option<Node<'tree>> {
303        self.captures
304            .iter()
305            .find(|(n, _)| *n == name)
306            .map(|(_, node)| *node)
307    }
308
309    /// Every node bound to a capture name.
310    pub fn get_all<'a>(&'a self, name: &'a str) -> impl Iterator<Item = Node<'tree>> + 'a {
311        self.captures
312            .iter()
313            .filter(move |(n, _)| *n == name)
314            .map(|(_, node)| *node)
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use lanekeep_lang_js::{JavaScript, Tsx, TypeScript};
321
322    use super::*;
323
324    fn parse(language: &dyn Language, source: &str) -> Tree {
325        let mut parser = tree_sitter::Parser::new();
326        parser
327            .set_language(&language.grammar())
328            .expect("grammar loads");
329        parser.parse(source, None).expect("parser returns a tree")
330    }
331
332    fn compile(source: &str) -> CompiledQuery {
333        CompiledQuery::compile(&TypeScript, source).expect("query compiles")
334    }
335
336    fn compile_err(source: &str) -> CompileError {
337        CompiledQuery::compile(&TypeScript, source).expect_err("query should not compile")
338    }
339
340    /// Every match, rendered as `capture=text` pairs, so assertions read like the query.
341    fn run(query: &CompiledQuery, source: &str) -> Vec<Vec<String>> {
342        let tree = parse(&TypeScript, source);
343        let mut out = Vec::new();
344        query.for_each_match(&tree, source.as_bytes(), |m| {
345            out.push(
346                m.captures
347                    .iter()
348                    .map(|(name, node)| {
349                        let text = node.utf8_text(source.as_bytes()).unwrap_or("<invalid>");
350                        format!("{name}={text}")
351                    })
352                    .collect(),
353            );
354        });
355        out
356    }
357
358    #[test]
359    fn compiles_a_simple_query() {
360        let query = compile("(identifier) @id");
361        assert_eq!(query.capture_names(), ["id"]);
362        assert_eq!(query.pattern_count(), 1);
363        assert_eq!(query.language().as_str(), "typescript");
364    }
365
366    #[test]
367    fn reports_capture_names_in_index_order() {
368        let query =
369            compile("(pair key: (property_identifier) @prop value: (number) @value) @match");
370        assert_eq!(query.capture_names(), ["prop", "value", "match"]);
371    }
372
373    #[test]
374    fn matches_expose_captures_by_name() {
375        let query =
376            compile("(pair key: (property_identifier) @prop value: (number) @value) @match");
377        let matches = run(&query, "const s = { padding: 12, margin: 4 };");
378
379        assert_eq!(matches.len(), 2);
380        assert!(matches[0].contains(&"prop=padding".to_owned()));
381        assert!(matches[0].contains(&"value=12".to_owned()));
382        assert!(matches[1].contains(&"prop=margin".to_owned()));
383        assert!(matches[1].contains(&"value=4".to_owned()));
384    }
385
386    #[test]
387    fn get_returns_the_node_for_a_capture() {
388        let query = compile("(pair key: (property_identifier) @prop) @match");
389        let source = "const s = { padding: 12 };";
390        let tree = parse(&TypeScript, source);
391
392        let mut seen = Vec::new();
393        query.for_each_match(&tree, source.as_bytes(), |m| {
394            let prop = m.get("prop").expect("prop is bound");
395            seen.push(
396                prop.utf8_text(source.as_bytes())
397                    .unwrap_or_default()
398                    .to_owned(),
399            );
400            assert!(m.get("nope").is_none(), "unbound capture must be None");
401        });
402
403        assert_eq!(seen, ["padding"]);
404    }
405
406    #[test]
407    fn match_order_is_deterministic() {
408        // Nothing downstream may depend on this order beyond its being stable — violations
409        // are sorted before reporting either way. But an unstable order here would make
410        // that sort the only thing standing between the tool and nondeterministic output,
411        // which is a thinner guarantee than it looks.
412        let query = compile("(identifier) @id");
413        let source = "const alpha = 1; const beta = 2; function gamma() { return delta }";
414
415        let first = run(&query, source);
416        for _ in 0..25 {
417            assert_eq!(
418                run(&query, source),
419                first,
420                "match order varied between runs"
421            );
422        }
423        assert!(
424            first.len() >= 4,
425            "expected several matches, got {}",
426            first.len()
427        );
428    }
429
430    #[test]
431    fn a_query_matching_nothing_yields_no_matches() {
432        let query = compile("(class_declaration) @c");
433        assert!(run(&query, "const x = 1;").is_empty());
434    }
435
436    #[test]
437    fn handles_an_empty_source_file() {
438        let query = compile("(identifier) @id");
439        assert!(run(&query, "").is_empty());
440    }
441
442    #[test]
443    fn rejects_a_query_with_no_captures() {
444        // A capture-free pattern still matches, but the handler receives nothing it can
445        // report at. Without this check the rule silently never reports and the author has
446        // no signal at all.
447        let err = compile_err("(identifier)");
448        assert_eq!(err.kind, CompileErrorKind::NoCaptures);
449        assert!(
450            err.to_string().contains("@match"),
451            "should suggest adding a capture"
452        );
453    }
454
455    #[test]
456    fn rejects_an_unknown_node_kind_with_a_useful_message() {
457        // tree-sitter's own message here is the quoted node name and nothing else.
458        let err = compile_err("(nonexistent_node) @x");
459        assert_eq!(err.kind, CompileErrorKind::UnknownNodeKind);
460        assert_eq!(err.detail, "nonexistent_node");
461
462        let rendered = err.to_string();
463        assert!(
464            rendered.contains("typescript"),
465            "should name the grammar: {rendered}"
466        );
467        assert!(
468            rendered.contains("nonexistent_node"),
469            "should name the node: {rendered}"
470        );
471        assert!(
472            rendered.contains("-->"),
473            "should point at a position: {rendered}"
474        );
475        assert!(rendered.contains('^'), "should carry a caret: {rendered}");
476    }
477
478    #[test]
479    fn rejects_an_unknown_field() {
480        let err = compile_err("(pair nonexistent_field: (number) @n) @m");
481        assert_eq!(err.kind, CompileErrorKind::UnknownField);
482        assert_eq!(err.detail, "nonexistent_field");
483        assert!(err.to_string().contains("no field"), "{err}");
484    }
485
486    #[test]
487    fn rejects_malformed_syntax() {
488        let err = compile_err("(pair key: (property_identifier) @a");
489        assert_eq!(err.kind, CompileErrorKind::Syntax);
490    }
491
492    #[test]
493    fn points_at_the_right_line_of_a_multiline_query() {
494        // The reason this matters: a real rule's query is a dozen lines, and an error
495        // pointing at "the query" rather than at a line is barely better than none.
496        let err = CompiledQuery::compile(
497            &TypeScript,
498            "(pair\n  key: (property_identifier) @prop\n  value: (nonexistent_node) @v) @m",
499        )
500        .expect_err("should not compile");
501
502        assert_eq!(err.position.line, 3, "should point at the third line");
503        assert!(
504            err.line.contains("nonexistent_node"),
505            "excerpt should be that line: {err:?}"
506        );
507
508        let rendered = err.to_string();
509        assert!(rendered.contains("query:3:"), "{rendered}");
510        // The caret must sit under the token, not at the start of the line.
511        let caret_line = rendered.lines().last().unwrap_or_default();
512        let caret_col = caret_line.find('^').unwrap_or(0);
513        assert!(
514            caret_col > 4,
515            "caret should be indented to the token: {rendered}"
516        );
517    }
518
519    #[test]
520    fn compiles_against_each_language() {
521        // A query valid for one grammar is not automatically valid for another, which is
522        // why compilation takes the language rather than assuming one.
523        let jsx = "(jsx_element) @el";
524        assert!(
525            CompiledQuery::compile(&Tsx, jsx).is_ok(),
526            "TSX should know jsx_element"
527        );
528        assert!(
529            CompiledQuery::compile(&JavaScript, jsx).is_ok(),
530            "JS should know jsx_element"
531        );
532
533        let err = CompiledQuery::compile(&TypeScript, jsx)
534            .expect_err("plain TypeScript has no JSX nodes");
535        assert_eq!(err.kind, CompileErrorKind::UnknownNodeKind);
536        assert_eq!(err.language.as_str(), "typescript");
537
538        let types = "(type_annotation) @t";
539        assert!(CompiledQuery::compile(&TypeScript, types).is_ok());
540        assert!(
541            CompiledQuery::compile(&JavaScript, types).is_err(),
542            "JavaScript has no type annotations"
543        );
544    }
545
546    #[test]
547    fn supports_alternations_and_multiple_patterns() {
548        let query = compile("[(number) (string)] @literal");
549        let matches = run(&query, "const a = 1; const b = 'two';");
550        assert_eq!(matches.len(), 2);
551
552        let two = compile("(number) @n\n(string) @s");
553        assert_eq!(two.pattern_count(), 2);
554        assert_eq!(two.capture_names(), ["n", "s"]);
555    }
556
557    #[test]
558    fn get_all_returns_every_binding_of_a_repeated_capture() {
559        // Under a quantifier one name legitimately binds several nodes, and `get` returning
560        // only the first would silently drop the rest.
561        let query = compile("(object (pair) @entry) @obj");
562        let source = "const s = { a: 1, b: 2, c: 3 };";
563        let tree = parse(&TypeScript, source);
564
565        let mut counts = Vec::new();
566        query.for_each_match(&tree, source.as_bytes(), |m| {
567            counts.push(m.get_all("entry").count());
568            assert!(m.get("entry").is_some());
569        });
570
571        assert!(!counts.is_empty(), "expected at least one match");
572    }
573
574    #[test]
575    fn nodes_carry_positions_usable_for_reporting() {
576        let query = compile("(number) @n");
577        let source = "const a = 1;\nconst b = 22;";
578        let tree = parse(&TypeScript, source);
579
580        let mut positions = Vec::new();
581        query.for_each_match(&tree, source.as_bytes(), |m| {
582            let node = m.get("n").expect("bound");
583            let start = node.start_position();
584            positions.push((start.row + 1, start.column + 1));
585        });
586
587        assert_eq!(positions, [(1, 11), (2, 11)]);
588    }
589}