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
34mod capture_sites;
35pub use capture_sites::{CaptureSite, capture_sites};
36
37use lanekeep_core::Position;
38use lanekeep_lang::{Language, LanguageId};
39use streaming_iterator::StreamingIterator;
40use thiserror::Error;
41use tree_sitter::{Node, Query, QueryCursor, QueryErrorKind, Tree};
42
43/// What is wrong with a query.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum CompileErrorKind {
46    /// The query is not well-formed S-expression syntax.
47    Syntax,
48    /// A node kind the grammar does not define.
49    UnknownNodeKind,
50    /// A field name the grammar does not define.
51    UnknownField,
52    /// A predicate referenced a capture that the pattern does not bind.
53    UnknownCapture,
54    /// The pattern is syntactically valid but structurally impossible.
55    ImpossiblePattern,
56    /// The query binds no captures, so a handler has nothing to reference.
57    NoCaptures,
58    /// The query carries a predicate tree-sitter parses but never applies.
59    UnsupportedPredicate,
60    /// The grammar rejected the query for a reason lanekeep does not model.
61    Other,
62}
63
64impl CompileErrorKind {
65    /// A one-line explanation, phrased as what went wrong rather than what tree-sitter
66    /// called it.
67    const fn describe(self) -> &'static str {
68        match self {
69            Self::Syntax => "the query is not valid s-expression syntax",
70            Self::UnknownNodeKind => "no such node kind in this grammar",
71            Self::UnknownField => "no such field in this grammar",
72            Self::UnknownCapture => "the query refers to a capture it never binds",
73            Self::ImpossiblePattern => "this pattern can never match",
74            Self::NoCaptures => "the query binds no captures",
75            Self::UnsupportedPredicate => "the query uses a predicate that is never applied",
76            Self::Other => "the grammar rejected this query",
77        }
78    }
79}
80
81/// A query that failed to compile.
82///
83/// Carries enough to point at the problem: the kind, the position within the query source,
84/// and the offending line with a caret. Rendering all of that is the whole point — a rule
85/// author who cannot see *where* a 12-line pattern went wrong will rewrite it by guessing.
86#[derive(Debug, Clone, PartialEq, Eq, Error)]
87pub struct CompileError {
88    /// What kind of problem.
89    pub kind: CompileErrorKind,
90    /// Which language's grammar rejected it.
91    pub language: LanguageId,
92    /// One-based position within the query source.
93    pub position: Position,
94    /// Byte offset within the query source.
95    pub offset: usize,
96    /// The specific token or name at fault, when tree-sitter identified one.
97    pub detail: String,
98    /// The offending line of the query, without its trailing newline.
99    pub line: String,
100}
101
102impl fmt::Display for CompileError {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        writeln!(f, "query error: {}", self.kind.describe())?;
105
106        if !self.detail.is_empty() {
107            match self.kind {
108                CompileErrorKind::UnknownNodeKind => writeln!(
109                    f,
110                    "  the {} grammar has no node kind `{}`",
111                    self.language, self.detail
112                )?,
113                CompileErrorKind::UnknownField => writeln!(
114                    f,
115                    "  the {} grammar has no field `{}`",
116                    self.language, self.detail
117                )?,
118                CompileErrorKind::UnsupportedPredicate => writeln!(
119                    f,
120                    "  the predicate `{}` is parsed but never applied; remove it",
121                    self.detail
122                )?,
123                _ => writeln!(f, "  {}", self.detail)?,
124            }
125        }
126
127        if !self.line.is_empty() {
128            let gutter = format!("{}", self.position.line);
129            let pad = " ".repeat(gutter.len());
130            writeln!(
131                f,
132                "{pad} --> query:{}:{}",
133                self.position.line, self.position.column
134            )?;
135            writeln!(f, "{pad}  |")?;
136            writeln!(f, "{gutter} | {}", self.line)?;
137            // The caret column is a character offset into the line, so it lines up under
138            // the token even when earlier characters are multi-byte.
139            let caret_pad = " ".repeat(self.position.column.saturating_sub(1) as usize);
140            writeln!(f, "{pad}  | {caret_pad}^")?;
141        }
142
143        Ok(())
144    }
145}
146
147/// A query compiled against one language's grammar.
148#[derive(Debug)]
149pub struct CompiledQuery {
150    query: Query,
151    language: LanguageId,
152    capture_names: Vec<String>,
153}
154
155impl CompiledQuery {
156    /// Compile query source against a language.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`CompileError`] for malformed syntax, unknown node kinds or fields, for a
161    /// query that binds no captures, and for a query carrying a predicate that tree-sitter
162    /// parses but never applies (anything beyond the text predicates `#eq?`/`#match?`/
163    /// `#any-of?` and their negations).
164    pub fn compile(language: &dyn Language, source: &str) -> Result<Self, CompileError> {
165        let grammar = language.grammar();
166        let id = language.id();
167
168        let query = Query::new(&grammar, source).map_err(|err| {
169            let kind = match err.kind {
170                QueryErrorKind::Syntax => CompileErrorKind::Syntax,
171                QueryErrorKind::NodeType => CompileErrorKind::UnknownNodeKind,
172                QueryErrorKind::Field => CompileErrorKind::UnknownField,
173                QueryErrorKind::Capture => CompileErrorKind::UnknownCapture,
174                QueryErrorKind::Structure => CompileErrorKind::ImpossiblePattern,
175                _ => CompileErrorKind::Other,
176            };
177
178            // Syntax errors arrive with a caret diagram already baked into the message,
179            // which would be rendered a second time by Display. Everything else arrives as
180            // a bare quoted token.
181            let detail = match kind {
182                CompileErrorKind::Syntax => String::new(),
183                _ => err.message.trim_matches('"').to_owned(),
184            };
185
186            CompileError {
187                kind,
188                language: id,
189                position: Position::new(
190                    u32::try_from(err.row).unwrap_or(u32::MAX).saturating_add(1),
191                    u32::try_from(err.column)
192                        .unwrap_or(u32::MAX)
193                        .saturating_add(1),
194                ),
195                offset: err.offset,
196                detail,
197                line: source.lines().nth(err.row).unwrap_or_default().to_owned(),
198            }
199        })?;
200
201        // The binding applies text predicates (`#eq?`, `#not-eq?`, `#match?`,
202        // `#not-match?`, `#any-of?`, `#not-any-of?`) while iterating matches, so those
203        // gate correctly here. Every other predicate — `#is?`/`#is-not?`, `#set!`, or an
204        // operator the binding does not know — is parsed but *never applied*: a rule
205        // carrying one gets every structural match as though the predicate were not there.
206        // Refusing it at compile time keeps that failure loud instead of silent.
207        for pattern in 0..query.pattern_count() {
208            let operator = query
209                .general_predicates(pattern)
210                .first()
211                .map(|predicate| predicate.operator.to_string())
212                .or_else(|| {
213                    query
214                        .property_predicates(pattern)
215                        .first()
216                        .map(|(_, is_positive)| {
217                            if *is_positive { "is?" } else { "is-not?" }.to_owned()
218                        })
219                })
220                .or_else(|| {
221                    query
222                        .property_settings(pattern)
223                        .first()
224                        .map(|_| "set!".to_owned())
225                });
226
227            if let Some(operator) = operator {
228                let start = query.start_byte_for_pattern(pattern);
229                let end = query.end_byte_for_pattern(pattern);
230                let needle = format!("#{operator}");
231                let offset = source[start..end]
232                    .find(&needle)
233                    .map_or(start, |rel| start + rel);
234                let (position, line) = position_at(source, offset);
235                return Err(CompileError {
236                    kind: CompileErrorKind::UnsupportedPredicate,
237                    language: id,
238                    position,
239                    offset,
240                    detail: needle,
241                    line: line.to_owned(),
242                });
243            }
244        }
245
246        let capture_names: Vec<String> = query
247            .capture_names()
248            .iter()
249            .map(|name| (*name).to_owned())
250            .collect();
251
252        // A pattern with no captures still matches, but the handler receives nothing it can
253        // report at or inspect. Catching it here turns a rule that silently never reports
254        // into a compile error naming the cause.
255        if capture_names.is_empty() {
256            return Err(CompileError {
257                kind: CompileErrorKind::NoCaptures,
258                language: id,
259                position: Position::START,
260                offset: 0,
261                detail: "add a capture such as `@match` so the handler can reference the node"
262                    .to_owned(),
263                line: source.lines().next().unwrap_or_default().to_owned(),
264            });
265        }
266
267        Ok(Self {
268            query,
269            language: id,
270            capture_names,
271        })
272    }
273
274    /// The language this query was compiled against.
275    #[must_use]
276    pub const fn language(&self) -> LanguageId {
277        self.language
278    }
279
280    /// Capture names, in capture-index order.
281    #[must_use]
282    pub fn capture_names(&self) -> &[String] {
283        &self.capture_names
284    }
285
286    /// How many alternative patterns the query contains.
287    #[must_use]
288    pub fn pattern_count(&self) -> usize {
289        self.query.pattern_count()
290    }
291
292    /// Run the query over a tree, invoking `visit` once per match.
293    ///
294    /// A callback rather than an iterator, and rather than returning a `Vec`: matches
295    /// borrow the tree, and collecting them would allocate for every match on the hot path
296    /// this crate exists to keep cheap.
297    ///
298    /// Matches arrive in the order tree-sitter walks the tree, which is deterministic for a
299    /// given tree and query. Nothing downstream may depend on that order beyond determinism
300    /// itself — violations are sorted before reporting regardless.
301    pub fn for_each_match<'tree>(
302        &self,
303        tree: &'tree Tree,
304        source: &[u8],
305        visit: impl FnMut(QueryMatch<'_, 'tree>),
306    ) {
307        self.for_each_match_in(tree.root_node(), source, visit);
308    }
309
310    /// The same, scoped to one node's subtree.
311    ///
312    /// What `ctx.querySubtree` is built on: a rule that has already matched a function and
313    /// wants to look inside it should not have to filter the whole file's matches by
314    /// position, which is both slower and easy to get subtly wrong at boundaries.
315    pub fn for_each_match_in<'tree>(
316        &self,
317        node: Node<'tree>,
318        source: &[u8],
319        mut visit: impl FnMut(QueryMatch<'_, 'tree>),
320    ) {
321        let mut cursor = QueryCursor::new();
322        let mut matches = cursor.matches(&self.query, node, source);
323
324        while let Some(m) = matches.next() {
325            let captures = m
326                .captures()
327                .iter()
328                .map(|capture| {
329                    let name = self
330                        .capture_names
331                        .get(capture.index as usize)
332                        .map_or("", String::as_str);
333                    (name, capture.node)
334                })
335                .collect();
336
337            visit(QueryMatch {
338                pattern_index: m.pattern_index,
339                captures,
340            });
341        }
342    }
343}
344
345/// One match, with its captured nodes.
346#[derive(Debug, Clone)]
347pub struct QueryMatch<'q, 'tree> {
348    /// Which alternative pattern matched.
349    pub pattern_index: usize,
350    /// Captured nodes, in the order tree-sitter reported them.
351    pub captures: Vec<(&'q str, Node<'tree>)>,
352}
353
354impl<'tree> QueryMatch<'_, 'tree> {
355    /// The node bound to a capture name, if the pattern bound one.
356    ///
357    /// Returns the first when a name is bound more than once — which happens under
358    /// quantifiers, where a single capture name legitimately matches repeatedly.
359    #[must_use]
360    pub fn get(&self, name: &str) -> Option<Node<'tree>> {
361        self.captures
362            .iter()
363            .find(|(n, _)| *n == name)
364            .map(|(_, node)| *node)
365    }
366
367    /// Every node bound to a capture name.
368    pub fn get_all<'a>(&'a self, name: &'a str) -> impl Iterator<Item = Node<'tree>> + 'a {
369        self.captures
370            .iter()
371            .filter(move |(n, _)| *n == name)
372            .map(|(_, node)| *node)
373    }
374}
375
376/// The one-based position of a byte offset in `source`, plus the line it lands on.
377///
378/// `Position` counts columns in characters rather than bytes, matching the caret rendering
379/// in [`CompileError`]'s `Display`, which pads by `column - 1`.
380fn position_at(source: &str, offset: usize) -> (Position, &str) {
381    let before = &source[..offset.min(source.len())];
382    let line = u32::try_from(before.bytes().filter(|&b| b == b'\n').count())
383        .unwrap_or(u32::MAX)
384        .saturating_add(1);
385    let last_nl = before.rfind('\n').map_or(0, |i| i + 1);
386    let column = u32::try_from(before[last_nl..].chars().count())
387        .unwrap_or(u32::MAX)
388        .saturating_add(1);
389    let line_text = source.lines().nth((line - 1) as usize).unwrap_or_default();
390    (Position::new(line, column), line_text)
391}
392
393#[cfg(test)]
394mod tests {
395    use lanekeep_lang_js::{JavaScript, Tsx, TypeScript};
396
397    use super::*;
398
399    fn parse(language: &dyn Language, source: &str) -> Tree {
400        let mut parser = tree_sitter::Parser::new();
401        parser
402            .set_language(&language.grammar())
403            .expect("grammar loads");
404        parser.parse(source, None).expect("parser returns a tree")
405    }
406
407    fn compile(source: &str) -> CompiledQuery {
408        CompiledQuery::compile(&TypeScript, source).expect("query compiles")
409    }
410
411    fn compile_err(source: &str) -> CompileError {
412        CompiledQuery::compile(&TypeScript, source).expect_err("query should not compile")
413    }
414
415    /// Every match, rendered as `capture=text` pairs, so assertions read like the query.
416    fn run(query: &CompiledQuery, source: &str) -> Vec<Vec<String>> {
417        let tree = parse(&TypeScript, source);
418        let mut out = Vec::new();
419        query.for_each_match(&tree, source.as_bytes(), |m| {
420            out.push(
421                m.captures
422                    .iter()
423                    .map(|(name, node)| {
424                        let text = node.utf8_text(source.as_bytes()).unwrap_or("<invalid>");
425                        format!("{name}={text}")
426                    })
427                    .collect(),
428            );
429        });
430        out
431    }
432
433    #[test]
434    fn compiles_a_simple_query() {
435        let query = compile("(identifier) @id");
436        assert_eq!(query.capture_names(), ["id"]);
437        assert_eq!(query.pattern_count(), 1);
438        assert_eq!(query.language().as_str(), "typescript");
439    }
440
441    #[test]
442    fn reports_capture_names_in_index_order() {
443        let query =
444            compile("(pair key: (property_identifier) @prop value: (number) @value) @match");
445        assert_eq!(query.capture_names(), ["prop", "value", "match"]);
446    }
447
448    #[test]
449    fn matches_expose_captures_by_name() {
450        let query =
451            compile("(pair key: (property_identifier) @prop value: (number) @value) @match");
452        let matches = run(&query, "const s = { padding: 12, margin: 4 };");
453
454        assert_eq!(matches.len(), 2);
455        assert!(matches[0].contains(&"prop=padding".to_owned()));
456        assert!(matches[0].contains(&"value=12".to_owned()));
457        assert!(matches[1].contains(&"prop=margin".to_owned()));
458        assert!(matches[1].contains(&"value=4".to_owned()));
459    }
460
461    #[test]
462    fn get_returns_the_node_for_a_capture() {
463        let query = compile("(pair key: (property_identifier) @prop) @match");
464        let source = "const s = { padding: 12 };";
465        let tree = parse(&TypeScript, source);
466
467        let mut seen = Vec::new();
468        query.for_each_match(&tree, source.as_bytes(), |m| {
469            let prop = m.get("prop").expect("prop is bound");
470            seen.push(
471                prop.utf8_text(source.as_bytes())
472                    .unwrap_or_default()
473                    .to_owned(),
474            );
475            assert!(m.get("nope").is_none(), "unbound capture must be None");
476        });
477
478        assert_eq!(seen, ["padding"]);
479    }
480
481    #[test]
482    fn match_order_is_deterministic() {
483        // Nothing downstream may depend on this order beyond its being stable — violations
484        // are sorted before reporting either way. But an unstable order here would make
485        // that sort the only thing standing between the tool and nondeterministic output,
486        // which is a thinner guarantee than it looks.
487        let query = compile("(identifier) @id");
488        let source = "const alpha = 1; const beta = 2; function gamma() { return delta }";
489
490        let first = run(&query, source);
491        for _ in 0..25 {
492            assert_eq!(
493                run(&query, source),
494                first,
495                "match order varied between runs"
496            );
497        }
498        assert!(
499            first.len() >= 4,
500            "expected several matches, got {}",
501            first.len()
502        );
503    }
504
505    #[test]
506    fn a_query_matching_nothing_yields_no_matches() {
507        let query = compile("(class_declaration) @c");
508        assert!(run(&query, "const x = 1;").is_empty());
509    }
510
511    #[test]
512    fn handles_an_empty_source_file() {
513        let query = compile("(identifier) @id");
514        assert!(run(&query, "").is_empty());
515    }
516
517    #[test]
518    fn rejects_a_query_with_no_captures() {
519        // A capture-free pattern still matches, but the handler receives nothing it can
520        // report at. Without this check the rule silently never reports and the author has
521        // no signal at all.
522        let err = compile_err("(identifier)");
523        assert_eq!(err.kind, CompileErrorKind::NoCaptures);
524        assert!(
525            err.to_string().contains("@match"),
526            "should suggest adding a capture"
527        );
528    }
529
530    #[test]
531    fn rejects_an_unknown_node_kind_with_a_useful_message() {
532        // tree-sitter's own message here is the quoted node name and nothing else.
533        let err = compile_err("(nonexistent_node) @x");
534        assert_eq!(err.kind, CompileErrorKind::UnknownNodeKind);
535        assert_eq!(err.detail, "nonexistent_node");
536
537        let rendered = err.to_string();
538        assert!(
539            rendered.contains("typescript"),
540            "should name the grammar: {rendered}"
541        );
542        assert!(
543            rendered.contains("nonexistent_node"),
544            "should name the node: {rendered}"
545        );
546        assert!(
547            rendered.contains("-->"),
548            "should point at a position: {rendered}"
549        );
550        assert!(rendered.contains('^'), "should carry a caret: {rendered}");
551    }
552
553    #[test]
554    fn rejects_an_unknown_field() {
555        let err = compile_err("(pair nonexistent_field: (number) @n) @m");
556        assert_eq!(err.kind, CompileErrorKind::UnknownField);
557        assert_eq!(err.detail, "nonexistent_field");
558        assert!(err.to_string().contains("no field"), "{err}");
559    }
560
561    #[test]
562    fn rejects_malformed_syntax() {
563        let err = compile_err("(pair key: (property_identifier) @a");
564        assert_eq!(err.kind, CompileErrorKind::Syntax);
565    }
566
567    #[test]
568    fn points_at_the_right_line_of_a_multiline_query() {
569        // The reason this matters: a real rule's query is a dozen lines, and an error
570        // pointing at "the query" rather than at a line is barely better than none.
571        let err = CompiledQuery::compile(
572            &TypeScript,
573            "(pair\n  key: (property_identifier) @prop\n  value: (nonexistent_node) @v) @m",
574        )
575        .expect_err("should not compile");
576
577        assert_eq!(err.position.line, 3, "should point at the third line");
578        assert!(
579            err.line.contains("nonexistent_node"),
580            "excerpt should be that line: {err:?}"
581        );
582
583        let rendered = err.to_string();
584        assert!(rendered.contains("query:3:"), "{rendered}");
585        // The caret must sit under the token, not at the start of the line.
586        let caret_line = rendered.lines().last().unwrap_or_default();
587        let caret_col = caret_line.find('^').unwrap_or(0);
588        assert!(
589            caret_col > 4,
590            "caret should be indented to the token: {rendered}"
591        );
592    }
593
594    #[test]
595    fn compiles_against_each_language() {
596        // A query valid for one grammar is not automatically valid for another, which is
597        // why compilation takes the language rather than assuming one.
598        let jsx = "(jsx_element) @el";
599        assert!(
600            CompiledQuery::compile(&Tsx, jsx).is_ok(),
601            "TSX should know jsx_element"
602        );
603        assert!(
604            CompiledQuery::compile(&JavaScript, jsx).is_ok(),
605            "JS should know jsx_element"
606        );
607
608        let err = CompiledQuery::compile(&TypeScript, jsx)
609            .expect_err("plain TypeScript has no JSX nodes");
610        assert_eq!(err.kind, CompileErrorKind::UnknownNodeKind);
611        assert_eq!(err.language.as_str(), "typescript");
612
613        let types = "(type_annotation) @t";
614        assert!(CompiledQuery::compile(&TypeScript, types).is_ok());
615        assert!(
616            CompiledQuery::compile(&JavaScript, types).is_err(),
617            "JavaScript has no type annotations"
618        );
619    }
620
621    #[test]
622    fn supports_alternations_and_multiple_patterns() {
623        let query = compile("[(number) (string)] @literal");
624        let matches = run(&query, "const a = 1; const b = 'two';");
625        assert_eq!(matches.len(), 2);
626
627        let two = compile("(number) @n\n(string) @s");
628        assert_eq!(two.pattern_count(), 2);
629        assert_eq!(two.capture_names(), ["n", "s"]);
630    }
631
632    #[test]
633    fn get_all_returns_every_binding_of_a_repeated_capture() {
634        // Under a quantifier one name legitimately binds several nodes, and `get` returning
635        // only the first would silently drop the rest.
636        let query = compile("(object (pair) @entry) @obj");
637        let source = "const s = { a: 1, b: 2, c: 3 };";
638        let tree = parse(&TypeScript, source);
639
640        let mut counts = Vec::new();
641        query.for_each_match(&tree, source.as_bytes(), |m| {
642            counts.push(m.get_all("entry").count());
643            assert!(m.get("entry").is_some());
644        });
645
646        assert!(!counts.is_empty(), "expected at least one match");
647    }
648
649    #[test]
650    fn nodes_carry_positions_usable_for_reporting() {
651        let query = compile("(number) @n");
652        let source = "const a = 1;\nconst b = 22;";
653        let tree = parse(&TypeScript, source);
654
655        let mut positions = Vec::new();
656        query.for_each_match(&tree, source.as_bytes(), |m| {
657            let node = m.get("n").expect("bound");
658            let start = node.start_position();
659            positions.push((start.row + 1, start.column + 1));
660        });
661
662        assert_eq!(positions, [(1, 11), (2, 11)]);
663    }
664
665    #[test]
666    fn text_predicates_filter_matches() {
667        // The binding applies these while iterating; the tests pin that through this
668        // crate's own match path, so a tree-sitter upgrade that stops applying them turns
669        // red here rather than silently loosening every gated rule. Each query wraps the
670        // node and its predicate in one outer pair of parens: tree-sitter reads
671        // `(identifier) @id (#eq? @id ...)` as TWO patterns, with the predicate on a
672        // pattern of its own that filters nothing.
673        let source = "const alpha = 1; const beta = 2;";
674
675        let query = compile("((identifier) @id (#eq? @id \"alpha\"))");
676        assert_eq!(run(&query, source), vec![vec!["id=alpha".to_owned()]]);
677
678        let query = compile("((identifier) @id (#not-eq? @id \"alpha\"))");
679        assert_eq!(run(&query, source), vec![vec!["id=beta".to_owned()]]);
680
681        let query = compile("((identifier) @id (#match? @id \"^a\"))");
682        assert_eq!(run(&query, source), vec![vec!["id=alpha".to_owned()]]);
683
684        let query = compile("((identifier) @id (#not-match? @id \"^a\"))");
685        assert_eq!(run(&query, source), vec![vec!["id=beta".to_owned()]]);
686
687        let source = "const alpha = 1; const beta = 2; const gamma = 3;";
688        let query = compile("((identifier) @id (#any-of? @id \"alpha\" \"gamma\"))");
689        assert_eq!(
690            run(&query, source),
691            vec![vec!["id=alpha".to_owned()], vec!["id=gamma".to_owned()]]
692        );
693
694        let query = compile("((identifier) @id (#not-any-of? @id \"alpha\" \"gamma\"))");
695        assert_eq!(run(&query, source), vec![vec!["id=beta".to_owned()]]);
696    }
697
698    #[test]
699    fn rejects_a_general_predicate_naming_the_operator() {
700        // tree-sitter parses `#is?`/`#set!`/unknown operators but never applies them: a
701        // rule carrying one gets every structural match as though the predicate were not
702        // there. Refusing to compile keeps that failure loud instead of silent.
703        let err = compile_err("((identifier) @id (#is? @id \"x\"))");
704        assert_eq!(err.kind, CompileErrorKind::UnsupportedPredicate);
705        let rendered = err.to_string();
706        assert!(
707            rendered.contains("#is?"),
708            "should name the operator: {rendered}"
709        );
710        assert!(
711            rendered.contains("-->"),
712            "should point at a position: {rendered}"
713        );
714        assert!(rendered.contains('^'), "should carry a caret: {rendered}");
715
716        let err = compile_err("((identifier) @id (#set! @id \"x\"))");
717        assert_eq!(err.kind, CompileErrorKind::UnsupportedPredicate);
718        assert!(err.to_string().contains("#set!"), "{}", err);
719
720        // `#is-not?` shares `#is?`'s never-applied bucket, under the negated name.
721        let err = compile_err("((identifier) @id (#is-not? @id \"x\"))");
722        assert_eq!(err.kind, CompileErrorKind::UnsupportedPredicate);
723        assert!(err.to_string().contains("#is-not?"), "{}", err);
724
725        let err = compile_err("((identifier) @id (#foo? @id \"x\"))");
726        assert_eq!(err.kind, CompileErrorKind::UnsupportedPredicate);
727        assert!(err.to_string().contains("#foo?"), "{}", err);
728    }
729
730    #[test]
731    fn general_predicate_error_points_at_the_operator() {
732        let err = CompiledQuery::compile(
733            &TypeScript,
734            "((pair\n  key: (property_identifier) @prop\n  value: (number) @v) @m\n  (#is? @prop \"x\"))",
735        )
736        .expect_err("should not compile");
737        assert_eq!(err.kind, CompileErrorKind::UnsupportedPredicate);
738        assert_eq!(err.position.line, 4, "should point at the fourth line");
739        assert!(
740            err.line.contains("#is?"),
741            "excerpt should be that line: {err:?}"
742        );
743        let rendered = err.to_string();
744        assert!(rendered.contains("query:4:"), "{rendered}");
745        // The caret must sit under the operator token, not at the start of the line.
746        let caret_line = rendered.lines().last().unwrap_or_default();
747        let caret_col = caret_line.find('^').unwrap_or(0);
748        assert!(
749            caret_col > 2,
750            "caret should be indented to the token: {rendered}"
751        );
752    }
753}