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