nusy_codegraph/
module_resolver.rs1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use crate::schema::CodeNode;
11
12pub struct RustModuleResolver {
14 module_to_file: HashMap<String, PathBuf>,
16 name_to_node_id: HashMap<String, String>,
18 crate_name: String,
20}
21
22impl RustModuleResolver {
23 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 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_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 pub fn index_nodes(&mut self, nodes: &[CodeNode]) {
57 for node in nodes {
58 if let Some(ref file_path) = node.file_path {
59 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 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 self.name_to_node_id
74 .entry(node.name.clone())
75 .or_insert_with(|| node.id.clone());
76 }
77 }
78 }
79
80 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; }
91
92 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 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 if let Some(node_id) = self.name_to_node_id.get(name.as_str()) {
108 resolved.push((name.clone(), node_id.clone()));
109 }
110 }
112
113 resolved
114 }
115
116 pub fn resolve_name(&self, name: &str) -> Option<String> {
120 self.name_to_node_id.get(name).cloned()
121 }
122
123 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 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 pub fn module_count(&self) -> usize {
148 self.module_to_file.len()
149 }
150
151 pub fn name_count(&self) -> usize {
153 self.name_to_node_id.len()
154 }
155}
156
157fn 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 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
186fn 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 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 }
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 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 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}