Skip to main content

greplm_core/
structural.rs

1//! Structural (AST) search.
2//!
3//! Two input dialects are accepted:
4//!   * A native tree-sitter query S-expression (anything starting with `(`),
5//!     with captures (`@name`) and `#eq?` / `#match?` predicates. This is the
6//!     full power of the tree-sitter query language.
7//!   * A friendlier code pattern with `$NAME` meta-variables, e.g.
8//!     `fn $NAME($PARAMS) -> Result<$T>`, which is compiled into a tree-sitter
9//!     query: concrete tokens become structure + `#eq?` constraints, and each
10//!     meta-variable becomes a wildcard capture. A variadic `$$$` (optionally
11//!     `$$$NAME`) matches any sequence of sibling nodes, so
12//!     `function $NAME($$$) { $$$ }` matches a function with any parameters and
13//!     any body. Variadic meta-variables only relax structure; they do not bind
14//!     a capture.
15//!
16//! Matching runs the compiled query over candidate documents. Literal tokens in
17//! the pattern double as trigram anchors so the index prunes candidates before
18//! parsing.
19
20use std::cell::RefCell;
21use std::collections::HashMap;
22
23use tree_sitter::{Node, Parser, Query, QueryCursor, StreamingIterator};
24
25use crate::error::{Error, Result};
26use crate::lang::Language;
27
28thread_local! {
29    static STRUCT_PARSERS: RefCell<HashMap<Language, Parser>> = RefCell::new(HashMap::new());
30}
31
32/// Sentinel prefix used when substituting `$NAME` meta-variables so the pattern
33/// still parses as code before we walk it.
34const SENTINEL: &str = "GREPLMMV";
35
36/// The capture automatically attached to the root of a compiled pattern, used
37/// to locate the matched node.
38const ROOT_CAPTURE: &str = "greplm.match";
39
40/// Sentinel that a variadic `$$$` meta-variable is collapsed to. It parses as
41/// an identifier in the common contexts (parameter lists, argument lists,
42/// statement blocks, arrays) and is dropped during emission, leaving the
43/// surrounding structure unconstrained — tree-sitter queries already allow
44/// extra, unmatched sibling nodes, which is exactly variadic semantics.
45const VARIADIC: &str = "GREPLMVARIADIC";
46
47/// A compiled structural pattern ready to run against documents.
48pub struct Compiled {
49    query: Query,
50    /// Literal tokens that must appear in any matching document (from `#eq?`
51    /// constraints); used as a trigram prefilter. Empty disables prefiltering.
52    pub anchors: Vec<String>,
53    root_capture: Option<u32>,
54}
55
56/// A capture bound by a structural match.
57#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
58pub struct StructCapture {
59    pub name: String,
60    pub text: String,
61    pub line: u32,
62}
63
64/// A single structural match within one document.
65#[derive(Debug, Clone)]
66pub struct StructMatch {
67    pub line_start: u32,
68    pub line_end: u32,
69    pub kind: String,
70    pub captures: Vec<StructCapture>,
71}
72
73/// Compile a pattern (S-expression or meta-variable form) for `lang`.
74pub fn compile(lang: Language, pattern: &str) -> Result<Compiled> {
75    let grammar = lang
76        .grammar()
77        .ok_or_else(|| Error::other(format!("language {} is not parseable", lang.id())))?;
78
79    let trimmed = pattern.trim();
80    let (query_src, anchors) = if trimmed.starts_with('(') {
81        // Raw tree-sitter query: trust the user, no prefilter anchors.
82        (trimmed.to_string(), Vec::new())
83    } else {
84        compile_metavars(lang, trimmed)?
85    };
86
87    let query = Query::new(&grammar, &query_src).map_err(|e| {
88        Error::other(format!(
89            "invalid structural query: {e}\n--- compiled query ---\n{query_src}"
90        ))
91    })?;
92    let root_capture = query
93        .capture_names()
94        .iter()
95        .position(|n| *n == ROOT_CAPTURE)
96        .map(|i| i as u32);
97    Ok(Compiled {
98        query,
99        anchors,
100        root_capture,
101    })
102}
103
104/// Run a compiled pattern over one source buffer.
105pub fn run(lang: Language, compiled: &Compiled, source: &[u8]) -> Vec<StructMatch> {
106    let grammar = match lang.grammar() {
107        Some(g) => g,
108        None => return Vec::new(),
109    };
110    STRUCT_PARSERS.with(|cell| {
111        let mut map = cell.borrow_mut();
112        let parser = map.entry(lang).or_insert_with(|| {
113            let mut p = Parser::new();
114            let _ = p.set_language(&grammar);
115            p
116        });
117        let tree = match parser.parse(source, None) {
118            Some(t) => t,
119            None => return Vec::new(),
120        };
121        let names = compiled.query.capture_names();
122        // QueryCursor's match limit defaults to `u32::MAX` (effectively
123        // unlimited), so matches are not silently capped on large files.
124        let mut cursor = QueryCursor::new();
125        let mut out = Vec::new();
126        let mut b1 = Vec::new();
127        let mut b2 = Vec::new();
128        let mut src = source;
129        let mut matches = cursor.matches(&compiled.query, tree.root_node(), source);
130        while let Some(m) = matches.next() {
131            if !m.satisfies_text_predicates(&compiled.query, &mut b1, &mut b2, &mut src) {
132                continue;
133            }
134            // Locate the primary node: the root capture if present, else the
135            // first capture.
136            let primary = compiled
137                .root_capture
138                .and_then(|ri| m.captures.iter().find(|c| c.index == ri))
139                .or_else(|| m.captures.first());
140            let node = match primary {
141                Some(c) => c.node,
142                None => continue,
143            };
144            let mut captures = Vec::new();
145            for c in m.captures {
146                let name = names.get(c.index as usize).copied().unwrap_or("");
147                if name == ROOT_CAPTURE {
148                    continue;
149                }
150                if let Ok(text) = std::str::from_utf8(&source[c.node.byte_range()]) {
151                    captures.push(StructCapture {
152                        name: name.to_string(),
153                        text: text.to_string(),
154                        line: c.node.start_position().row as u32 + 1,
155                    });
156                }
157            }
158            out.push(StructMatch {
159                line_start: node.start_position().row as u32 + 1,
160                line_end: node.end_position().row as u32 + 1,
161                kind: node.kind().to_string(),
162                captures,
163            });
164        }
165        out
166    })
167}
168
169/// Compile a `$NAME` meta-variable pattern into a tree-sitter query, returning
170/// the query text and the literal trigram anchors.
171fn compile_metavars(lang: Language, pattern: &str) -> Result<(String, Vec<String>)> {
172    let grammar = lang
173        .grammar()
174        .ok_or_else(|| Error::other(format!("language {} is not parseable", lang.id())))?;
175
176    // Substitute `$NAME` with a parseable sentinel identifier we can recognize.
177    let (substituted, _names) = substitute_metavars(pattern);
178
179    let mut parser = Parser::new();
180    parser
181        .set_language(&grammar)
182        .map_err(|e| Error::other(format!("set_language: {e}")))?;
183    let tree = parser
184        .parse(substituted.as_bytes(), None)
185        .ok_or_else(|| Error::other("failed to parse pattern".to_string()))?;
186    let src = substituted.as_bytes();
187
188    // Find the meaningful root by unwrapping single-child wrapper nodes.
189    let root = unwrap_root(tree.root_node());
190    if root.is_error() {
191        return Err(Error::other(
192            "pattern did not parse cleanly; check the syntax or use a tree-sitter query"
193                .to_string(),
194        ));
195    }
196
197    let mut out = String::new();
198    let mut anchors = Vec::new();
199    let mut counter = 0usize;
200    emit(root, src, &mut out, &mut anchors, &mut counter);
201    out.push_str(&format!(" @{ROOT_CAPTURE}"));
202    Ok((out, anchors))
203}
204
205/// Replace `$Ident` occurrences with `GREPLMMV_Ident` sentinels.
206fn substitute_metavars(pattern: &str) -> (String, Vec<String>) {
207    let mut out = String::with_capacity(pattern.len());
208    let mut names = Vec::new();
209    let bytes = pattern.as_bytes();
210    let mut i = 0;
211    while i < bytes.len() {
212        if bytes[i] == b'$' {
213            // Variadic `$$$` (optionally `$$$NAME`): collapse to a single
214            // sentinel identifier that we later drop from the query.
215            if i + 2 < bytes.len() && bytes[i + 1] == b'$' && bytes[i + 2] == b'$' {
216                let mut j = i + 3;
217                while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
218                    j += 1;
219                }
220                out.push_str(VARIADIC);
221                i = j;
222                continue;
223            }
224            let mut j = i + 1;
225            while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
226                j += 1;
227            }
228            if j > i + 1 {
229                let name = &pattern[i + 1..j];
230                out.push_str(SENTINEL);
231                out.push('_');
232                out.push_str(name);
233                names.push(name.to_string());
234                i = j;
235                continue;
236            }
237        }
238        out.push(bytes[i] as char);
239        i += 1;
240    }
241    (out, names)
242}
243
244/// Unwrap single-named-child wrapper nodes (file/program/statement shells) to
245/// reach the meaningful pattern node.
246fn unwrap_root(node: Node) -> Node {
247    const WRAPPERS: &[&str] = &[
248        "source_file",
249        "program",
250        "translation_unit",
251        "module",
252        "expression_statement",
253        "statement",
254        "compound_statement",
255        "block",
256    ];
257    let mut cur = node;
258    loop {
259        if !WRAPPERS.contains(&cur.kind()) {
260            return cur;
261        }
262        let mut cursor = cur.walk();
263        let children: Vec<Node> = cur.named_children(&mut cursor).collect();
264        if children.len() == 1 {
265            cur = children[0];
266        } else {
267            return cur;
268        }
269    }
270}
271
272/// Recursively emit an S-expression for `node`.
273fn emit(node: Node, src: &[u8], out: &mut String, anchors: &mut Vec<String>, counter: &mut usize) {
274    let text = node_text(node, src);
275
276    // A variadic `$$$` node (and any single-child wrapper around it, such as an
277    // expression statement) contributes no constraint: emit nothing so the
278    // parent matches regardless of the nodes in this position.
279    if let Some(t) = &text {
280        if t.trim() == VARIADIC {
281            return;
282        }
283    }
284
285    // A substituted meta-variable becomes a wildcard capture.
286    if let Some(t) = &text {
287        if let Some(name) = t.strip_prefix(&format!("{SENTINEL}_")) {
288            if is_clean_ident(name) {
289                out.push_str(&format!("(_) @{name}"));
290                return;
291            }
292        }
293    }
294
295    let mut cursor = node.walk();
296    let named: Vec<Node> = node.named_children(&mut cursor).collect();
297
298    if node.child_count() == 0 {
299        // A true terminal token (identifier, literal, keyword). Constrain by
300        // kind and exact text.
301        out.push_str(&format!("({})", node.kind()));
302        if let Some(t) = text {
303            if !t.is_empty() && !t.starts_with(SENTINEL) {
304                *counter += 1;
305                let cap = format!("greplm_a{counter}");
306                out.push_str(&format!(" @{cap}"));
307                out.push_str(&format!(" (#eq? @{cap} \"{}\")", escape(&t)));
308                if t.len() >= 3 && t.chars().all(|c| !c.is_whitespace()) {
309                    anchors.push(t);
310                }
311            }
312        }
313        return;
314    }
315
316    if named.is_empty() {
317        // Has only anonymous children (e.g. empty `()` or `{}`): match any node
318        // of this kind without constraining its contents.
319        out.push_str(&format!("({})", node.kind()));
320        return;
321    }
322
323    out.push('(');
324    out.push_str(node.kind());
325    for child in named {
326        out.push(' ');
327        emit(child, src, out, anchors, counter);
328    }
329    out.push(')');
330}
331
332fn node_text(node: Node, src: &[u8]) -> Option<String> {
333    std::str::from_utf8(src.get(node.start_byte()..node.end_byte())?)
334        .ok()
335        .map(|s| s.to_string())
336}
337
338fn is_clean_ident(s: &str) -> bool {
339    !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
340}
341
342fn escape(s: &str) -> String {
343    s.replace('\\', "\\\\").replace('"', "\\\"")
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn raw_query_matches_calls() {
352        let src = b"fn main() {\n    foo();\n    bar();\n}\n";
353        let c = compile(
354            Language::Rust,
355            "(call_expression function: (identifier) @fn)",
356        )
357        .unwrap();
358        let m = run(Language::Rust, &c, src);
359        let names: Vec<&str> = m
360            .iter()
361            .flat_map(|mm| mm.captures.iter())
362            .map(|c| c.text.as_str())
363            .collect();
364        assert!(names.contains(&"foo"), "got {names:?}");
365        assert!(names.contains(&"bar"), "got {names:?}");
366    }
367
368    #[test]
369    fn predicate_filters_by_name() {
370        let src = b"fn main() {\n    foo();\n    bar();\n}\n";
371        let c = compile(
372            Language::Rust,
373            "((call_expression function: (identifier) @fn) (#eq? @fn \"foo\"))",
374        )
375        .unwrap();
376        let m = run(Language::Rust, &c, src);
377        let names: Vec<&str> = m
378            .iter()
379            .flat_map(|mm| mm.captures.iter())
380            .map(|c| c.text.as_str())
381            .collect();
382        assert_eq!(names, vec!["foo"], "predicate should keep only foo");
383    }
384
385    #[test]
386    fn metavar_pattern_compiles_and_matches() {
387        let src = b"struct A;\nfn alpha() {}\nfn beta() {}\n";
388        let c = compile(Language::Rust, "fn $NAME() {}").unwrap();
389        let m = run(Language::Rust, &c, src);
390        // Both functions should match the shape; capture NAME bound.
391        let names: Vec<String> = m
392            .iter()
393            .flat_map(|mm| mm.captures.iter())
394            .filter(|c| c.name == "NAME")
395            .map(|c| c.text.clone())
396            .collect();
397        assert!(names.contains(&"alpha".to_string()), "got {names:?}");
398        assert!(names.contains(&"beta".to_string()), "got {names:?}");
399    }
400
401    #[test]
402    fn variadic_metavars_match_any_params_and_body() {
403        let src = b"function noop() {}\nfunction add(a, b) { return a + b; }\n";
404        let c = compile(Language::JavaScript, "function $NAME($$$) { $$$ }").unwrap();
405        let m = run(Language::JavaScript, &c, src);
406        let names: Vec<String> = m
407            .iter()
408            .flat_map(|mm| mm.captures.iter())
409            .filter(|c| c.name == "NAME")
410            .map(|c| c.text.clone())
411            .collect();
412        assert!(names.contains(&"noop".to_string()), "got {names:?}");
413        assert!(
414            names.contains(&"add".to_string()),
415            "variadic params/body should match a function with args, got {names:?}"
416        );
417    }
418}