rustbrain_core/ast/
parser.rs1use crate::ast::symbol::{compute_symbol_hash, AstError, SymbolAnchor};
4use tree_sitter::{Node, Parser, TreeCursor};
5
6pub struct CodeAstParser {
8 parser: Parser,
9}
10
11impl CodeAstParser {
12 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 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 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
93fn 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 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 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 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}