Skip to main content

waggle_lens_code/
extract.rs

1//! Extraction (doc `20 §5.3`): one parse, one tags-query pass, an arena
2//! out — the tree drops before this module returns. Pure CPU: no clock,
3//! no I/O, no globals mutated beyond the once-per-process query cache.
4//!
5//! The `tags.scm` convention this consumes is the standard one shipped by
6//! the official grammars: a `@name` capture (the identifier) inside a
7//! pattern whose whole node carries `@definition.<kind>`. Reference
8//! captures (`@reference.*`) are skipped — the outline is definitions
9//! only (doc `20 §9`). Standard text predicates (`#eq?`, `#match?`, …)
10//! are evaluated by the tree-sitter binding itself via the text provider.
11
12use std::cell::RefCell;
13use std::sync::OnceLock;
14
15use streaming_iterator::StreamingIterator as _;
16use tree_sitter::{Language, Parser, Query, QueryCursor};
17
18use crate::outline::{Sym, SymbolOutline, ROOT};
19use crate::Lang;
20
21fn grammar(lang: Lang) -> Language {
22    match lang {
23        Lang::Rust => tree_sitter_rust::LANGUAGE.into(),
24        Lang::Python => tree_sitter_python::LANGUAGE.into(),
25        Lang::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
26        Lang::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
27        Lang::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
28        Lang::Go => tree_sitter_go::LANGUAGE.into(),
29    }
30}
31
32fn tags_source(lang: Lang) -> String {
33    match lang {
34        Lang::Rust => tree_sitter_rust::TAGS_QUERY.to_owned(),
35        Lang::Python => tree_sitter_python::TAGS_QUERY.to_owned(),
36        // The TypeScript tags query covers only TS-specific constructs
37        // (interfaces, signatures, abstract classes); plain classes and
38        // functions come from the JavaScript query it inherits — the TS
39        // grammar is a superset, so both compile against it.
40        Lang::TypeScript | Lang::Tsx => format!(
41            "{}\n{}",
42            tree_sitter_javascript::TAGS_QUERY,
43            tree_sitter_typescript::TAGS_QUERY
44        ),
45        Lang::JavaScript => tree_sitter_javascript::TAGS_QUERY.to_owned(),
46        Lang::Go => tree_sitter_go::TAGS_QUERY.to_owned(),
47    }
48}
49
50/// Compiled queries, once per process per language (doc `20 §4`): query
51/// compilation is the expensive setup; sharing it forever is the point.
52fn query(lang: Lang) -> Option<&'static Query> {
53    static QUERIES: [OnceLock<Option<Query>>; 6] = [const { OnceLock::new() }; 6];
54    let slot = match lang {
55        Lang::Rust => 0,
56        Lang::Python => 1,
57        Lang::TypeScript => 2,
58        Lang::Tsx => 3,
59        Lang::JavaScript => 4,
60        Lang::Go => 5,
61    };
62    QUERIES[slot]
63        .get_or_init(|| Query::new(&grammar(lang), &tags_source(lang)).ok())
64        .as_ref()
65}
66
67thread_local! {
68    /// One parser per thread, `set_language` per call (doc `20 §4`).
69    static PARSER: RefCell<Parser> = RefCell::new(Parser::new());
70}
71
72/// Extract the symbol outline from source text. Pure: same inputs, same
73/// outline, always. Returns an empty outline (callers store no blob) when
74/// the text does not parse at all or yields no definitions — degradation
75/// is to today's text loop, never below it.
76#[must_use]
77pub fn extract(text: &str, lang: Lang) -> SymbolOutline {
78    let Some(q) = query(lang) else {
79        return SymbolOutline::default();
80    };
81    PARSER.with(|p| {
82        let mut parser = p.borrow_mut();
83        if parser.set_language(&grammar(lang)).is_err() {
84            return SymbolOutline::default();
85        }
86        let Some(tree) = parser.parse(text, None) else {
87            return SymbolOutline::default();
88        };
89
90        // Capture indices for the @name / @definition.* convention.
91        let names = q.capture_names();
92        let mut raw: Vec<(usize, usize, u32, u32, &str, &str)> = Vec::new();
93        //           (start_byte, end_byte, start_line, end_line, name, kind)
94        let mut cursor = QueryCursor::new();
95        let mut matches = cursor.matches(q, tree.root_node(), text.as_bytes());
96        while let Some(m) = matches.next() {
97            let mut def = None;
98            let mut ident = None;
99            for cap in m.captures {
100                let cap_name = names[cap.index as usize];
101                if let Some(kind) = cap_name.strip_prefix("definition.") {
102                    def = Some((cap.node, kind));
103                } else if cap_name == "name" {
104                    ident = Some(cap.node);
105                }
106            }
107            let (Some((node, kind)), Some(ident)) = (def, ident) else {
108                continue; // reference.* matches and doc captures
109            };
110            let Ok(name) = ident.utf8_text(text.as_bytes()) else {
111                continue;
112            };
113            #[allow(clippy::cast_possible_truncation)] // files are <16 MB by the read cap
114            raw.push((
115                node.start_byte(),
116                node.end_byte(),
117                node.start_position().row as u32 + 1,
118                node.end_position().row as u32 + 1,
119                name,
120                kind,
121            ));
122        }
123        build_arena(raw)
124        // `tree` drops HERE — never retained (20 §4).
125    })
126}
127
128/// Assemble the flat arena: document order, parent links by containment
129/// (a stack of enclosing definitions), kinds deduplicated into a legend.
130fn build_arena(mut raw: Vec<(usize, usize, u32, u32, &str, &str)>) -> SymbolOutline {
131    // Document order; outermost first at equal starts so parents precede
132    // children on the containment stack.
133    raw.sort_by_key(|&(start, end, ..)| (start, usize::MAX - end));
134    raw.dedup_by_key(|&mut (start, end, _, _, name, _)| (start, end, name.to_owned()));
135
136    let mut out = SymbolOutline::default();
137    let mut stack: Vec<(usize, usize, u32)> = Vec::new(); // (start, end, arena idx)
138    for (start, end, start_line, end_line, name, kind) in raw {
139        while stack
140            .last()
141            .is_some_and(|&(s, e, _)| !(s <= start && end <= e))
142        {
143            stack.pop();
144        }
145        let parent = stack.last().map_or(ROOT, |&(_, _, i)| i);
146        let depth = u8::try_from(stack.len().min(u8::MAX as usize)).unwrap_or(u8::MAX);
147        let kind_id = out.kinds.iter().position(|k| k == kind).unwrap_or_else(|| {
148            out.kinds.push(kind.to_owned());
149            out.kinds.len() - 1
150        });
151        let name_off = u32::try_from(out.names.len()).unwrap_or(u32::MAX);
152        let name_len = u16::try_from(name.len().min(u16::MAX as usize)).unwrap_or(u16::MAX);
153        out.names.push_str(&name[..name_len as usize]);
154        let idx = u32::try_from(out.syms.len()).unwrap_or(u32::MAX);
155        out.syms.push(Sym {
156            name: (name_off, name_len),
157            kind: u8::try_from(kind_id.min(u8::MAX as usize)).unwrap_or(u8::MAX),
158            start_line,
159            end_line,
160            parent,
161            depth,
162        });
163        stack.push((start, end, idx));
164    }
165    out
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    const RUST_SRC: &str = r"
173/// A thing.
174pub struct Contract {
175    regions: Vec<u32>,
176}
177
178impl Contract {
179    /// Judge the bits.
180    pub fn evaluate(&self, bits: u8) -> bool {
181        bits != 0
182    }
183}
184
185fn helper() -> u8 {
186    7
187}
188";
189
190    #[test]
191    fn rust_definitions_with_nesting_and_ranges() {
192        let o = extract(RUST_SRC, Lang::Rust);
193        assert!(!o.is_empty());
194        let eval = o.find("evaluate");
195        assert_eq!(eval.len(), 1, "outline: {o:?}");
196        let (start, end, kind) = o.lines_of(eval[0]).unwrap();
197        assert!(start >= 8 && end > start, "def extent covers the body");
198        // The rust tags query marks methods distinctly from functions
199        // (impl blocks themselves are references, not definitions — so
200        // rust nesting is kind-based, not depth-based).
201        assert_eq!(kind, "method");
202        let helper = o.find("helper")[0];
203        assert_eq!(o.lines_of(helper).unwrap().2, "function");
204        assert!(!o.find("Contract").is_empty());
205    }
206
207    #[test]
208    fn python_nesting_is_containment_based() {
209        let py = extract(
210            "class A:\n    def m(self):\n        pass\n\ndef f():\n    pass\n",
211            Lang::Python,
212        );
213        let (m, f) = (py.find("m")[0], py.find("f")[0]);
214        assert!(py.syms[m].depth > py.syms[f].depth, "{py:?}");
215        assert_eq!(py.syms[m].parent, 0, "m's parent is class A in the arena");
216    }
217
218    #[test]
219    fn python_and_typescript_and_go_extract() {
220        let py = extract(
221            "class A:\n    def m(self):\n        pass\n\ndef f():\n    pass\n",
222            Lang::Python,
223        );
224        assert!(py.find("m").len() == 1 && py.find("f").len() == 1, "{py:?}");
225
226        let ts = extract(
227            "export class Store {\n  get(k: string): string { return k; }\n}\nfunction main() {}\n",
228            Lang::TypeScript,
229        );
230        assert!(!ts.find("main").is_empty(), "{ts:?}");
231
232        let go = extract(
233            "package p\n\nfunc Handle(x int) int {\n\treturn x\n}\n",
234            Lang::Go,
235        );
236        assert!(!go.find("Handle").is_empty(), "{go:?}");
237    }
238
239    #[test]
240    fn broken_code_still_yields_what_parses() {
241        // Error tolerance is the reason tree-sitter was chosen: mid-edit
242        // code keeps its outline for everything before the breakage.
243        let o = extract("fn good() {}\n\nfn broken( {{{\n", Lang::Rust);
244        assert!(!o.find("good").is_empty());
245    }
246
247    #[test]
248    fn extraction_is_deterministic() {
249        let a = extract(RUST_SRC, Lang::Rust).to_wire();
250        let b = extract(RUST_SRC, Lang::Rust).to_wire();
251        assert_eq!(a, b, "same bytes, same outline — CAS dedupes for free");
252    }
253}