rust_relations_explorer/parser/
mod.rs

1use crate::errors::ParseError;
2use crate::graph::{FileMetrics, FileNode, Import, Item, ItemId, ItemType, Location, Visibility};
3use regex::Regex;
4use std::path::Path;
5use std::sync::Arc;
6
7#[derive(Debug, Default)]
8pub struct RustParser {
9    patterns: RegexPatterns,
10}
11
12#[derive(Debug)]
13pub struct RegexPatterns {
14    pub fn_sig: Regex,
15    pub struct_def: Regex,
16    pub enum_def: Regex,
17    pub vis_pub_in: Regex,
18    pub import_stmt: Regex,
19}
20
21impl RegexPatterns {
22    /// Compiles regex patterns used by the Rust parser.
23    ///
24    /// # Panics
25    /// Panics if any of the regular expressions fail to compile (should not happen in normal builds).
26    #[must_use]
27    pub fn compile() -> Self {
28        // Simple, conservative regexes to avoid catastrophic backtracking
29        let fn_sig = Regex::new(r"(?m)^\s*(?P<vis>pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:const\s+)?fn\s+(?P<name>[a-zA-Z_][a-zA-Z0-9_]*)\s*\(").unwrap();
30        let struct_def = Regex::new(
31            r"(?m)^\s*(?P<vis>pub(?:\([^)]*\))?\s+)?struct\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)",
32        )
33        .unwrap();
34        let enum_def = Regex::new(
35            r"(?m)^\s*(?P<vis>pub(?:\([^)]*\))?\s+)?enum\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)",
36        )
37        .unwrap();
38        let vis_pub_in = Regex::new(r"^pub\((?P<sc>[^)]+)\)$").unwrap();
39        let import_stmt = Regex::new(
40            r"(?m)^\s*(?:pub\s+)?use\s+([^;{]+?)(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?\s*;\s*$",
41        )
42        .unwrap();
43        Self { fn_sig, struct_def, enum_def, vis_pub_in, import_stmt }
44    }
45}
46
47impl Default for RegexPatterns {
48    fn default() -> Self {
49        Self::compile()
50    }
51}
52
53impl RustParser {
54    #[must_use]
55    pub fn new() -> Self {
56        Self { patterns: RegexPatterns::compile() }
57    }
58
59    /// Parse a Rust source file contents into a `FileNode` with items and imports.
60    ///
61    /// # Errors
62    /// Returns `ParseError` when the input cannot be parsed due to invalid UTF-8 or other parser failures.
63    pub fn parse_file(&self, content: &str, path: &Path) -> Result<FileNode, ParseError> {
64        let items = self.extract_items(content, path);
65        let imports = self.extract_imports(content);
66        let metrics = FileMetrics { item_count: items.len(), import_count: imports.len() };
67        Ok(FileNode { path: path.to_path_buf(), items, imports, metrics })
68    }
69
70    fn extract_items(&self, content: &str, path: &Path) -> Vec<Item> {
71        // Pre-size output using rough counts to reduce reallocations
72        let fn_count = self.patterns.fn_sig.captures_iter(content).count();
73        let struct_count = self.patterns.struct_def.captures_iter(content).count();
74        let enum_count = self.patterns.enum_def.captures_iter(content).count();
75        let mut out = Vec::with_capacity(fn_count + struct_count + enum_count);
76
77        for cap in self.patterns.fn_sig.captures_iter(content) {
78            let name = Arc::from(cap.name("name").map_or("", |m| m.as_str()));
79            let vis = cap.name("vis").map_or("", |m| m.as_str().trim());
80            let visibility = parse_visibility(&self.patterns.vis_pub_in, vis);
81            let m0 = cap.get(0).unwrap();
82            let line = line_number_for(content, m0.start());
83            let span = m0.as_str();
84            out.push(Item {
85                id: ItemId(format!("fn:{name}:{line}")),
86                item_type: ItemType::Function {
87                    is_async: span.contains("async "),
88                    is_const: span.contains("const "),
89                },
90                name,
91                visibility,
92                location: Location { file: path.to_path_buf(), line_start: line, line_end: line },
93                attributes: vec![],
94            });
95        }
96
97        for cap in self.patterns.struct_def.captures_iter(content) {
98            let name = Arc::from(cap.name("name").map_or("", |m| m.as_str()));
99            let vis = cap.name("vis").map_or("", |m| m.as_str().trim());
100            let visibility = parse_visibility(&self.patterns.vis_pub_in, vis);
101            let line = line_number_for(content, cap.get(0).map_or(0, |m| m.start()));
102            out.push(Item {
103                id: ItemId(format!("struct:{name}:{line}")),
104                item_type: ItemType::Struct { is_tuple: false },
105                name,
106                visibility,
107                location: Location { file: path.to_path_buf(), line_start: line, line_end: line },
108                attributes: vec![],
109            });
110        }
111
112        for cap in self.patterns.enum_def.captures_iter(content) {
113            let name = Arc::from(cap.name("name").map_or("", |m| m.as_str()));
114            let vis = cap.name("vis").map_or("", |m| m.as_str().trim());
115            let visibility = parse_visibility(&self.patterns.vis_pub_in, vis);
116            let line = line_number_for(content, cap.get(0).map_or(0, |m| m.start()));
117            out.push(Item {
118                id: ItemId(format!("enum:{name}:{line}")),
119                item_type: ItemType::Enum { variant_count: 0 },
120                name,
121                visibility,
122                location: Location { file: path.to_path_buf(), line_start: line, line_end: line },
123                attributes: vec![],
124            });
125        }
126
127        out
128    }
129
130    fn extract_imports(&self, content: &str) -> Vec<Import> {
131        let import_count = self.patterns.import_stmt.captures_iter(content).count();
132        let mut out = Vec::with_capacity(import_count);
133        for cap in self.patterns.import_stmt.captures_iter(content) {
134            let path = Arc::from(cap.get(1).map_or("", |m| m.as_str().trim()));
135            let alias = cap.get(2).map(|m| Arc::from(m.as_str()));
136            out.push(Import { path, alias });
137        }
138        out
139    }
140}
141
142fn parse_visibility(vis_pub_in: &Regex, vis: &str) -> Visibility {
143    let v = vis.trim();
144    if v.is_empty() {
145        return Visibility::Private;
146    }
147    if v == "pub" {
148        return Visibility::Public;
149    }
150    if v == "pub(crate)" {
151        return Visibility::PubCrate;
152    }
153    if v == "pub(super)" {
154        return Visibility::PubSuper;
155    }
156    if let Some(c) = vis_pub_in.captures(v) {
157        return Visibility::PubIn(Arc::from(c.name("sc").map_or("", |m| m.as_str())));
158    }
159    Visibility::Private
160}
161
162fn line_number_for(content: &str, byte_idx: usize) -> usize {
163    // 1-based line number
164    content[..byte_idx].bytes().filter(|&b| b == b'\n').count() + 1
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_extract_fn_struct_enum_and_visibility() {
173        let src = r#"
174        pub fn top() {}
175        fn hidden() {}
176        pub(crate) struct S;
177        pub(super) enum E { A, B }
178        "#;
179        let parser = RustParser::new();
180        let file = std::path::Path::new("/tmp/test.rs");
181        let node = parser.parse_file(src, file).expect("parse");
182        // items: 2 fn + 1 struct + 1 enum
183        assert_eq!(node.items.len(), 4);
184        // check visibility parsing
185        let mut names: Vec<(String, Visibility)> =
186            node.items.iter().map(|i| (i.name.to_string(), i.visibility.clone())).collect();
187        names.sort_by(|a, b| a.0.cmp(&b.0));
188        assert!(names.iter().any(|(n, v)| n == "top" && matches!(v, Visibility::Public)));
189        assert!(names.iter().any(|(n, v)| n == "hidden" && matches!(v, Visibility::Private)));
190        assert!(names.iter().any(|(n, v)| n == "S" && matches!(v, Visibility::PubCrate)));
191        assert!(names.iter().any(|(n, v)| n == "E" && matches!(v, Visibility::PubSuper)));
192    }
193
194    #[test]
195    fn test_extract_imports_with_alias() {
196        let src = r#"
197        use std::collections::HashMap;
198        pub use crate::module::Thing as Alias;
199        "#;
200        let parser = RustParser::new();
201        let node = parser.parse_file(src, std::path::Path::new("/x.rs")).unwrap();
202        assert_eq!(node.imports.len(), 2);
203        assert!(node
204            .imports
205            .iter()
206            .any(|im| im.path.contains("std::collections::HashMap") && im.alias.is_none()));
207        assert!(node
208            .imports
209            .iter()
210            .any(|im| im.path.contains("crate::module::Thing")
211                && im.alias.as_deref() == Some("Alias")));
212    }
213
214    #[test]
215    fn test_async_const_functions_and_tuple_struct() {
216        let src = r#"
217        pub async fn af() {}
218        pub const fn cf() -> i32 { 0 }
219        pub struct TS(u32, u32);
220        pub(self) fn scoped() {}
221        "#;
222        let parser = RustParser::new();
223        let node = parser.parse_file(src, std::path::Path::new("/y.rs")).unwrap();
224        let names: Vec<_> = node.items.iter().map(|i| i.name.as_ref()).collect();
225        assert!(names.contains(&"af"));
226        assert!(names.contains(&"cf"));
227        assert!(names.contains(&"TS"));
228        // Ensure vis pub(in ..) patterns are accepted and mapped
229        let scoped =
230            node.items.iter().find(|i| i.name.as_ref() == "scoped").expect("scoped present");
231        match scoped.visibility {
232            Visibility::PubIn(ref s) => assert_eq!(s.as_ref(), "self"),
233            _ => panic!("expected Visibility::PubIn('self') for scoped"),
234        }
235        // Sanity: counts align (2 fns + 1 tuple struct + 1 scoped fn)
236        assert_eq!(node.items.len(), 4);
237    }
238}