1pub mod ast_extract;
11pub mod dedup;
12pub mod lang_config;
13pub mod parser;
14pub mod semantic;
15pub mod treesitter;
16
17use std::collections::{HashMap, HashSet};
18use std::path::{Path, PathBuf};
19
20use graphify_core::confidence::Confidence;
21use graphify_core::model::{ExtractionResult, GraphEdge, NodeType};
22use rayon::prelude::*;
23use tracing::{debug, info, warn};
24
25pub const DISPATCH: &[(&str, &str)] = &[
31 (".py", "python"),
32 (".js", "javascript"),
33 (".jsx", "javascript"),
34 (".ts", "typescript"),
35 (".tsx", "typescript"),
36 (".go", "go"),
37 (".rs", "rust"),
38 (".java", "java"),
39 (".c", "c"),
40 (".h", "c"),
41 (".cpp", "cpp"),
42 (".cc", "cpp"),
43 (".cxx", "cpp"),
44 (".hpp", "cpp"),
45 (".rb", "ruby"),
46 (".cs", "csharp"),
47 (".kt", "kotlin"),
48 (".kts", "kotlin"),
49 (".scala", "scala"),
50 (".php", "php"),
51 (".swift", "swift"),
52 (".lua", "lua"),
53 (".toc", "lua"),
54 (".zig", "zig"),
55 (".ps1", "powershell"),
56 (".ex", "elixir"),
57 (".exs", "elixir"),
58 (".m", "objc"),
59 (".mm", "objc"),
60 (".jl", "julia"),
61 (".dart", "dart"),
62];
63
64fn dispatch_map() -> HashMap<&'static str, &'static str> {
66 DISPATCH.iter().copied().collect()
67}
68
69pub fn language_for_path(path: &Path) -> Option<&'static str> {
71 let ext = path.extension()?.to_str()?;
72 let dotted = format!(".{ext}");
73 dispatch_map().get(dotted.as_str()).copied()
74}
75
76pub fn collect_files(target: &Path) -> Vec<PathBuf> {
82 let map = dispatch_map();
83 let mut files = Vec::new();
84 collect_files_inner(target, &map, &mut files);
85 files.sort();
86 files
87}
88
89fn collect_files_inner(dir: &Path, map: &HashMap<&str, &str>, out: &mut Vec<PathBuf>) {
90 let entries = match std::fs::read_dir(dir) {
91 Ok(e) => e,
92 Err(e) => {
93 warn!("cannot read directory {}: {e}", dir.display());
94 return;
95 }
96 };
97 for entry in entries.flatten() {
98 let path = entry.path();
99 if path.is_dir() {
100 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
102 if name.starts_with('.')
103 || name == "node_modules"
104 || name == "__pycache__"
105 || name == "target"
106 || name == "vendor"
107 || name == "venv"
108 || name == ".git"
109 {
110 continue;
111 }
112 collect_files_inner(&path, map, out);
113 } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
114 let dotted = format!(".{ext}");
115 if map.contains_key(dotted.as_str()) {
116 out.push(path);
117 }
118 }
119 }
120}
121
122pub fn extract(paths: &[PathBuf]) -> ExtractionResult {
134 let results: Vec<ExtractionResult> = paths
135 .par_iter()
136 .filter_map(|path| {
137 let lang = match language_for_path(path) {
138 Some(l) => l,
139 None => {
140 debug!("skipping unsupported file: {}", path.display());
141 return None;
142 }
143 };
144
145 let source = match std::fs::read(path) {
146 Ok(s) => s,
147 Err(e) => {
148 warn!("cannot read {}: {e}", path.display());
149 return None;
150 }
151 };
152
153 debug!("extracting {} ({})", path.display(), lang);
154
155 let mut result = if let Some(ts_result) = treesitter::try_extract(path, &source, lang) {
157 debug!("used tree-sitter for {} ({})", path.display(), lang);
158 ts_result
159 } else {
160 let source_str = String::from_utf8_lossy(&source);
161 ast_extract::extract_file(path, source_str.as_ref(), lang)
162 };
163 dedup::dedup_file(&mut result);
164
165 Some(result)
166 })
167 .collect();
168
169 let mut combined = ExtractionResult::default();
170 for r in results {
171 combined.nodes.extend(r.nodes);
172 combined.edges.extend(r.edges);
173 combined.hyperedges.extend(r.hyperedges);
174 }
175
176 resolve_python_imports(&mut combined);
178
179 resolve_cross_file_imports(&mut combined);
181
182 info!(
183 "extraction complete: {} nodes, {} edges",
184 combined.nodes.len(),
185 combined.edges.len()
186 );
187
188 combined
189}
190
191fn resolve_python_imports(result: &mut ExtractionResult) {
196 let label_to_id: HashMap<String, String> = result
198 .nodes
199 .iter()
200 .map(|n| (n.label.clone(), n.id.clone()))
201 .collect();
202
203 let mut stem_to_entity_ids: HashMap<String, Vec<String>> = HashMap::new();
205 let defined_targets: HashSet<String> = result
206 .edges
207 .iter()
208 .filter(|e| e.relation == "defines")
209 .map(|e| e.target.clone())
210 .collect();
211 for node in &result.nodes {
212 if !defined_targets.contains(&node.id) {
213 continue;
214 }
215 let stem = std::path::Path::new(&node.source_file)
216 .file_stem()
217 .and_then(|s| s.to_str())
218 .unwrap_or("")
219 .to_string();
220 stem_to_entity_ids
221 .entry(stem)
222 .or_default()
223 .push(node.id.clone());
224 }
225
226 let mut star_expansions: Vec<GraphEdge> = Vec::new();
228
229 for edge in &mut result.edges {
231 if edge.relation == "imports" {
232 let import_label = result
234 .nodes
235 .iter()
236 .find(|n| n.id == edge.target)
237 .map(|n| n.label.as_str())
238 .unwrap_or("");
239
240 if import_label.contains('*') {
241 let module_name = import_label.trim_end_matches(".*").trim_end_matches(" *");
243 if let Some(entity_ids) = stem_to_entity_ids.get(module_name) {
244 for target_id in entity_ids {
245 star_expansions.push(GraphEdge {
246 source: edge.source.clone(),
247 target: target_id.clone(),
248 relation: "uses".to_string(),
249 confidence: Confidence::Inferred,
250 confidence_score: 0.7,
251 source_file: edge.source_file.clone(),
252 source_location: None,
253 weight: 0.7,
254 extra: Default::default(),
255 });
256 }
257 }
258 } else {
259 if let Some(resolved_id) = label_to_id.get(&edge.target) {
261 edge.target = resolved_id.clone();
262 edge.confidence = graphify_core::confidence::Confidence::Extracted;
263 }
264 }
265 }
266 }
267
268 if !star_expansions.is_empty() {
269 debug!(
270 "python star import expansion: created {} uses edges",
271 star_expansions.len()
272 );
273 result.edges.extend(star_expansions);
274 }
275}
276
277fn resolve_cross_file_imports(result: &mut ExtractionResult) {
284 let mut id_to_label: HashMap<String, String> = HashMap::new();
289 let mut stem_to_entities: HashMap<String, Vec<(String, String, NodeType)>> = HashMap::new();
290 let mut go_pkg_to_entities: HashMap<String, Vec<(String, String, NodeType)>> = HashMap::new();
291 let mut source_file_to_stem: HashMap<String, String> = HashMap::new();
292 let mut file_id_to_source: HashMap<String, String> = HashMap::new();
293
294 let defined_entity_ids: HashSet<String> = result
296 .edges
297 .iter()
298 .filter(|e| e.relation == "defines")
299 .map(|e| e.target.clone())
300 .collect();
301
302 let mut source_file_entities: HashMap<String, Vec<String>> = HashMap::new();
304 for edge in &result.edges {
305 if edge.relation == "defines" {
306 source_file_entities
307 .entry(edge.source_file.clone())
308 .or_default()
309 .push(edge.target.clone());
310 }
311 }
312
313 for node in &result.nodes {
315 id_to_label.insert(node.id.clone(), node.label.clone());
316
317 if node.node_type == NodeType::File {
318 let stem = Path::new(&node.source_file)
319 .file_stem()
320 .and_then(|s| s.to_str())
321 .unwrap_or("")
322 .to_string();
323 source_file_to_stem.insert(node.source_file.clone(), stem);
324 file_id_to_source.insert(node.id.clone(), node.source_file.clone());
325 continue;
326 }
327
328 if !defined_entity_ids.contains(&node.id) {
329 continue;
330 }
331
332 let path = Path::new(&node.source_file);
333 let stem = path
334 .file_stem()
335 .and_then(|s| s.to_str())
336 .unwrap_or("")
337 .to_string();
338
339 stem_to_entities.entry(stem).or_default().push((
340 node.label.clone(),
341 node.id.clone(),
342 node.node_type.clone(),
343 ));
344
345 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
347 if ext == "go"
348 && let Some(dir) = path
349 .parent()
350 .and_then(|d| d.file_name())
351 .and_then(|d| d.to_str())
352 {
353 go_pkg_to_entities
354 .entry(dir.to_string())
355 .or_default()
356 .push((node.label.clone(), node.id.clone(), node.node_type.clone()));
357 }
358 }
359
360 let mut new_edges: Vec<GraphEdge> = Vec::new();
362 let mut seen = HashSet::new();
363
364 for edge in &result.edges {
365 if edge.relation != "imports" {
366 continue;
367 }
368
369 let source_file = &edge.source_file;
370 let ext = Path::new(source_file)
371 .extension()
372 .and_then(|e| e.to_str())
373 .unwrap_or("");
374
375 let import_label = match id_to_label.get(&edge.target) {
377 Some(label) => label.as_str(),
378 None => continue,
379 };
380
381 if import_label.is_empty() {
382 continue;
383 }
384
385 let target_entities = match ext {
386 "js" | "jsx" | "ts" | "tsx" => resolve_jsts_import(import_label, &stem_to_entities),
387 "go" => resolve_go_import(import_label, &stem_to_entities, &go_pkg_to_entities),
388 "rs" => resolve_rust_import(import_label, &stem_to_entities),
389 "java" => resolve_dot_import(import_label, &stem_to_entities),
390 "cs" => resolve_dot_import(import_label, &stem_to_entities),
391 "c" | "h" | "cpp" | "cc" | "cxx" | "hpp" => {
392 resolve_c_include(import_label, &stem_to_entities)
393 }
394 "kt" | "kts" => {
395 let cleaned = import_label.strip_prefix("import ").unwrap_or(import_label);
396 resolve_dot_import(cleaned.trim(), &stem_to_entities)
397 }
398 "php" => {
399 let cleaned = import_label.strip_prefix("use ").unwrap_or(import_label);
400 resolve_backslash_import(cleaned.trim(), &stem_to_entities)
401 }
402 "dart" => resolve_dart_import(import_label, &stem_to_entities),
403 "scala" => {
404 let cleaned = import_label.strip_prefix("import ").unwrap_or(import_label);
405 resolve_dot_import(cleaned.trim(), &stem_to_entities)
406 }
407 "swift" => {
408 let cleaned = import_label.strip_prefix("import ").unwrap_or(import_label);
409 resolve_dot_import(cleaned.trim(), &stem_to_entities)
410 }
411 _ => continue,
412 };
413
414 if target_entities.is_empty() {
415 continue;
416 }
417
418 let local_entities = match source_file_entities.get(source_file) {
420 Some(ids) => ids,
421 None => continue,
422 };
423
424 for local_id in local_entities {
426 for (_, target_id, _) in &target_entities {
427 if local_id == target_id {
428 continue;
429 }
430 let key = (local_id.clone(), target_id.clone());
431 if seen.contains(&key) {
432 continue;
433 }
434 seen.insert(key);
435 new_edges.push(GraphEdge {
436 source: local_id.clone(),
437 target: target_id.clone(),
438 relation: "uses".to_string(),
439 confidence: Confidence::Inferred,
440 confidence_score: 0.8,
441 source_file: source_file.clone(),
442 source_location: None,
443 weight: 0.8,
444 extra: Default::default(),
445 });
446 }
447 }
448 }
449
450 if !new_edges.is_empty() {
451 debug!(
452 "cross-file import resolution: created {} inferred uses edges",
453 new_edges.len()
454 );
455 }
456
457 result.edges.extend(new_edges);
458}
459
460fn resolve_jsts_import<'a>(
470 import_label: &str,
471 stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
472) -> Vec<&'a (String, String, NodeType)> {
473 let parts: Vec<&str> = import_label.split('/').collect();
476
477 if parts.len() >= 2 {
479 let module_stem = parts[0].trim_start_matches('.');
480 if let Some(entities) = stem_to_entities.get(module_stem) {
481 return entities.iter().collect();
482 }
483 }
484
485 if let Some(last) = parts.last() {
487 let stem = last.trim_start_matches('.');
488 if let Some(entities) = stem_to_entities.get(stem) {
489 return entities.iter().collect();
490 }
491 }
492
493 let simple = import_label
495 .trim_start_matches("./")
496 .trim_start_matches("../");
497 if let Some(entities) = stem_to_entities.get(simple) {
498 return entities.iter().collect();
499 }
500
501 if let Some(entities) = stem_to_entities.get("index") {
504 if import_label.contains('/') || import_label.starts_with('.') {
506 return entities.iter().collect();
507 }
508 }
509
510 Vec::new()
511}
512
513fn resolve_go_import<'a>(
519 import_label: &str,
520 stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
521 go_pkg_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
522) -> Vec<&'a (String, String, NodeType)> {
523 let label = import_label.trim_start_matches(". ");
525
526 let pkg_name = label.rsplit('/').next().unwrap_or(label);
528
529 if let Some(entities) = go_pkg_to_entities.get(pkg_name) {
531 return entities.iter().collect();
532 }
533
534 if let Some(entities) = stem_to_entities.get(pkg_name) {
536 return entities.iter().collect();
537 }
538
539 Vec::new()
540}
541
542fn resolve_rust_import<'a>(
548 import_label: &str,
549 stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
550) -> Vec<&'a (String, String, NodeType)> {
551 let label = import_label
553 .strip_prefix("pub use ")
554 .unwrap_or(import_label);
555 let segments: Vec<&str> = label.split("::").collect();
556
557 if let Some(last) = segments.last()
559 && let Some(entities) = stem_to_entities.get(*last)
560 {
561 return entities.iter().collect();
562 }
563
564 if segments.len() >= 2 {
566 let module = segments[segments.len() - 2];
567 if let Some(entities) = stem_to_entities.get(module) {
568 let last = segments.last().unwrap();
570 let filtered: Vec<_> = entities
571 .iter()
572 .filter(|(label, _, _)| label == last)
573 .collect();
574 if !filtered.is_empty() {
575 return filtered;
576 }
577 return entities.iter().collect();
579 }
580 }
581
582 Vec::new()
583}
584
585fn resolve_dot_import<'a>(
590 import_label: &str,
591 stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
592) -> Vec<&'a (String, String, NodeType)> {
593 let segments: Vec<&str> = import_label.split('.').collect();
594
595 if let Some(last) = segments.last()
597 && let Some(entities) = stem_to_entities.get(*last)
598 {
599 return entities.iter().collect();
600 }
601
602 if segments.len() >= 2 {
604 let module = segments[segments.len() - 2];
605 if let Some(entities) = stem_to_entities.get(module) {
606 let last = segments.last().unwrap();
607 let filtered: Vec<_> = entities
608 .iter()
609 .filter(|(label, _, _)| label == last)
610 .collect();
611 if !filtered.is_empty() {
612 return filtered;
613 }
614 return entities.iter().collect();
615 }
616 }
617
618 Vec::new()
619}
620
621fn resolve_c_include<'a>(
626 import_label: &str,
627 stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
628) -> Vec<&'a (String, String, NodeType)> {
629 let label = import_label
631 .trim_start_matches('<')
632 .trim_end_matches('>')
633 .trim_start_matches('"')
634 .trim_end_matches('"');
635
636 let stem = std::path::Path::new(label)
638 .file_stem()
639 .and_then(|s| s.to_str())
640 .unwrap_or(label);
641
642 if let Some(entities) = stem_to_entities.get(stem) {
643 return entities.iter().collect();
644 }
645
646 Vec::new()
647}
648
649fn resolve_backslash_import<'a>(
653 import_label: &str,
654 stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
655) -> Vec<&'a (String, String, NodeType)> {
656 let segments: Vec<&str> = import_label.split('\\').collect();
657
658 if let Some(last) = segments.last()
660 && let Some(entities) = stem_to_entities.get(*last)
661 {
662 return entities.iter().collect();
663 }
664
665 if segments.len() >= 2 {
667 let module = segments[segments.len() - 2];
668 if let Some(entities) = stem_to_entities.get(module) {
669 return entities.iter().collect();
670 }
671 }
672
673 Vec::new()
674}
675
676fn resolve_dart_import<'a>(
681 import_label: &str,
682 stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
683) -> Vec<&'a (String, String, NodeType)> {
684 let label = import_label
686 .strip_prefix("import ")
687 .unwrap_or(import_label)
688 .trim_matches('\'')
689 .trim_matches('"');
690
691 let path_part = label.strip_prefix("package:").unwrap_or(label);
693 let last_segment = path_part.rsplit('/').next().unwrap_or(path_part);
694
695 let stem = last_segment.strip_suffix(".dart").unwrap_or(last_segment);
697
698 if let Some(entities) = stem_to_entities.get(stem) {
699 return entities.iter().collect();
700 }
701
702 Vec::new()
703}
704
705#[cfg(test)]
710mod tests {
711 use super::*;
712 use graphify_core::model::{GraphEdge, GraphNode};
713
714 #[test]
715 fn dispatch_table_covers_all_languages() {
716 let map = dispatch_map();
717 assert_eq!(map.get(".py"), Some(&"python"));
718 assert_eq!(map.get(".rs"), Some(&"rust"));
719 assert_eq!(map.get(".go"), Some(&"go"));
720 assert_eq!(map.get(".tsx"), Some(&"typescript"));
721 assert_eq!(map.get(".jl"), Some(&"julia"));
722 assert_eq!(map.get(".mm"), Some(&"objc"));
723 }
724
725 fn make_test_node(id: &str, label: &str, source_file: &str, node_type: NodeType) -> GraphNode {
730 GraphNode {
731 id: id.to_string(),
732 label: label.to_string(),
733 source_file: source_file.to_string(),
734 source_location: None,
735 node_type,
736 community: None,
737 extra: Default::default(),
738 }
739 }
740
741 fn make_test_edge(source: &str, target: &str, relation: &str, source_file: &str) -> GraphEdge {
742 GraphEdge {
743 source: source.to_string(),
744 target: target.to_string(),
745 relation: relation.to_string(),
746 confidence: Confidence::Extracted,
747 confidence_score: 1.0,
748 source_file: source_file.to_string(),
749 source_location: None,
750 weight: 1.0,
751 extra: Default::default(),
752 }
753 }
754
755 #[test]
760 fn jsts_cross_file_creates_uses_edges() {
761 let mut result = ExtractionResult {
764 nodes: vec![
765 make_test_node("file_app", "app", "src/app.ts", NodeType::File),
766 make_test_node("app_ctrl", "AppController", "src/app.ts", NodeType::Class),
767 make_test_node(
768 "import_utils",
769 "utils/parseDate",
770 "src/app.ts",
771 NodeType::Module,
772 ),
773 make_test_node("file_utils", "utils", "src/utils.ts", NodeType::File),
774 make_test_node(
775 "parse_date",
776 "parseDate",
777 "src/utils.ts",
778 NodeType::Function,
779 ),
780 make_test_node(
781 "format_date",
782 "formatDate",
783 "src/utils.ts",
784 NodeType::Function,
785 ),
786 ],
787 edges: vec![
788 make_test_edge("file_app", "app_ctrl", "defines", "src/app.ts"),
789 make_test_edge("file_app", "import_utils", "imports", "src/app.ts"),
790 make_test_edge("file_utils", "parse_date", "defines", "src/utils.ts"),
791 make_test_edge("file_utils", "format_date", "defines", "src/utils.ts"),
792 ],
793 hyperedges: vec![],
794 };
795
796 resolve_cross_file_imports(&mut result);
797
798 let uses_edges: Vec<_> = result
799 .edges
800 .iter()
801 .filter(|e| e.relation == "uses")
802 .collect();
803
804 assert_eq!(
806 uses_edges.len(),
807 2,
808 "expected 2 uses edges, got {}",
809 uses_edges.len()
810 );
811 assert!(
812 uses_edges
813 .iter()
814 .any(|e| e.source == "app_ctrl" && e.target == "parse_date")
815 );
816 assert!(
817 uses_edges
818 .iter()
819 .any(|e| e.source == "app_ctrl" && e.target == "format_date")
820 );
821
822 for edge in &uses_edges {
824 assert_eq!(edge.confidence, Confidence::Inferred);
825 assert!((edge.weight - 0.8).abs() < f64::EPSILON);
826 assert!((edge.confidence_score - 0.8).abs() < f64::EPSILON);
827 }
828 }
829
830 #[test]
835 fn go_cross_file_creates_uses_edges() {
836 let mut result = ExtractionResult {
839 nodes: vec![
840 make_test_node("file_main", "main", "cmd/main.go", NodeType::File),
841 make_test_node("server", "Server", "cmd/main.go", NodeType::Struct),
842 make_test_node(
843 "import_utils",
844 "myproject/pkg/utils",
845 "cmd/main.go",
846 NodeType::Package,
847 ),
848 make_test_node(
849 "file_helpers",
850 "helpers",
851 "pkg/utils/helpers.go",
852 NodeType::File,
853 ),
854 make_test_node(
855 "parse_config",
856 "ParseConfig",
857 "pkg/utils/helpers.go",
858 NodeType::Function,
859 ),
860 make_test_node(
861 "validate",
862 "Validate",
863 "pkg/utils/helpers.go",
864 NodeType::Function,
865 ),
866 ],
867 edges: vec![
868 make_test_edge("file_main", "server", "defines", "cmd/main.go"),
869 make_test_edge("file_main", "import_utils", "imports", "cmd/main.go"),
870 make_test_edge(
871 "file_helpers",
872 "parse_config",
873 "defines",
874 "pkg/utils/helpers.go",
875 ),
876 make_test_edge(
877 "file_helpers",
878 "validate",
879 "defines",
880 "pkg/utils/helpers.go",
881 ),
882 ],
883 hyperedges: vec![],
884 };
885
886 resolve_cross_file_imports(&mut result);
887
888 let uses_edges: Vec<_> = result
889 .edges
890 .iter()
891 .filter(|e| e.relation == "uses")
892 .collect();
893
894 assert_eq!(
896 uses_edges.len(),
897 2,
898 "expected 2 uses edges, got {}",
899 uses_edges.len()
900 );
901 assert!(
902 uses_edges
903 .iter()
904 .any(|e| e.source == "server" && e.target == "parse_config")
905 );
906 assert!(
907 uses_edges
908 .iter()
909 .any(|e| e.source == "server" && e.target == "validate")
910 );
911
912 for edge in &uses_edges {
913 assert_eq!(edge.confidence, Confidence::Inferred);
914 }
915 }
916
917 #[test]
922 fn rust_cross_file_creates_uses_edges() {
923 let mut result = ExtractionResult {
926 nodes: vec![
927 make_test_node("file_main", "main", "src/main.rs", NodeType::File),
928 make_test_node("app", "App", "src/main.rs", NodeType::Struct),
929 make_test_node(
930 "import_model",
931 "crate::model",
932 "src/main.rs",
933 NodeType::Module,
934 ),
935 make_test_node("file_model", "model", "src/model.rs", NodeType::File),
936 make_test_node("config", "Config", "src/model.rs", NodeType::Struct),
937 make_test_node("database", "Database", "src/model.rs", NodeType::Struct),
938 ],
939 edges: vec![
940 make_test_edge("file_main", "app", "defines", "src/main.rs"),
941 make_test_edge("file_main", "import_model", "imports", "src/main.rs"),
942 make_test_edge("file_model", "config", "defines", "src/model.rs"),
943 make_test_edge("file_model", "database", "defines", "src/model.rs"),
944 ],
945 hyperedges: vec![],
946 };
947
948 resolve_cross_file_imports(&mut result);
949
950 let uses_edges: Vec<_> = result
951 .edges
952 .iter()
953 .filter(|e| e.relation == "uses")
954 .collect();
955
956 assert_eq!(
958 uses_edges.len(),
959 2,
960 "expected 2 uses edges, got {}",
961 uses_edges.len()
962 );
963 assert!(
964 uses_edges
965 .iter()
966 .any(|e| e.source == "app" && e.target == "config")
967 );
968 assert!(
969 uses_edges
970 .iter()
971 .any(|e| e.source == "app" && e.target == "database")
972 );
973
974 for edge in &uses_edges {
975 assert_eq!(edge.confidence, Confidence::Inferred);
976 assert!((edge.weight - 0.8).abs() < f64::EPSILON);
977 }
978 }
979
980 #[test]
981 fn rust_cross_file_resolves_specific_type() {
982 let mut result = ExtractionResult {
984 nodes: vec![
985 make_test_node("file_main", "main", "src/main.rs", NodeType::File),
986 make_test_node("app", "App", "src/main.rs", NodeType::Struct),
987 make_test_node(
988 "import_config",
989 "crate::model::Config",
990 "src/main.rs",
991 NodeType::Module,
992 ),
993 make_test_node("file_model", "model", "src/model.rs", NodeType::File),
994 make_test_node("config", "Config", "src/model.rs", NodeType::Struct),
995 make_test_node("database", "Database", "src/model.rs", NodeType::Struct),
996 ],
997 edges: vec![
998 make_test_edge("file_main", "app", "defines", "src/main.rs"),
999 make_test_edge("file_main", "import_config", "imports", "src/main.rs"),
1000 make_test_edge("file_model", "config", "defines", "src/model.rs"),
1001 make_test_edge("file_model", "database", "defines", "src/model.rs"),
1002 ],
1003 hyperedges: vec![],
1004 };
1005
1006 resolve_cross_file_imports(&mut result);
1007
1008 let uses_edges: Vec<_> = result
1009 .edges
1010 .iter()
1011 .filter(|e| e.relation == "uses")
1012 .collect();
1013
1014 assert_eq!(
1016 uses_edges.len(),
1017 1,
1018 "expected 1 uses edge, got {}",
1019 uses_edges.len()
1020 );
1021 assert_eq!(uses_edges[0].source, "app");
1022 assert_eq!(uses_edges[0].target, "config");
1023 }
1024
1025 #[test]
1026 fn cross_file_no_duplicate_edges() {
1027 let mut result = ExtractionResult {
1029 nodes: vec![
1030 make_test_node("file_app", "app", "src/app.ts", NodeType::File),
1031 make_test_node("ctrl", "Controller", "src/app.ts", NodeType::Class),
1032 make_test_node("import1", "utils/foo", "src/app.ts", NodeType::Module),
1033 make_test_node("import2", "utils/bar", "src/app.ts", NodeType::Module),
1034 make_test_node("file_utils", "utils", "src/utils.ts", NodeType::File),
1035 make_test_node("helper", "Helper", "src/utils.ts", NodeType::Class),
1036 ],
1037 edges: vec![
1038 make_test_edge("file_app", "ctrl", "defines", "src/app.ts"),
1039 make_test_edge("file_app", "import1", "imports", "src/app.ts"),
1040 make_test_edge("file_app", "import2", "imports", "src/app.ts"),
1041 make_test_edge("file_utils", "helper", "defines", "src/utils.ts"),
1042 ],
1043 hyperedges: vec![],
1044 };
1045
1046 resolve_cross_file_imports(&mut result);
1047
1048 let uses_edges: Vec<_> = result
1049 .edges
1050 .iter()
1051 .filter(|e| e.relation == "uses")
1052 .collect();
1053
1054 assert_eq!(
1056 uses_edges.len(),
1057 1,
1058 "expected 1 uses edge (no dups), got {}",
1059 uses_edges.len()
1060 );
1061 }
1062
1063 #[test]
1064 fn cross_file_unresolved_import_creates_no_edges() {
1065 let mut result = ExtractionResult {
1067 nodes: vec![
1068 make_test_node("file_main", "main", "src/main.rs", NodeType::File),
1069 make_test_node("app", "App", "src/main.rs", NodeType::Struct),
1070 make_test_node(
1071 "import_serde",
1072 "serde::Deserialize",
1073 "src/main.rs",
1074 NodeType::Module,
1075 ),
1076 ],
1077 edges: vec![
1078 make_test_edge("file_main", "app", "defines", "src/main.rs"),
1079 make_test_edge("file_main", "import_serde", "imports", "src/main.rs"),
1080 ],
1081 hyperedges: vec![],
1082 };
1083
1084 resolve_cross_file_imports(&mut result);
1085
1086 let uses_edges: Vec<_> = result
1087 .edges
1088 .iter()
1089 .filter(|e| e.relation == "uses")
1090 .collect();
1091
1092 assert!(
1093 uses_edges.is_empty(),
1094 "external imports should not create uses edges"
1095 );
1096 }
1097
1098 #[test]
1099 fn python_resolver_not_broken_by_cross_file() {
1100 let mut result = ExtractionResult {
1102 nodes: vec![
1103 make_test_node("file_a", "module_a", "src/a.py", NodeType::File),
1104 make_test_node("my_class", "MyClass", "src/a.py", NodeType::Class),
1105 ],
1106 edges: vec![make_test_edge("file_a", "MyClass", "imports", "src/a.py")],
1107 hyperedges: vec![],
1108 };
1109
1110 resolve_python_imports(&mut result);
1111
1112 assert_eq!(result.edges[0].target, "my_class");
1114 }
1115
1116 #[test]
1119 fn java_cross_file_creates_uses_edges() {
1120 let mut result = ExtractionResult {
1121 nodes: vec![
1122 make_test_node("file_app", "App", "src/App.java", NodeType::File),
1123 make_test_node("app_class", "App", "src/App.java", NodeType::Class),
1124 make_test_node(
1125 "import_util",
1126 "com.example.Util",
1127 "src/App.java",
1128 NodeType::Module,
1129 ),
1130 make_test_node("file_util", "Util", "src/Util.java", NodeType::File),
1131 make_test_node("util_class", "Util", "src/Util.java", NodeType::Class),
1132 ],
1133 edges: vec![
1134 make_test_edge("file_app", "app_class", "defines", "src/App.java"),
1135 make_test_edge("file_app", "import_util", "imports", "src/App.java"),
1136 make_test_edge("file_util", "util_class", "defines", "src/Util.java"),
1137 ],
1138 hyperedges: vec![],
1139 };
1140
1141 resolve_cross_file_imports(&mut result);
1142
1143 let uses_edges: Vec<_> = result
1144 .edges
1145 .iter()
1146 .filter(|e| e.relation == "uses")
1147 .collect();
1148 assert!(
1149 !uses_edges.is_empty(),
1150 "Java cross-file should create uses edges"
1151 );
1152 assert!(
1153 uses_edges
1154 .iter()
1155 .any(|e| e.source == "app_class" && e.target == "util_class")
1156 );
1157 }
1158
1159 #[test]
1162 fn c_include_creates_uses_edges() {
1163 let mut result = ExtractionResult {
1164 nodes: vec![
1165 make_test_node("file_main", "main", "src/main.c", NodeType::File),
1166 make_test_node("main_fn", "main", "src/main.c", NodeType::Function),
1167 make_test_node("import_utils", "utils.h", "src/main.c", NodeType::Module),
1168 make_test_node("file_utils", "utils", "src/utils.c", NodeType::File),
1169 make_test_node("helper_fn", "helper", "src/utils.c", NodeType::Function),
1170 ],
1171 edges: vec![
1172 make_test_edge("file_main", "main_fn", "defines", "src/main.c"),
1173 make_test_edge("file_main", "import_utils", "imports", "src/main.c"),
1174 make_test_edge("file_utils", "helper_fn", "defines", "src/utils.c"),
1175 ],
1176 hyperedges: vec![],
1177 };
1178
1179 resolve_cross_file_imports(&mut result);
1180
1181 let uses_edges: Vec<_> = result
1182 .edges
1183 .iter()
1184 .filter(|e| e.relation == "uses")
1185 .collect();
1186 assert!(!uses_edges.is_empty(), "C include should create uses edges");
1187 assert!(
1188 uses_edges
1189 .iter()
1190 .any(|e| e.source == "main_fn" && e.target == "helper_fn")
1191 );
1192 }
1193
1194 #[test]
1197 fn csharp_using_creates_uses_edges() {
1198 let mut result = ExtractionResult {
1199 nodes: vec![
1200 make_test_node("file_prog", "Program", "src/Program.cs", NodeType::File),
1201 make_test_node("prog_class", "Program", "src/Program.cs", NodeType::Class),
1202 make_test_node(
1203 "import_svc",
1204 "MyApp.Services.UserService",
1205 "src/Program.cs",
1206 NodeType::Module,
1207 ),
1208 make_test_node(
1209 "file_svc",
1210 "UserService",
1211 "src/UserService.cs",
1212 NodeType::File,
1213 ),
1214 make_test_node(
1215 "svc_class",
1216 "UserService",
1217 "src/UserService.cs",
1218 NodeType::Class,
1219 ),
1220 ],
1221 edges: vec![
1222 make_test_edge("file_prog", "prog_class", "defines", "src/Program.cs"),
1223 make_test_edge("file_prog", "import_svc", "imports", "src/Program.cs"),
1224 make_test_edge("file_svc", "svc_class", "defines", "src/UserService.cs"),
1225 ],
1226 hyperedges: vec![],
1227 };
1228
1229 resolve_cross_file_imports(&mut result);
1230
1231 let uses_edges: Vec<_> = result
1232 .edges
1233 .iter()
1234 .filter(|e| e.relation == "uses")
1235 .collect();
1236 assert!(!uses_edges.is_empty(), "C# using should create uses edges");
1237 assert!(
1238 uses_edges
1239 .iter()
1240 .any(|e| e.source == "prog_class" && e.target == "svc_class")
1241 );
1242 }
1243
1244 #[test]
1247 fn php_use_creates_uses_edges() {
1248 let mut result = ExtractionResult {
1249 nodes: vec![
1250 make_test_node(
1251 "file_ctrl",
1252 "Controller",
1253 "src/Controller.php",
1254 NodeType::File,
1255 ),
1256 make_test_node(
1257 "ctrl_class",
1258 "Controller",
1259 "src/Controller.php",
1260 NodeType::Class,
1261 ),
1262 make_test_node(
1263 "import_user",
1264 r"use App\Models\User",
1265 "src/Controller.php",
1266 NodeType::Module,
1267 ),
1268 make_test_node("file_user", "User", "src/User.php", NodeType::File),
1269 make_test_node("user_class", "User", "src/User.php", NodeType::Class),
1270 ],
1271 edges: vec![
1272 make_test_edge("file_ctrl", "ctrl_class", "defines", "src/Controller.php"),
1273 make_test_edge("file_ctrl", "import_user", "imports", "src/Controller.php"),
1274 make_test_edge("file_user", "user_class", "defines", "src/User.php"),
1275 ],
1276 hyperedges: vec![],
1277 };
1278
1279 resolve_cross_file_imports(&mut result);
1280
1281 let uses_edges: Vec<_> = result
1282 .edges
1283 .iter()
1284 .filter(|e| e.relation == "uses")
1285 .collect();
1286 assert!(!uses_edges.is_empty(), "PHP use should create uses edges");
1287 assert!(
1288 uses_edges
1289 .iter()
1290 .any(|e| e.source == "ctrl_class" && e.target == "user_class")
1291 );
1292 }
1293
1294 #[test]
1297 fn dart_import_creates_uses_edges() {
1298 let mut result = ExtractionResult {
1299 nodes: vec![
1300 make_test_node("file_main", "main", "lib/main.dart", NodeType::File),
1301 make_test_node("main_fn", "main", "lib/main.dart", NodeType::Function),
1302 make_test_node(
1303 "import_utils",
1304 "import 'package:myapp/utils.dart'",
1305 "lib/main.dart",
1306 NodeType::Module,
1307 ),
1308 make_test_node("file_utils", "utils", "lib/utils.dart", NodeType::File),
1309 make_test_node("helper_fn", "helper", "lib/utils.dart", NodeType::Function),
1310 ],
1311 edges: vec![
1312 make_test_edge("file_main", "main_fn", "defines", "lib/main.dart"),
1313 make_test_edge("file_main", "import_utils", "imports", "lib/main.dart"),
1314 make_test_edge("file_utils", "helper_fn", "defines", "lib/utils.dart"),
1315 ],
1316 hyperedges: vec![],
1317 };
1318
1319 resolve_cross_file_imports(&mut result);
1320
1321 let uses_edges: Vec<_> = result
1322 .edges
1323 .iter()
1324 .filter(|e| e.relation == "uses")
1325 .collect();
1326 assert!(
1327 !uses_edges.is_empty(),
1328 "Dart import should create uses edges"
1329 );
1330 assert!(
1331 uses_edges
1332 .iter()
1333 .any(|e| e.source == "main_fn" && e.target == "helper_fn")
1334 );
1335 }
1336
1337 #[test]
1340 fn kotlin_import_creates_uses_edges() {
1341 let mut result = ExtractionResult {
1342 nodes: vec![
1343 make_test_node("file_main", "Main", "src/Main.kt", NodeType::File),
1344 make_test_node("main_fn", "main", "src/Main.kt", NodeType::Function),
1345 make_test_node(
1346 "import_repo",
1347 "import com.example.UserRepo",
1348 "src/Main.kt",
1349 NodeType::Module,
1350 ),
1351 make_test_node("file_repo", "UserRepo", "src/UserRepo.kt", NodeType::File),
1352 make_test_node("repo_class", "UserRepo", "src/UserRepo.kt", NodeType::Class),
1353 ],
1354 edges: vec![
1355 make_test_edge("file_main", "main_fn", "defines", "src/Main.kt"),
1356 make_test_edge("file_main", "import_repo", "imports", "src/Main.kt"),
1357 make_test_edge("file_repo", "repo_class", "defines", "src/UserRepo.kt"),
1358 ],
1359 hyperedges: vec![],
1360 };
1361
1362 resolve_cross_file_imports(&mut result);
1363
1364 let uses_edges: Vec<_> = result
1365 .edges
1366 .iter()
1367 .filter(|e| e.relation == "uses")
1368 .collect();
1369 assert!(
1370 !uses_edges.is_empty(),
1371 "Kotlin import should create uses edges"
1372 );
1373 assert!(
1374 uses_edges
1375 .iter()
1376 .any(|e| e.source == "main_fn" && e.target == "repo_class")
1377 );
1378 }
1379
1380 #[test]
1383 fn python_star_import_expands_to_entities() {
1384 let mut result = ExtractionResult {
1385 nodes: vec![
1386 make_test_node("file_app", "app", "src/app.py", NodeType::File),
1387 make_test_node("app_fn", "run", "src/app.py", NodeType::Function),
1388 make_test_node("import_star", "utils.*", "src/app.py", NodeType::Module),
1389 make_test_node("file_utils", "utils", "src/utils.py", NodeType::File),
1390 make_test_node("helper1", "helper1", "src/utils.py", NodeType::Function),
1391 make_test_node("helper2", "helper2", "src/utils.py", NodeType::Function),
1392 ],
1393 edges: vec![
1394 make_test_edge("file_app", "app_fn", "defines", "src/app.py"),
1395 make_test_edge("file_app", "import_star", "imports", "src/app.py"),
1396 make_test_edge("file_utils", "helper1", "defines", "src/utils.py"),
1397 make_test_edge("file_utils", "helper2", "defines", "src/utils.py"),
1398 ],
1399 hyperedges: vec![],
1400 };
1401
1402 resolve_python_imports(&mut result);
1403
1404 let uses_edges: Vec<_> = result
1405 .edges
1406 .iter()
1407 .filter(|e| e.relation == "uses")
1408 .collect();
1409 assert_eq!(
1410 uses_edges.len(),
1411 2,
1412 "star import should expand to 2 uses edges, got {}",
1413 uses_edges.len()
1414 );
1415 }
1416
1417 #[test]
1420 fn scala_cross_file_creates_uses_edges() {
1421 let mut result = ExtractionResult {
1422 nodes: vec![
1423 make_test_node("file_main", "Main", "src/Main.scala", NodeType::File),
1424 make_test_node("main_fn", "main", "src/Main.scala", NodeType::Function),
1425 make_test_node(
1426 "import_calc",
1427 "import com.example.Calculator",
1428 "src/Main.scala",
1429 NodeType::Module,
1430 ),
1431 make_test_node(
1432 "file_calc",
1433 "Calculator",
1434 "src/Calculator.scala",
1435 NodeType::File,
1436 ),
1437 make_test_node(
1438 "calc_class",
1439 "Calculator",
1440 "src/Calculator.scala",
1441 NodeType::Class,
1442 ),
1443 ],
1444 edges: vec![
1445 make_test_edge("file_main", "main_fn", "defines", "src/Main.scala"),
1446 make_test_edge("file_main", "import_calc", "imports", "src/Main.scala"),
1447 make_test_edge("file_calc", "calc_class", "defines", "src/Calculator.scala"),
1448 ],
1449 hyperedges: vec![],
1450 };
1451
1452 resolve_cross_file_imports(&mut result);
1453
1454 let uses_edges: Vec<_> = result
1455 .edges
1456 .iter()
1457 .filter(|e| e.relation == "uses")
1458 .collect();
1459 assert!(
1460 !uses_edges.is_empty(),
1461 "Scala cross-file should create uses edges"
1462 );
1463 assert!(
1464 uses_edges
1465 .iter()
1466 .any(|e| e.source == "main_fn" && e.target == "calc_class")
1467 );
1468 }
1469
1470 #[test]
1473 fn swift_cross_file_creates_uses_edges() {
1474 let mut result = ExtractionResult {
1475 nodes: vec![
1476 make_test_node("file_app", "App", "src/App.swift", NodeType::File),
1477 make_test_node("app_fn", "run", "src/App.swift", NodeType::Function),
1478 make_test_node(
1479 "import_mgr",
1480 "import UserManager",
1481 "src/App.swift",
1482 NodeType::Module,
1483 ),
1484 make_test_node(
1485 "file_mgr",
1486 "UserManager",
1487 "src/UserManager.swift",
1488 NodeType::File,
1489 ),
1490 make_test_node(
1491 "mgr_class",
1492 "UserManager",
1493 "src/UserManager.swift",
1494 NodeType::Class,
1495 ),
1496 ],
1497 edges: vec![
1498 make_test_edge("file_app", "app_fn", "defines", "src/App.swift"),
1499 make_test_edge("file_app", "import_mgr", "imports", "src/App.swift"),
1500 make_test_edge("file_mgr", "mgr_class", "defines", "src/UserManager.swift"),
1501 ],
1502 hyperedges: vec![],
1503 };
1504
1505 resolve_cross_file_imports(&mut result);
1506
1507 let uses_edges: Vec<_> = result
1508 .edges
1509 .iter()
1510 .filter(|e| e.relation == "uses")
1511 .collect();
1512 assert!(
1513 !uses_edges.is_empty(),
1514 "Swift cross-file should create uses edges"
1515 );
1516 assert!(
1517 uses_edges
1518 .iter()
1519 .any(|e| e.source == "app_fn" && e.target == "mgr_class")
1520 );
1521 }
1522}