Skip to main content

rustbrain_core/ast/
parser.rs

1//! Tree-sitter Rust parser for top-level and nested items.
2
3use crate::ast::symbol::{compute_symbol_hash, AstError, SymbolAnchor};
4use tree_sitter::{Node, Parser, TreeCursor};
5
6/// Parses Rust source into [`SymbolAnchor`] records.
7pub struct CodeAstParser {
8    parser: Parser,
9}
10
11impl CodeAstParser {
12    /// Create a parser loaded with the tree-sitter Rust grammar.
13    pub fn new_rust() -> Result<Self, AstError> {
14        let mut parser = Parser::new();
15        let language = tree_sitter_rust::LANGUAGE.into();
16        parser
17            .set_language(&language)
18            .map_err(|e| AstError::Message(format!("failed to load tree-sitter rust: {e}")))?;
19        Ok(Self { parser })
20    }
21
22    /// Extract symbol anchors from Rust source.
23    ///
24    /// `module_path` should be a logical module path (e.g. `storage::db`), not an
25    /// absolute filesystem path. Callers may derive it from a relative file path.
26    ///
27    /// Impl methods are recorded as `Type::method` for stable, location-independent
28    /// identity within the module.
29    pub fn parse_symbols(
30        &mut self,
31        crate_name: &str,
32        file_path: &str,
33        source_code: &str,
34    ) -> Result<Vec<SymbolAnchor>, AstError> {
35        let tree = self
36            .parser
37            .parse(source_code, None)
38            .ok_or_else(|| AstError::Message("tree-sitter parse returned None".into()))?;
39
40        let module_path = module_path_from_file(file_path);
41        let mut anchors = Vec::new();
42        let root = tree.root_node();
43        let mut cursor = root.walk();
44        walk_items(
45            &mut cursor,
46            source_code.as_bytes(),
47            crate_name,
48            &module_path,
49            file_path,
50            None,
51            &mut anchors,
52        );
53        Ok(anchors)
54    }
55
56    /// Recursively scan a directory for `.rs` files.
57    pub fn scan_directory(
58        &mut self,
59        crate_name: &str,
60        dir_path: &std::path::Path,
61    ) -> Result<Vec<SymbolAnchor>, AstError> {
62        let mut all_anchors = Vec::new();
63        if !dir_path.exists() {
64            return Ok(all_anchors);
65        }
66
67        let entries =
68            std::fs::read_dir(dir_path).map_err(|e| AstError::Message(e.to_string()))?;
69        for entry in entries {
70            let entry = entry.map_err(|e| AstError::Message(e.to_string()))?;
71            let path = entry.path();
72
73            if path.is_dir() {
74                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
75                    if name == "target" || name.starts_with('.') {
76                        continue;
77                    }
78                }
79                all_anchors.extend(self.scan_directory(crate_name, &path)?);
80            } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
81                if let Ok(source) = std::fs::read_to_string(&path) {
82                    let rel_path = path.to_string_lossy().to_string();
83                    if let Ok(anchors) = self.parse_symbols(crate_name, &rel_path, &source) {
84                        all_anchors.extend(anchors);
85                    }
86                }
87            }
88        }
89        Ok(all_anchors)
90    }
91}
92
93/// Derive a logical module path from a repository-relative file path.
94fn module_path_from_file(file_path: &str) -> String {
95    let normalized = file_path.replace('\\', "/");
96    let mut parts: Vec<&str> = normalized.split('/').collect();
97
98    if let Some(idx) = parts.iter().position(|p| *p == "src") {
99        parts = parts[idx + 1..].to_vec();
100    }
101
102    if parts.is_empty() {
103        return "crate".to_string();
104    }
105
106    if let Some(last) = parts.last_mut() {
107        if let Some(stem) = last.strip_suffix(".rs") {
108            *last = stem;
109        }
110    }
111
112    match parts.last().copied() {
113        Some("mod") | Some("lib") | Some("main") => {
114            parts.pop();
115        }
116        _ => {}
117    }
118
119    if parts.is_empty() {
120        "crate".to_string()
121    } else {
122        parts.join("::")
123    }
124}
125
126fn walk_items(
127    cursor: &mut TreeCursor,
128    source: &[u8],
129    crate_name: &str,
130    module_path: &str,
131    file_path: &str,
132    impl_type: Option<&str>,
133    out: &mut Vec<SymbolAnchor>,
134) {
135    loop {
136        let node = cursor.node();
137        let next_impl = if node.kind() == "impl_item" {
138            extract_impl_type(&node, source)
139        } else {
140            None
141        };
142        let effective_impl = next_impl.as_deref().or(impl_type);
143
144        maybe_record_item(
145            &node,
146            source,
147            crate_name,
148            module_path,
149            file_path,
150            effective_impl,
151            out,
152        );
153
154        if cursor.goto_first_child() {
155            walk_items(
156                cursor,
157                source,
158                crate_name,
159                module_path,
160                file_path,
161                effective_impl,
162                out,
163            );
164            cursor.goto_parent();
165        }
166
167        if !cursor.goto_next_sibling() {
168            break;
169        }
170    }
171}
172
173fn extract_impl_type(node: &Node, source: &[u8]) -> Option<String> {
174    // Prefer type field; fall back to scanning children for type_identifier.
175    if let Some(t) = node.child_by_field_name("type") {
176        if let Ok(text) = t.utf8_text(source) {
177            let name = text
178                .split(['<', ' ', ':'])
179                .next()
180                .unwrap_or(text)
181                .trim();
182            if !name.is_empty() {
183                return Some(name.to_string());
184            }
185        }
186    }
187    let mut c = node.walk();
188    for child in node.children(&mut c) {
189        if child.kind() == "type_identifier" {
190            if let Ok(text) = child.utf8_text(source) {
191                return Some(text.to_string());
192            }
193        }
194    }
195    None
196}
197
198fn maybe_record_item(
199    node: &Node,
200    source: &[u8],
201    crate_name: &str,
202    module_path: &str,
203    file_path: &str,
204    impl_type: Option<&str>,
205    out: &mut Vec<SymbolAnchor>,
206) {
207    let kind = node.kind();
208    let interesting = matches!(
209        kind,
210        "function_item"
211            | "struct_item"
212            | "enum_item"
213            | "trait_item"
214            | "mod_item"
215            | "type_item"
216            | "const_item"
217            | "static_item"
218            | "function_signature_item"
219            | "union_item"
220            | "macro_definition"
221    );
222    if !interesting {
223        return;
224    }
225
226    let Some(name_node) = node.child_by_field_name("name") else {
227        return;
228    };
229    let Ok(raw_name) = name_node.utf8_text(source) else {
230        return;
231    };
232
233    // Qualify methods with their impl type: `StorageEngine::open`
234    let symbol_name = if kind == "function_item" || kind == "function_signature_item" {
235        if let Some(ty) = impl_type {
236            format!("{ty}::{raw_name}")
237        } else {
238            raw_name.to_string()
239        }
240    } else {
241        raw_name.to_string()
242    };
243
244    let start_line = node.start_position().row as u32 + 1;
245    let end_line = node.end_position().row as u32 + 1;
246    let symbol_hash = compute_symbol_hash(crate_name, module_path, &symbol_name);
247    let doc_comment = collect_doc_comment(node, source);
248
249    out.push(SymbolAnchor {
250        symbol_hash,
251        crate_name: crate_name.to_string(),
252        module_path: module_path.to_string(),
253        symbol_name,
254        file_path: file_path.to_string(),
255        start_line,
256        end_line,
257        doc_comment,
258    });
259}
260
261fn collect_doc_comment(node: &Node, source: &[u8]) -> Option<String> {
262    let mut prev = node.prev_sibling();
263    let mut stack: Vec<String> = Vec::new();
264    while let Some(p) = prev {
265        if p.kind() == "line_comment" || p.kind() == "block_comment" {
266            if let Ok(t) = p.utf8_text(source) {
267                let t = t.trim();
268                if t.starts_with("///") || t.starts_with("/**") || t.starts_with("//!") {
269                    stack.push(t.to_string());
270                } else {
271                    break;
272                }
273            }
274            prev = p.prev_sibling();
275        } else if p.kind() == "attribute_item" {
276            prev = p.prev_sibling();
277        } else {
278            break;
279        }
280    }
281    stack.reverse();
282    if stack.is_empty() {
283        None
284    } else {
285        Some(stack.join("\n"))
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn parses_impl_methods_qualified() {
295        let mut p = CodeAstParser::new_rust().unwrap();
296        let src = r#"
297            /// Engine
298            pub struct StorageEngine;
299            impl StorageEngine {
300                /// open it
301                pub fn open() {}
302            }
303            pub fn compact_log() {}
304        "#;
305        let anchors = p.parse_symbols("demo", "src/lib.rs", src).unwrap();
306        let names: Vec<_> = anchors.iter().map(|a| a.symbol_name.as_str()).collect();
307        assert!(names.contains(&"StorageEngine"));
308        assert!(
309            names.contains(&"StorageEngine::open"),
310            "expected qualified method, got {names:?}"
311        );
312        assert!(names.contains(&"compact_log"));
313        // Method hash differs from free function with same bare name
314        let method = anchors
315            .iter()
316            .find(|a| a.symbol_name == "StorageEngine::open")
317            .unwrap();
318        let free = compute_symbol_hash("demo", "crate", "open");
319        assert_ne!(method.symbol_hash, free);
320    }
321
322    #[test]
323    fn module_path_from_nested_file() {
324        assert_eq!(module_path_from_file("src/storage/db.rs"), "storage::db");
325        assert_eq!(module_path_from_file("src/lib.rs"), "crate");
326    }
327}