Skip to main content

mnem_ingest/
code.rs

1//! Source-code parser using tree-sitter.
2//!
3//! Converts source code into one [`Section`] per top-level callable or
4//! type definition (function, method, struct, class, enum, trait, impl
5//! block). The section body is the verbatim source span of the item, making
6//! it the natural unit for embedding: the whole function is the chunk.
7//!
8//! When no structured items are found (e.g. a header-only file or a
9//! file that the grammar does not handle), a single headless section
10//! covering the full source is returned so the pipeline always has
11//! something to embed.
12//!
13//! # Supported languages
14//!
15//! Rust, Python, JavaScript, TypeScript, Go, Java, C, C++.
16//! Languages are selected by [`CodeLanguage`]; the grammar is compiled
17//! from C source by each grammar crate's `build.rs`.
18
19use streaming_iterator::StreamingIterator;
20use tree_sitter::{Language, Parser, Query, QueryCursor};
21
22use crate::error::Error;
23use crate::types::{CodeLanguage, Section};
24
25/// Parse source code into structural sections.
26///
27/// Each top-level function / class / struct / impl becomes a [`Section`]
28/// whose `heading` is `"<kind>:<name>"` (e.g. `"fn:main"`, `"class:Foo"`)
29/// and whose `text` is the verbatim source span.
30///
31/// # Errors
32///
33/// Returns [`Error::ParseFailed`] if the grammar cannot be initialised or
34/// if the source is not valid UTF-8 (the caller should validate before
35/// passing). A source with syntax errors is still parsed - tree-sitter
36/// produces a best-effort tree for recovery.
37pub fn parse_code(source: &str, lang: CodeLanguage) -> Result<Vec<Section>, Error> {
38    let ts_lang = language_for(lang);
39
40    let mut parser = Parser::new();
41    if let Err(e) = parser.set_language(&ts_lang) {
42        // Grammar ABI mismatch (e.g. a grammar crate built for a newer tree-sitter
43        // ABI than the core we link against). Degrade gracefully to a whole-file
44        // section rather than failing the ingest entirely.
45        tracing::debug!(
46            lang = lang.as_str(),
47            error = %e,
48            "tree-sitter grammar ABI incompatible; falling back to full-file section"
49        );
50        return Ok(vec![Section {
51            heading: None,
52            depth: 0,
53            text: source.to_string(),
54            byte_range: 0..source.len(),
55        }]);
56    }
57
58    let tree = parser.parse(source, None).ok_or_else(|| Error::ParseFailed {
59        what: format!("code:{}", lang.as_str()),
60        detail: "tree-sitter parse returned None (input may be empty or cancelled)".into(),
61    })?;
62
63    let query_src = item_query(lang);
64    let query = match Query::new(&ts_lang, query_src) {
65        Ok(q) => q,
66        Err(e) => {
67            tracing::debug!(
68                lang = lang.as_str(),
69                error = %e,
70                "tree-sitter query compile failed; falling back to full-file section"
71            );
72            return Ok(vec![Section {
73                heading: None,
74                depth: 0,
75                text: source.to_string(),
76                byte_range: 0..source.len(),
77            }]);
78        }
79    };
80
81    let name_idx = query.capture_index_for_name("name");
82    let item_idx = query.capture_index_for_name("item");
83
84    let mut cursor = QueryCursor::new();
85    let raw = source.as_bytes();
86    let mut matches = cursor.matches(&query, tree.root_node(), raw);
87
88    let mut sections: Vec<Section> = Vec::new();
89
90    while let Some(m) = matches.next() {
91        let mut item_node = None;
92        let mut name_text: Option<String> = None;
93
94        for cap in m.captures {
95            if Some(cap.index) == item_idx {
96                item_node = Some(cap.node);
97            } else if Some(cap.index) == name_idx {
98                if let Ok(t) = cap.node.utf8_text(raw) {
99                    name_text = Some(t.to_string());
100                }
101            }
102        }
103
104        let (Some(node), Some(name)) = (item_node, name_text) else {
105            continue;
106        };
107
108        let start_byte = node.start_byte();
109        let end_byte = node.end_byte();
110        let body = source[start_byte..end_byte].to_string();
111
112        let kind = item_kind_label(node.kind());
113        let heading = format!("{kind}:{name}");
114
115        sections.push(Section {
116            heading: Some(heading),
117            depth: 1,
118            text: body,
119            byte_range: start_byte..end_byte,
120        });
121    }
122
123    if sections.is_empty() {
124        // Fallback: whole file as one headless section.
125        return Ok(vec![Section {
126            heading: None,
127            depth: 0,
128            text: source.to_string(),
129            byte_range: 0..source.len(),
130        }]);
131    }
132
133    Ok(sections)
134}
135
136// ─── Language selection ───────────────────────────────────────────────────────
137
138fn language_for(lang: CodeLanguage) -> Language {
139    match lang {
140        CodeLanguage::Rust => tree_sitter_rust::LANGUAGE.into(),
141        CodeLanguage::Python => tree_sitter_python::LANGUAGE.into(),
142        CodeLanguage::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
143        CodeLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
144        CodeLanguage::Go => tree_sitter_go::LANGUAGE.into(),
145        CodeLanguage::Java => tree_sitter_java::LANGUAGE.into(),
146        CodeLanguage::C => tree_sitter_c::LANGUAGE.into(),
147        CodeLanguage::Cpp => tree_sitter_cpp::LANGUAGE.into(),
148        CodeLanguage::Ruby => tree_sitter_ruby::LANGUAGE.into(),
149        CodeLanguage::CSharp => tree_sitter_c_sharp::LANGUAGE.into(),
150    }
151}
152
153// ─── Tree-sitter queries ──────────────────────────────────────────────────────
154
155/// Return the S-expression query that extracts named items for `lang`.
156///
157/// Each query must capture exactly two names:
158/// - `@item` - the whole node (for byte range extraction)
159/// - `@name` - the identifier node (for heading text)
160fn item_query(lang: CodeLanguage) -> &'static str {
161    match lang {
162        CodeLanguage::Rust => concat!(
163            "(function_item name: (identifier) @name) @item\n",
164            "(struct_item name: (type_identifier) @name) @item\n",
165            "(enum_item name: (type_identifier) @name) @item\n",
166            "(trait_item name: (type_identifier) @name) @item\n",
167        ),
168        CodeLanguage::Python => concat!(
169            "(function_definition name: (identifier) @name) @item\n",
170            "(class_definition name: (identifier) @name) @item\n",
171        ),
172        CodeLanguage::JavaScript => concat!(
173            "(function_declaration name: (identifier) @name) @item\n",
174            "(class_declaration name: (identifier) @name) @item\n",
175        ),
176        CodeLanguage::TypeScript => concat!(
177            "(function_declaration name: (identifier) @name) @item\n",
178            "(class_declaration name: (type_identifier) @name) @item\n",
179            "(interface_declaration name: (type_identifier) @name) @item\n",
180            "(type_alias_declaration name: (type_identifier) @name) @item\n",
181        ),
182        CodeLanguage::Go => concat!(
183            "(function_declaration name: (identifier) @name) @item\n",
184            "(method_declaration name: (field_identifier) @name) @item\n",
185            "(type_spec name: (type_identifier) @name) @item\n",
186        ),
187        CodeLanguage::Java => concat!(
188            "(method_declaration name: (identifier) @name) @item\n",
189            "(class_declaration name: (identifier) @name) @item\n",
190        ),
191        CodeLanguage::C | CodeLanguage::Cpp => concat!(
192            "(function_definition\n",
193            "  declarator: (function_declarator\n",
194            "    declarator: (identifier) @name)) @item\n",
195        ),
196        CodeLanguage::Ruby => concat!(
197            "(method name: (identifier) @name) @item\n",
198            "(singleton_method name: (identifier) @name) @item\n",
199            "(class name: (constant) @name) @item\n",
200            "(module name: (constant) @name) @item\n",
201        ),
202        CodeLanguage::CSharp => concat!(
203            "(method_declaration name: (identifier) @name) @item\n",
204            "(class_declaration name: (identifier) @name) @item\n",
205            "(interface_declaration name: (identifier) @name) @item\n",
206            "(struct_declaration name: (identifier) @name) @item\n",
207        ),
208    }
209}
210
211fn item_kind_label(kind: &str) -> &'static str {
212    match kind {
213        "function_item" | "function_definition" | "function_declaration" => "fn",
214        "method_declaration" | "method_definition" | "method" | "singleton_method" => "method",
215        "struct_item" | "struct_declaration" => "struct",
216        "enum_item" => "enum",
217        "trait_item" => "trait",
218        "class_definition" | "class_declaration" => "class",
219        "interface_declaration" => "interface",
220        "module" => "module",
221        "type_alias_declaration" | "type_spec" => "type",
222        _ => "item",
223    }
224}
225
226// ─── Tests ────────────────────────────────────────────────────────────────────
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn parse_rust_functions() {
234        let src = r#"
235fn add(a: i32, b: i32) -> i32 { a + b }
236
237struct Point { x: f32, y: f32 }
238
239fn main() {
240    println!("hello");
241}
242"#;
243        let sections = parse_code(src, CodeLanguage::Rust).unwrap();
244        // Should find: add, Point, main → at least 2
245        assert!(
246            sections.len() >= 2,
247            "expected at least 2 sections, got {} - {:#?}",
248            sections.len(),
249            sections
250        );
251        let headings: Vec<_> = sections.iter().filter_map(|s| s.heading.as_deref()).collect();
252        assert!(
253            headings.iter().any(|h| h.starts_with("fn:add")),
254            "missing fn:add in {headings:?}"
255        );
256        assert!(
257            headings.iter().any(|h| h.starts_with("fn:main")),
258            "missing fn:main in {headings:?}"
259        );
260    }
261
262    #[test]
263    fn parse_python_class_and_function() {
264        let src = r#"
265class Animal:
266    def speak(self):
267        pass
268
269def standalone():
270    return 42
271"#;
272        let sections = parse_code(src, CodeLanguage::Python).unwrap();
273        let headings: Vec<_> = sections.iter().filter_map(|s| s.heading.as_deref()).collect();
274        assert!(
275            headings.iter().any(|h| h.starts_with("class:Animal")),
276            "missing class:Animal in {headings:?}"
277        );
278        assert!(
279            headings.iter().any(|h| h.starts_with("fn:standalone")),
280            "missing fn:standalone in {headings:?}"
281        );
282    }
283
284    #[test]
285    fn fallback_for_empty_or_no_items() {
286        // A file with no top-level declarations.
287        let src = "// just a comment\n";
288        let sections = parse_code(src, CodeLanguage::Rust).unwrap();
289        assert_eq!(sections.len(), 1, "expected single fallback section");
290        assert!(sections[0].heading.is_none());
291    }
292
293    #[test]
294    fn section_byte_range_is_valid() {
295        let src = "fn foo() {}\nfn bar() {}\n";
296        let sections = parse_code(src, CodeLanguage::Rust).unwrap();
297        for s in &sections {
298            assert!(s.byte_range.end <= src.len());
299            assert!(s.byte_range.start <= s.byte_range.end);
300        }
301    }
302}