Skip to main content

nusy_codegraph/
module_resolver.rs

1//! Rust module tree resolution for cross-file edge extraction.
2//!
3//! Builds a map from Rust module paths (`crate::graph::GraphStore`) to file paths
4//! and CodeNode IDs. Used by the ingest pipeline to resolve `use` statements and
5//! function calls into cross-file edges.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use crate::schema::CodeNode;
11
12/// Maps Rust module paths to file paths within a crate.
13pub struct RustModuleResolver {
14    /// Module path → file path (e.g., "crate::graph" → "src/graph.rs")
15    module_to_file: HashMap<String, PathBuf>,
16    /// Fully qualified name → CodeNode ID (e.g., "crate::graph::GraphStore" → "rust_struct:src/graph.rs::GraphStore")
17    name_to_node_id: HashMap<String, String>,
18    /// Crate name (e.g., "nusy_arrow_core")
19    crate_name: String,
20}
21
22impl RustModuleResolver {
23    /// Build a module resolver from a crate root directory.
24    ///
25    /// Walks `src/` to build the module tree from file structure.
26    /// The crate name is derived from Cargo.toml or the directory name.
27    pub fn from_crate(crate_root: &Path) -> Option<Self> {
28        let src_dir = crate_root.join("src");
29        if !src_dir.exists() {
30            return None;
31        }
32
33        let crate_name = detect_crate_name(crate_root);
34        let mut module_to_file = HashMap::new();
35
36        // Root module
37        if src_dir.join("lib.rs").exists() {
38            module_to_file.insert("crate".to_string(), src_dir.join("lib.rs"));
39        } else if src_dir.join("main.rs").exists() {
40            module_to_file.insert("crate".to_string(), src_dir.join("main.rs"));
41        }
42
43        // Walk src/ for module files
44        walk_modules(&src_dir, "crate", &mut module_to_file);
45
46        Some(Self {
47            module_to_file,
48            name_to_node_id: HashMap::new(),
49            crate_name,
50        })
51    }
52
53    /// Populate the name→ID index from parsed CodeNodes.
54    ///
55    /// Call this after parsing all files to enable cross-file name resolution.
56    pub fn index_nodes(&mut self, nodes: &[CodeNode]) {
57        for node in nodes {
58            if let Some(ref file_path) = node.file_path {
59                // Build qualified name: "crate::module::ItemName"
60                let module_path = self.file_to_module(Path::new(file_path));
61                if let Some(mod_path) = module_path {
62                    let qualified = format!("{}::{}", mod_path, node.name);
63                    self.name_to_node_id.insert(qualified, node.id.clone());
64                }
65                // Also index by crate name (e.g., "nusy_arrow_core::graph::GraphStore")
66                let module_path = self.file_to_module(Path::new(file_path));
67                if let Some(mod_path) = module_path {
68                    let external = mod_path.replacen("crate", &self.crate_name, 1);
69                    let qualified = format!("{}::{}", external, node.name);
70                    self.name_to_node_id.insert(qualified, node.id.clone());
71                }
72                // Direct name lookup (for same-module references)
73                self.name_to_node_id
74                    .entry(node.name.clone())
75                    .or_insert_with(|| node.id.clone());
76            }
77        }
78    }
79
80    /// Resolve a `use` path to a CodeNode ID.
81    ///
82    /// Handles `crate::`, `self::`, `super::`, and external crate paths.
83    /// Returns None if the target can't be resolved (e.g., external crate).
84    pub fn resolve_use(&self, module: &str, names: &[String]) -> Vec<(String, String)> {
85        let mut resolved = Vec::new();
86
87        for name in names {
88            if name == "*" {
89                continue; // Skip glob imports
90            }
91
92            // Try: module::name (e.g., "crate::graph::GraphStore")
93            let qualified = format!("{module}::{name}");
94            if let Some(node_id) = self.name_to_node_id.get(&qualified) {
95                resolved.push((name.clone(), node_id.clone()));
96                continue;
97            }
98
99            // Try with crate name substitution
100            let external = qualified.replacen("crate", &self.crate_name, 1);
101            if let Some(node_id) = self.name_to_node_id.get(&external) {
102                resolved.push((name.clone(), node_id.clone()));
103                continue;
104            }
105
106            // Try just the name (unqualified)
107            if let Some(node_id) = self.name_to_node_id.get(name.as_str()) {
108                resolved.push((name.clone(), node_id.clone()));
109            }
110            // Silently skip unresolvable imports (external crates, etc.)
111        }
112
113        resolved
114    }
115
116    /// Resolve a type name or function call to a CodeNode ID.
117    ///
118    /// Tries active imports first, then same-module lookup.
119    pub fn resolve_name(&self, name: &str) -> Option<String> {
120        self.name_to_node_id.get(name).cloned()
121    }
122
123    /// Get the module path for a file.
124    fn file_to_module(&self, file_path: &Path) -> Option<String> {
125        for (mod_path, path) in &self.module_to_file {
126            if path.ends_with(file_path) || file_path.ends_with(path) {
127                return Some(mod_path.clone());
128            }
129        }
130        // Try to derive from file path structure
131        let path_str = file_path.to_string_lossy();
132        if let Some(src_idx) = path_str.find("src/") {
133            let relative = &path_str[src_idx + 4..];
134            let module = relative
135                .trim_end_matches(".rs")
136                .trim_end_matches("/mod")
137                .replace('/', "::");
138            if module == "lib" || module == "main" {
139                return Some("crate".to_string());
140            }
141            return Some(format!("crate::{module}"));
142        }
143        None
144    }
145
146    /// Number of indexed module paths.
147    pub fn module_count(&self) -> usize {
148        self.module_to_file.len()
149    }
150
151    /// Number of indexed names.
152    pub fn name_count(&self) -> usize {
153        self.name_to_node_id.len()
154    }
155}
156
157/// Walk a directory tree to find Rust module files.
158fn walk_modules(dir: &Path, parent_module: &str, map: &mut HashMap<String, PathBuf>) {
159    let entries = match std::fs::read_dir(dir) {
160        Ok(e) => e,
161        Err(_) => return,
162    };
163
164    for entry in entries.flatten() {
165        let path = entry.path();
166        let name = entry.file_name().to_string_lossy().to_string();
167
168        if path.is_file() && name.ends_with(".rs") && name != "lib.rs" && name != "main.rs" {
169            let mod_name = name.trim_end_matches(".rs");
170            let module_path = format!("{parent_module}::{mod_name}");
171            map.insert(module_path.clone(), path.clone());
172
173            // Check for submodule directory
174            let subdir = dir.join(mod_name);
175            if subdir.is_dir() {
176                walk_modules(&subdir, &module_path, map);
177            }
178        } else if path.is_dir() && path.join("mod.rs").exists() {
179            let module_path = format!("{parent_module}::{name}");
180            map.insert(module_path.clone(), path.join("mod.rs"));
181            walk_modules(&path, &module_path, map);
182        }
183    }
184}
185
186/// Detect crate name from Cargo.toml or directory name.
187fn detect_crate_name(crate_root: &Path) -> String {
188    let cargo_toml = crate_root.join("Cargo.toml");
189    if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
190        for line in content.lines() {
191            if let Some(name) = line.strip_prefix("name") {
192                let name = name.trim().trim_start_matches('=').trim().trim_matches('"');
193                return name.replace('-', "_");
194            }
195        }
196    }
197    crate_root
198        .file_name()
199        .map(|n| n.to_string_lossy().replace('-', "_"))
200        .unwrap_or_else(|| "unknown".to_string())
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::schema::{CodeNode, CodeNodeKind};
207
208    #[test]
209    fn test_from_crate_finds_modules() {
210        // Use the nusy-codegraph crate itself as test data
211        let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
212        let resolver = RustModuleResolver::from_crate(&crate_root).expect("should resolve");
213
214        assert!(resolver.module_count() > 0);
215        // Should find at least: crate, crate::schema, crate::parser, crate::ingest, crate::rust_parser
216    }
217
218    #[test]
219    fn test_index_nodes_and_resolve() {
220        let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
221        let mut resolver = RustModuleResolver::from_crate(&crate_root).expect("should resolve");
222
223        let nodes = vec![CodeNode {
224            id: "rust_struct:src/schema.rs::CodeNode".to_string(),
225            kind: CodeNodeKind::RustStruct,
226            name: "CodeNode".to_string(),
227            file_path: Some("src/schema.rs".to_string()),
228            ..Default::default()
229        }];
230
231        resolver.index_nodes(&nodes);
232        assert!(resolver.name_count() > 0);
233
234        // Should resolve by name
235        let resolved = resolver.resolve_name("CodeNode");
236        assert!(resolved.is_some());
237        assert!(resolved.unwrap().contains("CodeNode"));
238    }
239
240    #[test]
241    fn test_resolve_use_crate_path() {
242        let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
243        let mut resolver = RustModuleResolver::from_crate(&crate_root).expect("should resolve");
244
245        let nodes = vec![CodeNode {
246            id: "rust_fn:src/ingest.rs::ingest_directory".to_string(),
247            kind: CodeNodeKind::RustFn,
248            name: "ingest_directory".to_string(),
249            file_path: Some("src/ingest.rs".to_string()),
250            ..Default::default()
251        }];
252
253        resolver.index_nodes(&nodes);
254
255        let resolved = resolver.resolve_use("crate::ingest", &["ingest_directory".to_string()]);
256        assert_eq!(resolved.len(), 1);
257        assert_eq!(resolved[0].0, "ingest_directory");
258    }
259
260    #[test]
261    fn test_resolve_external_crate_returns_empty() {
262        let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
263        let resolver = RustModuleResolver::from_crate(&crate_root).expect("should resolve");
264
265        // External crate references should silently return empty
266        let resolved = resolver.resolve_use("std::collections", &["HashMap".to_string()]);
267        assert!(resolved.is_empty());
268    }
269
270    #[test]
271    fn test_detect_crate_name() {
272        let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
273        let name = detect_crate_name(&crate_root);
274        assert_eq!(name, "nusy_codegraph");
275    }
276
277    #[test]
278    fn test_file_to_module() {
279        let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
280        let resolver = RustModuleResolver::from_crate(&crate_root).expect("should resolve");
281
282        let module = resolver.file_to_module(Path::new("src/schema.rs"));
283        assert_eq!(module, Some("crate::schema".to_string()));
284
285        let module = resolver.file_to_module(Path::new("src/lib.rs"));
286        assert_eq!(module, Some("crate".to_string()));
287    }
288
289    #[test]
290    fn test_glob_imports_skipped() {
291        let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
292        let resolver = RustModuleResolver::from_crate(&crate_root).expect("should resolve");
293
294        let resolved = resolver.resolve_use("crate::schema", &["*".to_string()]);
295        assert!(resolved.is_empty());
296    }
297}