Skip to main content

graphify_extract/
lib.rs

1//! AST and semantic extraction engine for graphify.
2//!
3//! Implements a two-pass extraction pipeline ported from the Python `extract.py`:
4//!
5//! - **Pass 1** (deterministic): regex-based AST extraction of functions, classes,
6//!   imports, and call relationships from source code.
7//! - **Pass 2** (semantic): Claude API–based extraction of higher-level concepts
8//!   from documents, papers, and images.
9
10pub 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
25// ---------------------------------------------------------------------------
26// Extension → language dispatch table
27// ---------------------------------------------------------------------------
28
29/// Maps file extensions to language identifiers used by the extraction engine.
30pub 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
64/// Build a hashmap for fast extension lookup.
65fn dispatch_map() -> HashMap<&'static str, &'static str> {
66    DISPATCH.iter().copied().collect()
67}
68
69/// Return the language name for a file extension (e.g. `".py"` → `"python"`).
70pub 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
76// ---------------------------------------------------------------------------
77// File collection
78// ---------------------------------------------------------------------------
79
80/// Recursively collect all supported source files under `target`.
81pub 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            // Skip hidden dirs and common vendor dirs
101            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
122// ---------------------------------------------------------------------------
123// Main extraction entry point
124// ---------------------------------------------------------------------------
125
126/// Run Pass 1 extraction on a set of file paths.
127///
128/// Dispatches each file to the appropriate regex-based extractor, collects all
129/// nodes and edges, deduplicates, and runs cross-file import resolution for Python.
130///
131/// Files are processed in parallel using rayon for improved throughput on
132/// multi-core machines.
133pub 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            // Try tree-sitter first, fall back to regex
156            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    // Cross-file import resolution for Python
177    resolve_python_imports(&mut combined);
178
179    // Cross-file import resolution for JS/TS, Go, and Rust
180    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
191/// Resolve Python `import` / `from ... import` edges to actual module/function
192/// nodes discovered across files.
193///
194/// Also handles `from x import *` by expanding to all entities in module x.
195fn resolve_python_imports(result: &mut ExtractionResult) {
196    // Build a lookup from node label → node id
197    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    // Build module stem → [entity_id] for star import expansion
204    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    // Collect star import edges for expansion
227    let mut star_expansions: Vec<GraphEdge> = Vec::new();
228
229    // For every edge with relation "imports", try to resolve the target
230    for edge in &mut result.edges {
231        if edge.relation == "imports" {
232            // Check for star import: target label contains "*"
233            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                // `from module import *` — expand to all entities in module
242                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                // Regular import — resolve by label
260                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
277/// Resolve cross-file imports for JS/TS, Go, and Rust.
278///
279/// For each `imports` edge, tries to match the imported module name to a file
280/// stem and then creates `uses` edges from entities in the importing file to
281/// entities defined in the target module. This turns file-level import edges
282/// into entity-level relationship edges.
283fn resolve_cross_file_imports(result: &mut ExtractionResult) {
284    // Step 1: Build lookup indexes in a single pass over nodes.
285    //   - id_to_label: node_id → label (for fast import label lookup)
286    //   - stem_to_entities: file_stem → [(label, node_id, node_type)]
287    //   - go_pkg_to_entities: go_dir_name → [(label, node_id, node_type)]
288    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    // Collect defined entity IDs from edges (one pass)
295    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    // Build source_file → [entity_node_id] and id_to_label in one pass over edges
303    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    // Single pass over nodes to build all indexes
314    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        // Go package grouping
346        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    // Step 2: Resolve imports → create uses edges
361    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        // O(1) lookup instead of linear scan
376        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        // Get the importing file's own entities
419        let local_entities = match source_file_entities.get(source_file) {
420            Some(ids) => ids,
421            None => continue,
422        };
423
424        // Create uses edges: each entity in the importing file → each entity in the target module
425        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
460/// Resolve a JS/TS import label to target entities.
461///
462/// Import labels can be:
463/// - `"module/ExportedName"` (named import from module)
464/// - `"DefaultName"` (default import, label is the local binding name)
465/// - `"./relative/path"` module path
466///
467/// Handles aliased imports (`X as Y`), barrel exports (index files),
468/// and re-exports (`export { } from`).
469fn 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    // Strip alias: "Foo as Bar" → "Foo"
474    let label = import_label.split(" as ").next().unwrap_or(import_label);
475
476    let parts: Vec<&str> = label.split('/').collect();
477
478    // Try the first segment as module stem (for "module/Name" patterns)
479    if parts.len() >= 2 {
480        let module_stem = parts[0].trim_start_matches('.');
481        if let Some(entities) = stem_to_entities.get(module_stem) {
482            return entities.iter().collect();
483        }
484    }
485
486    // Try the last segment as file stem (for path-style imports)
487    if let Some(last) = parts.last() {
488        let stem = last.trim_start_matches('.');
489        if let Some(entities) = stem_to_entities.get(stem) {
490            return entities.iter().collect();
491        }
492    }
493
494    // Try the whole label as a stem (for simple imports like "React")
495    let simple = label.trim_start_matches("./").trim_start_matches("../");
496    if let Some(entities) = stem_to_entities.get(simple) {
497        return entities.iter().collect();
498    }
499
500    // Barrel export: if the last segment matches a directory, check for "index" file
501    if let Some(entities) = stem_to_entities.get("index")
502        && (label.contains('/') || label.starts_with('.'))
503    {
504        return entities.iter().collect();
505    }
506
507    Vec::new()
508}
509
510/// Resolve a Go import to target entities.
511///
512/// Go import labels are like `"fmt"`, `"net/http"`, or `"myproject/pkg/utils"`.
513/// Handles dot imports (`import . "pkg"`), blank imports (`import _ "pkg"`),
514/// and aliased imports (`import alias "pkg"`).
515fn resolve_go_import<'a>(
516    import_label: &str,
517    stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
518    go_pkg_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
519) -> Vec<&'a (String, String, NodeType)> {
520    // Strip dot import prefix, blank import prefix, or alias
521    let label = import_label
522        .trim_start_matches(". ")
523        .trim_start_matches("_ ");
524    // Also strip any remaining alias: `alias "path"` → `"path"`
525    let label = if label.contains('"') {
526        label.split('"').nth(1).unwrap_or(label)
527    } else {
528        label
529    };
530
531    let pkg_name = label.rsplit('/').next().unwrap_or(label);
532
533    if let Some(entities) = go_pkg_to_entities.get(pkg_name) {
534        return entities.iter().collect();
535    }
536
537    if let Some(entities) = stem_to_entities.get(pkg_name) {
538        return entities.iter().collect();
539    }
540
541    Vec::new()
542}
543
544/// Resolve a Rust `use` import to target entities.
545///
546/// Handles `pub use` re-exports, glob imports (`use foo::*`),
547/// and specific type imports (`use crate::model::Config`).
548fn resolve_rust_import<'a>(
549    import_label: &str,
550    stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
551) -> Vec<&'a (String, String, NodeType)> {
552    let label = import_label
553        .strip_prefix("pub use ")
554        .unwrap_or(import_label);
555    let segments: Vec<&str> = label.split("::").collect();
556
557    // Glob import: `use module::*` → return all entities from module
558    if segments.last() == Some(&"*") && segments.len() >= 2 {
559        let module = segments[segments.len() - 2];
560        if let Some(entities) = stem_to_entities.get(module) {
561            return entities.iter().collect();
562        }
563    }
564
565    // Try the last segment as a module/file stem
566    if let Some(last) = segments.last()
567        && *last != "*"
568        && let Some(entities) = stem_to_entities.get(*last)
569    {
570        return entities.iter().collect();
571    }
572
573    // Try the second-to-last segment (for `crate::module::Type` patterns)
574    if segments.len() >= 2 {
575        let module = segments[segments.len() - 2];
576        if let Some(entities) = stem_to_entities.get(module) {
577            let last = segments.last().unwrap();
578            let filtered: Vec<_> = entities.iter().filter(|(lbl, _, _)| lbl == last).collect();
579            if !filtered.is_empty() {
580                return filtered;
581            }
582            return entities.iter().collect();
583        }
584    }
585
586    Vec::new()
587}
588
589/// Resolve a dot-separated import (Java, C#, Kotlin, Scala, Swift).
590///
591/// Import labels like `"java.util.List"` or `"System.Collections.Generic"`.
592/// Handles aliased imports (`using X = Y`), static imports (`import static`).
593fn resolve_dot_import<'a>(
594    import_label: &str,
595    stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
596) -> Vec<&'a (String, String, NodeType)> {
597    // Strip common prefixes: "static ", alias part after " = "
598    let label = import_label.strip_prefix("static ").unwrap_or(import_label);
599    let label = if let Some(idx) = label.find(" = ") {
600        label[idx + 3..].trim()
601    } else {
602        label
603    };
604
605    let segments: Vec<&str> = label.split('.').collect();
606
607    // Try the last segment as a type/entity name matching a file stem
608    if let Some(last) = segments.last()
609        && let Some(entities) = stem_to_entities.get(*last)
610    {
611        return entities.iter().collect();
612    }
613
614    // Try second-to-last as module, filter to last segment
615    if segments.len() >= 2 {
616        let module = segments[segments.len() - 2];
617        if let Some(entities) = stem_to_entities.get(module) {
618            let last = segments.last().unwrap();
619            let filtered: Vec<_> = entities.iter().filter(|(lbl, _, _)| lbl == last).collect();
620            if !filtered.is_empty() {
621                return filtered;
622            }
623            return entities.iter().collect();
624        }
625    }
626
627    Vec::new()
628}
629
630/// Resolve a C/C++ `#include` to target entities.
631///
632/// Include labels are like `"stdio.h"` or `"myheader.h"`.
633/// Strips the extension and matches the stem to file entities.
634fn resolve_c_include<'a>(
635    import_label: &str,
636    stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
637) -> Vec<&'a (String, String, NodeType)> {
638    // Strip angle brackets and quotes
639    let label = import_label
640        .trim_start_matches('<')
641        .trim_end_matches('>')
642        .trim_start_matches('"')
643        .trim_end_matches('"');
644
645    // Strip extension (.h, .hpp, etc.)
646    let stem = std::path::Path::new(label)
647        .file_stem()
648        .and_then(|s| s.to_str())
649        .unwrap_or(label);
650
651    if let Some(entities) = stem_to_entities.get(stem) {
652        return entities.iter().collect();
653    }
654
655    Vec::new()
656}
657
658/// Resolve a PHP backslash-separated import.
659///
660/// Labels like `"App\Models\User"` → try "User" as stem, then "Models".
661fn resolve_backslash_import<'a>(
662    import_label: &str,
663    stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
664) -> Vec<&'a (String, String, NodeType)> {
665    let segments: Vec<&str> = import_label.split('\\').collect();
666
667    // Try the last segment as entity name
668    if let Some(last) = segments.last()
669        && let Some(entities) = stem_to_entities.get(*last)
670    {
671        return entities.iter().collect();
672    }
673
674    // Try second-to-last as module
675    if segments.len() >= 2 {
676        let module = segments[segments.len() - 2];
677        if let Some(entities) = stem_to_entities.get(module) {
678            return entities.iter().collect();
679        }
680    }
681
682    Vec::new()
683}
684
685/// Resolve a Dart import.
686///
687/// Labels like `"import 'package:foo/bar.dart'"` or `"import 'bar.dart'"`.
688/// Extracts the file stem from the path.
689fn resolve_dart_import<'a>(
690    import_label: &str,
691    stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
692) -> Vec<&'a (String, String, NodeType)> {
693    // Start with the full import label
694    let mut label = import_label;
695
696    // Strip common prefixes: "import ", "export ", "part "
697    if let Some(stripped) = label.strip_prefix("import ") {
698        label = stripped;
699    } else if let Some(stripped) = label.strip_prefix("export ") {
700        label = stripped;
701    } else if let Some(stripped) = label.strip_prefix("part ") {
702        label = stripped;
703    }
704
705    // Step 1: Handle aliased imports like "utils.dart' as utils"
706    // Extract the path part before " as "
707    let path_and_alias = label;
708    let path_part = if let Some(idx) = path_and_alias.find(" as ") {
709        &path_and_alias[..idx]
710    } else {
711        path_and_alias
712    };
713
714    // Step 2: Handle deferred imports like "heavy.dart' deferred as heavy"
715    // Extract the path part before " deferred"
716    let path_deferred = path_part;
717    let path_no_deferred = if let Some(idx) = path_deferred.find(" deferred") {
718        &path_deferred[..idx]
719    } else {
720        path_deferred
721    };
722
723    // Step 3: Strip quotes
724    let quoted = path_no_deferred.trim();
725    let unquoted = quoted
726        .trim_matches('\'') // Single quote character
727        .trim_matches('"');
728
729    // Step 4: Handle relative imports with "../" - resolve up to file stem
730    let normalized = if unquoted.contains("../") {
731        // For relative imports, just take the last segment (filename)
732        // e.g., "../models/user.dart" -> "user"
733        let last_segment = unquoted.rsplit('/').next().unwrap_or(unquoted);
734        last_segment.strip_suffix(".dart").unwrap_or(last_segment)
735    } else {
736        // Step 5: Strip "package:" prefix
737        let path_part = unquoted.strip_prefix("package:").unwrap_or(unquoted);
738
739        // Step 6: Extract last path segment (filename)
740        let last_segment = path_part.rsplit('/').next().unwrap_or(path_part);
741
742        // Step 7: Strip .dart extension
743        last_segment.strip_suffix(".dart").unwrap_or(last_segment)
744    };
745
746    // Look up the stem in the entities map
747    if let Some(entities) = stem_to_entities.get(normalized) {
748        return entities.iter().collect();
749    }
750
751    Vec::new()
752}
753
754// ---------------------------------------------------------------------------
755// Tests
756// ---------------------------------------------------------------------------
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761    use graphify_core::model::{GraphEdge, GraphNode};
762
763    #[test]
764    fn dispatch_table_covers_all_languages() {
765        let map = dispatch_map();
766        assert_eq!(map.get(".py"), Some(&"python"));
767        assert_eq!(map.get(".rs"), Some(&"rust"));
768        assert_eq!(map.get(".go"), Some(&"go"));
769        assert_eq!(map.get(".tsx"), Some(&"typescript"));
770        assert_eq!(map.get(".jl"), Some(&"julia"));
771        assert_eq!(map.get(".mm"), Some(&"objc"));
772    }
773
774    // -----------------------------------------------------------------------
775    // Helpers for cross-file import resolution tests
776    // -----------------------------------------------------------------------
777
778    fn make_test_node(id: &str, label: &str, source_file: &str, node_type: NodeType) -> GraphNode {
779        GraphNode {
780            id: id.to_string(),
781            label: label.to_string(),
782            source_file: source_file.to_string(),
783            source_location: None,
784            node_type,
785            community: None,
786            extra: Default::default(),
787        }
788    }
789
790    fn make_test_edge(source: &str, target: &str, relation: &str, source_file: &str) -> GraphEdge {
791        GraphEdge {
792            source: source.to_string(),
793            target: target.to_string(),
794            relation: relation.to_string(),
795            confidence: Confidence::Extracted,
796            confidence_score: 1.0,
797            source_file: source_file.to_string(),
798            source_location: None,
799            weight: 1.0,
800            extra: Default::default(),
801        }
802    }
803
804    // -----------------------------------------------------------------------
805    // JS/TS cross-file resolution
806    // -----------------------------------------------------------------------
807
808    #[test]
809    fn jsts_cross_file_creates_uses_edges() {
810        // File: src/app.ts defines AppController, imports from "utils"
811        // File: src/utils.ts defines parseDate, formatDate
812        let mut result = ExtractionResult {
813            nodes: vec![
814                make_test_node("file_app", "app", "src/app.ts", NodeType::File),
815                make_test_node("app_ctrl", "AppController", "src/app.ts", NodeType::Class),
816                make_test_node(
817                    "import_utils",
818                    "utils/parseDate",
819                    "src/app.ts",
820                    NodeType::Module,
821                ),
822                make_test_node("file_utils", "utils", "src/utils.ts", NodeType::File),
823                make_test_node(
824                    "parse_date",
825                    "parseDate",
826                    "src/utils.ts",
827                    NodeType::Function,
828                ),
829                make_test_node(
830                    "format_date",
831                    "formatDate",
832                    "src/utils.ts",
833                    NodeType::Function,
834                ),
835            ],
836            edges: vec![
837                make_test_edge("file_app", "app_ctrl", "defines", "src/app.ts"),
838                make_test_edge("file_app", "import_utils", "imports", "src/app.ts"),
839                make_test_edge("file_utils", "parse_date", "defines", "src/utils.ts"),
840                make_test_edge("file_utils", "format_date", "defines", "src/utils.ts"),
841            ],
842            hyperedges: vec![],
843        };
844
845        resolve_cross_file_imports(&mut result);
846
847        let uses_edges: Vec<_> = result
848            .edges
849            .iter()
850            .filter(|e| e.relation == "uses")
851            .collect();
852
853        // AppController should use both parseDate and formatDate
854        assert_eq!(
855            uses_edges.len(),
856            2,
857            "expected 2 uses edges, got {}",
858            uses_edges.len()
859        );
860        assert!(
861            uses_edges
862                .iter()
863                .any(|e| e.source == "app_ctrl" && e.target == "parse_date")
864        );
865        assert!(
866            uses_edges
867                .iter()
868                .any(|e| e.source == "app_ctrl" && e.target == "format_date")
869        );
870
871        // All uses edges should be Inferred with weight 0.8
872        for edge in &uses_edges {
873            assert_eq!(edge.confidence, Confidence::Inferred);
874            assert!((edge.weight - 0.8).abs() < f64::EPSILON);
875            assert!((edge.confidence_score - 0.8).abs() < f64::EPSILON);
876        }
877    }
878
879    // -----------------------------------------------------------------------
880    // Go cross-file resolution
881    // -----------------------------------------------------------------------
882
883    #[test]
884    fn go_cross_file_creates_uses_edges() {
885        // File: cmd/main.go defines Server, imports "myproject/pkg/utils"
886        // File: pkg/utils/helpers.go defines ParseConfig, Validate
887        let mut result = ExtractionResult {
888            nodes: vec![
889                make_test_node("file_main", "main", "cmd/main.go", NodeType::File),
890                make_test_node("server", "Server", "cmd/main.go", NodeType::Struct),
891                make_test_node(
892                    "import_utils",
893                    "myproject/pkg/utils",
894                    "cmd/main.go",
895                    NodeType::Package,
896                ),
897                make_test_node(
898                    "file_helpers",
899                    "helpers",
900                    "pkg/utils/helpers.go",
901                    NodeType::File,
902                ),
903                make_test_node(
904                    "parse_config",
905                    "ParseConfig",
906                    "pkg/utils/helpers.go",
907                    NodeType::Function,
908                ),
909                make_test_node(
910                    "validate",
911                    "Validate",
912                    "pkg/utils/helpers.go",
913                    NodeType::Function,
914                ),
915            ],
916            edges: vec![
917                make_test_edge("file_main", "server", "defines", "cmd/main.go"),
918                make_test_edge("file_main", "import_utils", "imports", "cmd/main.go"),
919                make_test_edge(
920                    "file_helpers",
921                    "parse_config",
922                    "defines",
923                    "pkg/utils/helpers.go",
924                ),
925                make_test_edge(
926                    "file_helpers",
927                    "validate",
928                    "defines",
929                    "pkg/utils/helpers.go",
930                ),
931            ],
932            hyperedges: vec![],
933        };
934
935        resolve_cross_file_imports(&mut result);
936
937        let uses_edges: Vec<_> = result
938            .edges
939            .iter()
940            .filter(|e| e.relation == "uses")
941            .collect();
942
943        // Server should use both ParseConfig and Validate
944        assert_eq!(
945            uses_edges.len(),
946            2,
947            "expected 2 uses edges, got {}",
948            uses_edges.len()
949        );
950        assert!(
951            uses_edges
952                .iter()
953                .any(|e| e.source == "server" && e.target == "parse_config")
954        );
955        assert!(
956            uses_edges
957                .iter()
958                .any(|e| e.source == "server" && e.target == "validate")
959        );
960
961        for edge in &uses_edges {
962            assert_eq!(edge.confidence, Confidence::Inferred);
963        }
964    }
965
966    // -----------------------------------------------------------------------
967    // Rust cross-file resolution
968    // -----------------------------------------------------------------------
969
970    #[test]
971    fn rust_cross_file_creates_uses_edges() {
972        // File: src/main.rs defines App, imports "crate::model"
973        // File: src/model.rs defines Config, Database
974        let mut result = ExtractionResult {
975            nodes: vec![
976                make_test_node("file_main", "main", "src/main.rs", NodeType::File),
977                make_test_node("app", "App", "src/main.rs", NodeType::Struct),
978                make_test_node(
979                    "import_model",
980                    "crate::model",
981                    "src/main.rs",
982                    NodeType::Module,
983                ),
984                make_test_node("file_model", "model", "src/model.rs", NodeType::File),
985                make_test_node("config", "Config", "src/model.rs", NodeType::Struct),
986                make_test_node("database", "Database", "src/model.rs", NodeType::Struct),
987            ],
988            edges: vec![
989                make_test_edge("file_main", "app", "defines", "src/main.rs"),
990                make_test_edge("file_main", "import_model", "imports", "src/main.rs"),
991                make_test_edge("file_model", "config", "defines", "src/model.rs"),
992                make_test_edge("file_model", "database", "defines", "src/model.rs"),
993            ],
994            hyperedges: vec![],
995        };
996
997        resolve_cross_file_imports(&mut result);
998
999        let uses_edges: Vec<_> = result
1000            .edges
1001            .iter()
1002            .filter(|e| e.relation == "uses")
1003            .collect();
1004
1005        // App should use both Config and Database
1006        assert_eq!(
1007            uses_edges.len(),
1008            2,
1009            "expected 2 uses edges, got {}",
1010            uses_edges.len()
1011        );
1012        assert!(
1013            uses_edges
1014                .iter()
1015                .any(|e| e.source == "app" && e.target == "config")
1016        );
1017        assert!(
1018            uses_edges
1019                .iter()
1020                .any(|e| e.source == "app" && e.target == "database")
1021        );
1022
1023        for edge in &uses_edges {
1024            assert_eq!(edge.confidence, Confidence::Inferred);
1025            assert!((edge.weight - 0.8).abs() < f64::EPSILON);
1026        }
1027    }
1028
1029    #[test]
1030    fn rust_cross_file_resolves_specific_type() {
1031        // `use crate::model::Config` should prefer Config over all entities in model
1032        let mut result = ExtractionResult {
1033            nodes: vec![
1034                make_test_node("file_main", "main", "src/main.rs", NodeType::File),
1035                make_test_node("app", "App", "src/main.rs", NodeType::Struct),
1036                make_test_node(
1037                    "import_config",
1038                    "crate::model::Config",
1039                    "src/main.rs",
1040                    NodeType::Module,
1041                ),
1042                make_test_node("file_model", "model", "src/model.rs", NodeType::File),
1043                make_test_node("config", "Config", "src/model.rs", NodeType::Struct),
1044                make_test_node("database", "Database", "src/model.rs", NodeType::Struct),
1045            ],
1046            edges: vec![
1047                make_test_edge("file_main", "app", "defines", "src/main.rs"),
1048                make_test_edge("file_main", "import_config", "imports", "src/main.rs"),
1049                make_test_edge("file_model", "config", "defines", "src/model.rs"),
1050                make_test_edge("file_model", "database", "defines", "src/model.rs"),
1051            ],
1052            hyperedges: vec![],
1053        };
1054
1055        resolve_cross_file_imports(&mut result);
1056
1057        let uses_edges: Vec<_> = result
1058            .edges
1059            .iter()
1060            .filter(|e| e.relation == "uses")
1061            .collect();
1062
1063        // Should only create edge to Config, not Database
1064        assert_eq!(
1065            uses_edges.len(),
1066            1,
1067            "expected 1 uses edge, got {}",
1068            uses_edges.len()
1069        );
1070        assert_eq!(uses_edges[0].source, "app");
1071        assert_eq!(uses_edges[0].target, "config");
1072    }
1073
1074    #[test]
1075    fn cross_file_no_duplicate_edges() {
1076        // Two imports from the same module shouldn't create duplicate uses edges
1077        let mut result = ExtractionResult {
1078            nodes: vec![
1079                make_test_node("file_app", "app", "src/app.ts", NodeType::File),
1080                make_test_node("ctrl", "Controller", "src/app.ts", NodeType::Class),
1081                make_test_node("import1", "utils/foo", "src/app.ts", NodeType::Module),
1082                make_test_node("import2", "utils/bar", "src/app.ts", NodeType::Module),
1083                make_test_node("file_utils", "utils", "src/utils.ts", NodeType::File),
1084                make_test_node("helper", "Helper", "src/utils.ts", NodeType::Class),
1085            ],
1086            edges: vec![
1087                make_test_edge("file_app", "ctrl", "defines", "src/app.ts"),
1088                make_test_edge("file_app", "import1", "imports", "src/app.ts"),
1089                make_test_edge("file_app", "import2", "imports", "src/app.ts"),
1090                make_test_edge("file_utils", "helper", "defines", "src/utils.ts"),
1091            ],
1092            hyperedges: vec![],
1093        };
1094
1095        resolve_cross_file_imports(&mut result);
1096
1097        let uses_edges: Vec<_> = result
1098            .edges
1099            .iter()
1100            .filter(|e| e.relation == "uses")
1101            .collect();
1102
1103        // Only one edge Controller → Helper even though there are two imports from utils
1104        assert_eq!(
1105            uses_edges.len(),
1106            1,
1107            "expected 1 uses edge (no dups), got {}",
1108            uses_edges.len()
1109        );
1110    }
1111
1112    #[test]
1113    fn cross_file_unresolved_import_creates_no_edges() {
1114        // Import from external module (not in our files) should create no uses edges
1115        let mut result = ExtractionResult {
1116            nodes: vec![
1117                make_test_node("file_main", "main", "src/main.rs", NodeType::File),
1118                make_test_node("app", "App", "src/main.rs", NodeType::Struct),
1119                make_test_node(
1120                    "import_serde",
1121                    "serde::Deserialize",
1122                    "src/main.rs",
1123                    NodeType::Module,
1124                ),
1125            ],
1126            edges: vec![
1127                make_test_edge("file_main", "app", "defines", "src/main.rs"),
1128                make_test_edge("file_main", "import_serde", "imports", "src/main.rs"),
1129            ],
1130            hyperedges: vec![],
1131        };
1132
1133        resolve_cross_file_imports(&mut result);
1134
1135        let uses_edges: Vec<_> = result
1136            .edges
1137            .iter()
1138            .filter(|e| e.relation == "uses")
1139            .collect();
1140
1141        assert!(
1142            uses_edges.is_empty(),
1143            "external imports should not create uses edges"
1144        );
1145    }
1146
1147    #[test]
1148    fn python_resolver_not_broken_by_cross_file() {
1149        // Ensure the Python resolver still works independently
1150        let mut result = ExtractionResult {
1151            nodes: vec![
1152                make_test_node("file_a", "module_a", "src/a.py", NodeType::File),
1153                make_test_node("my_class", "MyClass", "src/a.py", NodeType::Class),
1154            ],
1155            edges: vec![make_test_edge("file_a", "MyClass", "imports", "src/a.py")],
1156            hyperedges: vec![],
1157        };
1158
1159        resolve_python_imports(&mut result);
1160
1161        // The import edge target should resolve to the node ID "my_class"
1162        assert_eq!(result.edges[0].target, "my_class");
1163    }
1164
1165    // ===== Java cross-file resolution =====
1166
1167    #[test]
1168    fn java_cross_file_creates_uses_edges() {
1169        let mut result = ExtractionResult {
1170            nodes: vec![
1171                make_test_node("file_app", "App", "src/App.java", NodeType::File),
1172                make_test_node("app_class", "App", "src/App.java", NodeType::Class),
1173                make_test_node(
1174                    "import_util",
1175                    "com.example.Util",
1176                    "src/App.java",
1177                    NodeType::Module,
1178                ),
1179                make_test_node("file_util", "Util", "src/Util.java", NodeType::File),
1180                make_test_node("util_class", "Util", "src/Util.java", NodeType::Class),
1181            ],
1182            edges: vec![
1183                make_test_edge("file_app", "app_class", "defines", "src/App.java"),
1184                make_test_edge("file_app", "import_util", "imports", "src/App.java"),
1185                make_test_edge("file_util", "util_class", "defines", "src/Util.java"),
1186            ],
1187            hyperedges: vec![],
1188        };
1189
1190        resolve_cross_file_imports(&mut result);
1191
1192        let uses_edges: Vec<_> = result
1193            .edges
1194            .iter()
1195            .filter(|e| e.relation == "uses")
1196            .collect();
1197        assert!(
1198            !uses_edges.is_empty(),
1199            "Java cross-file should create uses edges"
1200        );
1201        assert!(
1202            uses_edges
1203                .iter()
1204                .any(|e| e.source == "app_class" && e.target == "util_class")
1205        );
1206    }
1207
1208    // ===== C/C++ cross-file resolution =====
1209
1210    #[test]
1211    fn c_include_creates_uses_edges() {
1212        let mut result = ExtractionResult {
1213            nodes: vec![
1214                make_test_node("file_main", "main", "src/main.c", NodeType::File),
1215                make_test_node("main_fn", "main", "src/main.c", NodeType::Function),
1216                make_test_node("import_utils", "utils.h", "src/main.c", NodeType::Module),
1217                make_test_node("file_utils", "utils", "src/utils.c", NodeType::File),
1218                make_test_node("helper_fn", "helper", "src/utils.c", NodeType::Function),
1219            ],
1220            edges: vec![
1221                make_test_edge("file_main", "main_fn", "defines", "src/main.c"),
1222                make_test_edge("file_main", "import_utils", "imports", "src/main.c"),
1223                make_test_edge("file_utils", "helper_fn", "defines", "src/utils.c"),
1224            ],
1225            hyperedges: vec![],
1226        };
1227
1228        resolve_cross_file_imports(&mut result);
1229
1230        let uses_edges: Vec<_> = result
1231            .edges
1232            .iter()
1233            .filter(|e| e.relation == "uses")
1234            .collect();
1235        assert!(!uses_edges.is_empty(), "C include should create uses edges");
1236        assert!(
1237            uses_edges
1238                .iter()
1239                .any(|e| e.source == "main_fn" && e.target == "helper_fn")
1240        );
1241    }
1242
1243    // ===== C# cross-file resolution =====
1244
1245    #[test]
1246    fn csharp_using_creates_uses_edges() {
1247        let mut result = ExtractionResult {
1248            nodes: vec![
1249                make_test_node("file_prog", "Program", "src/Program.cs", NodeType::File),
1250                make_test_node("prog_class", "Program", "src/Program.cs", NodeType::Class),
1251                make_test_node(
1252                    "import_svc",
1253                    "MyApp.Services.UserService",
1254                    "src/Program.cs",
1255                    NodeType::Module,
1256                ),
1257                make_test_node(
1258                    "file_svc",
1259                    "UserService",
1260                    "src/UserService.cs",
1261                    NodeType::File,
1262                ),
1263                make_test_node(
1264                    "svc_class",
1265                    "UserService",
1266                    "src/UserService.cs",
1267                    NodeType::Class,
1268                ),
1269            ],
1270            edges: vec![
1271                make_test_edge("file_prog", "prog_class", "defines", "src/Program.cs"),
1272                make_test_edge("file_prog", "import_svc", "imports", "src/Program.cs"),
1273                make_test_edge("file_svc", "svc_class", "defines", "src/UserService.cs"),
1274            ],
1275            hyperedges: vec![],
1276        };
1277
1278        resolve_cross_file_imports(&mut result);
1279
1280        let uses_edges: Vec<_> = result
1281            .edges
1282            .iter()
1283            .filter(|e| e.relation == "uses")
1284            .collect();
1285        assert!(!uses_edges.is_empty(), "C# using should create uses edges");
1286        assert!(
1287            uses_edges
1288                .iter()
1289                .any(|e| e.source == "prog_class" && e.target == "svc_class")
1290        );
1291    }
1292
1293    // ===== PHP cross-file resolution =====
1294
1295    #[test]
1296    fn php_use_creates_uses_edges() {
1297        let mut result = ExtractionResult {
1298            nodes: vec![
1299                make_test_node(
1300                    "file_ctrl",
1301                    "Controller",
1302                    "src/Controller.php",
1303                    NodeType::File,
1304                ),
1305                make_test_node(
1306                    "ctrl_class",
1307                    "Controller",
1308                    "src/Controller.php",
1309                    NodeType::Class,
1310                ),
1311                make_test_node(
1312                    "import_user",
1313                    r"use App\Models\User",
1314                    "src/Controller.php",
1315                    NodeType::Module,
1316                ),
1317                make_test_node("file_user", "User", "src/User.php", NodeType::File),
1318                make_test_node("user_class", "User", "src/User.php", NodeType::Class),
1319            ],
1320            edges: vec![
1321                make_test_edge("file_ctrl", "ctrl_class", "defines", "src/Controller.php"),
1322                make_test_edge("file_ctrl", "import_user", "imports", "src/Controller.php"),
1323                make_test_edge("file_user", "user_class", "defines", "src/User.php"),
1324            ],
1325            hyperedges: vec![],
1326        };
1327
1328        resolve_cross_file_imports(&mut result);
1329
1330        let uses_edges: Vec<_> = result
1331            .edges
1332            .iter()
1333            .filter(|e| e.relation == "uses")
1334            .collect();
1335        assert!(!uses_edges.is_empty(), "PHP use should create uses edges");
1336        assert!(
1337            uses_edges
1338                .iter()
1339                .any(|e| e.source == "ctrl_class" && e.target == "user_class")
1340        );
1341    }
1342
1343    // ===== Dart cross-file resolution =====
1344
1345    #[test]
1346    fn dart_import_creates_uses_edges() {
1347        let mut result = ExtractionResult {
1348            nodes: vec![
1349                make_test_node("file_main", "main", "lib/main.dart", NodeType::File),
1350                make_test_node("main_fn", "main", "lib/main.dart", NodeType::Function),
1351                make_test_node(
1352                    "import_utils",
1353                    "import 'package:myapp/utils.dart'",
1354                    "lib/main.dart",
1355                    NodeType::Module,
1356                ),
1357                make_test_node("file_utils", "utils", "lib/utils.dart", NodeType::File),
1358                make_test_node("helper_fn", "helper", "lib/utils.dart", NodeType::Function),
1359            ],
1360            edges: vec![
1361                make_test_edge("file_main", "main_fn", "defines", "lib/main.dart"),
1362                make_test_edge("file_main", "import_utils", "imports", "lib/main.dart"),
1363                make_test_edge("file_utils", "helper_fn", "defines", "lib/utils.dart"),
1364            ],
1365            hyperedges: vec![],
1366        };
1367
1368        resolve_cross_file_imports(&mut result);
1369
1370        let uses_edges: Vec<_> = result
1371            .edges
1372            .iter()
1373            .filter(|e| e.relation == "uses")
1374            .collect();
1375        assert!(
1376            !uses_edges.is_empty(),
1377            "Dart import should create uses edges"
1378        );
1379        assert!(
1380            uses_edges
1381                .iter()
1382                .any(|e| e.source == "main_fn" && e.target == "helper_fn")
1383        );
1384    }
1385
1386    // ===== Kotlin cross-file resolution =====
1387
1388    #[test]
1389    fn kotlin_import_creates_uses_edges() {
1390        let mut result = ExtractionResult {
1391            nodes: vec![
1392                make_test_node("file_main", "Main", "src/Main.kt", NodeType::File),
1393                make_test_node("main_fn", "main", "src/Main.kt", NodeType::Function),
1394                make_test_node(
1395                    "import_repo",
1396                    "import com.example.UserRepo",
1397                    "src/Main.kt",
1398                    NodeType::Module,
1399                ),
1400                make_test_node("file_repo", "UserRepo", "src/UserRepo.kt", NodeType::File),
1401                make_test_node("repo_class", "UserRepo", "src/UserRepo.kt", NodeType::Class),
1402            ],
1403            edges: vec![
1404                make_test_edge("file_main", "main_fn", "defines", "src/Main.kt"),
1405                make_test_edge("file_main", "import_repo", "imports", "src/Main.kt"),
1406                make_test_edge("file_repo", "repo_class", "defines", "src/UserRepo.kt"),
1407            ],
1408            hyperedges: vec![],
1409        };
1410
1411        resolve_cross_file_imports(&mut result);
1412
1413        let uses_edges: Vec<_> = result
1414            .edges
1415            .iter()
1416            .filter(|e| e.relation == "uses")
1417            .collect();
1418        assert!(
1419            !uses_edges.is_empty(),
1420            "Kotlin import should create uses edges"
1421        );
1422        assert!(
1423            uses_edges
1424                .iter()
1425                .any(|e| e.source == "main_fn" && e.target == "repo_class")
1426        );
1427    }
1428
1429    // ===== Python star import expansion =====
1430
1431    #[test]
1432    fn python_star_import_expands_to_entities() {
1433        let mut result = ExtractionResult {
1434            nodes: vec![
1435                make_test_node("file_app", "app", "src/app.py", NodeType::File),
1436                make_test_node("app_fn", "run", "src/app.py", NodeType::Function),
1437                make_test_node("import_star", "utils.*", "src/app.py", NodeType::Module),
1438                make_test_node("file_utils", "utils", "src/utils.py", NodeType::File),
1439                make_test_node("helper1", "helper1", "src/utils.py", NodeType::Function),
1440                make_test_node("helper2", "helper2", "src/utils.py", NodeType::Function),
1441            ],
1442            edges: vec![
1443                make_test_edge("file_app", "app_fn", "defines", "src/app.py"),
1444                make_test_edge("file_app", "import_star", "imports", "src/app.py"),
1445                make_test_edge("file_utils", "helper1", "defines", "src/utils.py"),
1446                make_test_edge("file_utils", "helper2", "defines", "src/utils.py"),
1447            ],
1448            hyperedges: vec![],
1449        };
1450
1451        resolve_python_imports(&mut result);
1452
1453        let uses_edges: Vec<_> = result
1454            .edges
1455            .iter()
1456            .filter(|e| e.relation == "uses")
1457            .collect();
1458        assert_eq!(
1459            uses_edges.len(),
1460            2,
1461            "star import should expand to 2 uses edges, got {}",
1462            uses_edges.len()
1463        );
1464    }
1465
1466    // ===== Scala cross-file resolution =====
1467
1468    #[test]
1469    fn scala_cross_file_creates_uses_edges() {
1470        let mut result = ExtractionResult {
1471            nodes: vec![
1472                make_test_node("file_main", "Main", "src/Main.scala", NodeType::File),
1473                make_test_node("main_fn", "main", "src/Main.scala", NodeType::Function),
1474                make_test_node(
1475                    "import_calc",
1476                    "import com.example.Calculator",
1477                    "src/Main.scala",
1478                    NodeType::Module,
1479                ),
1480                make_test_node(
1481                    "file_calc",
1482                    "Calculator",
1483                    "src/Calculator.scala",
1484                    NodeType::File,
1485                ),
1486                make_test_node(
1487                    "calc_class",
1488                    "Calculator",
1489                    "src/Calculator.scala",
1490                    NodeType::Class,
1491                ),
1492            ],
1493            edges: vec![
1494                make_test_edge("file_main", "main_fn", "defines", "src/Main.scala"),
1495                make_test_edge("file_main", "import_calc", "imports", "src/Main.scala"),
1496                make_test_edge("file_calc", "calc_class", "defines", "src/Calculator.scala"),
1497            ],
1498            hyperedges: vec![],
1499        };
1500
1501        resolve_cross_file_imports(&mut result);
1502
1503        let uses_edges: Vec<_> = result
1504            .edges
1505            .iter()
1506            .filter(|e| e.relation == "uses")
1507            .collect();
1508        assert!(
1509            !uses_edges.is_empty(),
1510            "Scala cross-file should create uses edges"
1511        );
1512        assert!(
1513            uses_edges
1514                .iter()
1515                .any(|e| e.source == "main_fn" && e.target == "calc_class")
1516        );
1517    }
1518
1519    // ===== Swift cross-file resolution =====
1520
1521    #[test]
1522    fn swift_cross_file_creates_uses_edges() {
1523        let mut result = ExtractionResult {
1524            nodes: vec![
1525                make_test_node("file_app", "App", "src/App.swift", NodeType::File),
1526                make_test_node("app_fn", "run", "src/App.swift", NodeType::Function),
1527                make_test_node(
1528                    "import_mgr",
1529                    "import UserManager",
1530                    "src/App.swift",
1531                    NodeType::Module,
1532                ),
1533                make_test_node(
1534                    "file_mgr",
1535                    "UserManager",
1536                    "src/UserManager.swift",
1537                    NodeType::File,
1538                ),
1539                make_test_node(
1540                    "mgr_class",
1541                    "UserManager",
1542                    "src/UserManager.swift",
1543                    NodeType::Class,
1544                ),
1545            ],
1546            edges: vec![
1547                make_test_edge("file_app", "app_fn", "defines", "src/App.swift"),
1548                make_test_edge("file_app", "import_mgr", "imports", "src/App.swift"),
1549                make_test_edge("file_mgr", "mgr_class", "defines", "src/UserManager.swift"),
1550            ],
1551            hyperedges: vec![],
1552        };
1553
1554        resolve_cross_file_imports(&mut result);
1555
1556        let uses_edges: Vec<_> = result
1557            .edges
1558            .iter()
1559            .filter(|e| e.relation == "uses")
1560            .collect();
1561        assert!(
1562            !uses_edges.is_empty(),
1563            "Swift cross-file should create uses edges"
1564        );
1565        assert!(
1566            uses_edges
1567                .iter()
1568                .any(|e| e.source == "app_fn" && e.target == "mgr_class")
1569        );
1570    }
1571
1572    // ===== Resolver unit tests =====
1573
1574    #[test]
1575    fn jsts_resolver_strips_alias() {
1576        let mut entities = HashMap::new();
1577        entities.insert(
1578            "utils".to_string(),
1579            vec![("parseDate".into(), "pd_id".into(), NodeType::Function)],
1580        );
1581        // "utils/parseDate as pd" should still resolve to utils entities
1582        let result = resolve_jsts_import("utils/parseDate as pd", &entities);
1583        assert!(!result.is_empty(), "aliased JS import should resolve");
1584    }
1585
1586    #[test]
1587    fn go_resolver_handles_blank_import() {
1588        let mut entities = HashMap::new();
1589        entities.insert(
1590            "driver".to_string(),
1591            vec![("Register".into(), "reg_id".into(), NodeType::Function)],
1592        );
1593        let empty = HashMap::new();
1594        // import _ "database/sql/driver"
1595        let result = resolve_go_import("_ database/sql/driver", &entities, &empty);
1596        assert!(!result.is_empty(), "Go blank import should resolve");
1597    }
1598
1599    #[test]
1600    fn go_resolver_handles_alias_import() {
1601        let mut entities = HashMap::new();
1602        entities.insert(
1603            "http".to_string(),
1604            vec![("Server".into(), "srv_id".into(), NodeType::Struct)],
1605        );
1606        let empty = HashMap::new();
1607        // import h "net/http"
1608        let result = resolve_go_import(r#"h "net/http""#, &entities, &empty);
1609        assert!(!result.is_empty(), "Go aliased import should resolve");
1610    }
1611
1612    #[test]
1613    fn rust_resolver_handles_glob() {
1614        let mut entities = HashMap::new();
1615        entities.insert(
1616            "model".to_string(),
1617            vec![
1618                ("Config".into(), "cfg_id".into(), NodeType::Struct),
1619                ("Database".into(), "db_id".into(), NodeType::Struct),
1620            ],
1621        );
1622        // use crate::model::*
1623        let result = resolve_rust_import("crate::model::*", &entities);
1624        assert_eq!(result.len(), 2, "glob import should return all entities");
1625    }
1626
1627    #[test]
1628    fn dot_resolver_handles_static_import() {
1629        let mut entities = HashMap::new();
1630        entities.insert(
1631            "Math".to_string(),
1632            vec![("sqrt".into(), "sqrt_id".into(), NodeType::Function)],
1633        );
1634        // import static java.lang.Math.sqrt
1635        let result = resolve_dot_import("static java.lang.Math.sqrt", &entities);
1636        assert!(
1637            !result.is_empty(),
1638            "Java static import should resolve: got empty"
1639        );
1640    }
1641
1642    #[test]
1643    fn dot_resolver_handles_csharp_alias() {
1644        let mut entities = HashMap::new();
1645        entities.insert(
1646            "MySqlClient".to_string(),
1647            vec![("Connection".into(), "conn_id".into(), NodeType::Class)],
1648        );
1649        // using MySql = MySql.Data.MySqlClient
1650        let result = resolve_dot_import("MySql = MySql.Data.MySqlClient", &entities);
1651        assert!(
1652            !result.is_empty(),
1653            "C# alias using should resolve: got empty"
1654        );
1655    }
1656
1657    #[test]
1658    fn dart_resolver_handles_relative_import() {
1659        let mut entities = HashMap::new();
1660        entities.insert(
1661            "user".to_string(),
1662            vec![("User".into(), "user_id".into(), NodeType::Class)],
1663        );
1664        let result = resolve_dart_import("import '../models/user.dart'", &entities);
1665        assert!(!result.is_empty(), "Dart relative import should resolve");
1666    }
1667
1668    #[test]
1669    fn dart_resolver_handles_deferred_import() {
1670        let mut entities = HashMap::new();
1671        entities.insert(
1672            "heavy".to_string(),
1673            vec![("compute".into(), "comp_id".into(), NodeType::Function)],
1674        );
1675        let result = resolve_dart_import(
1676            "import 'package:myapp/heavy.dart' deferred as heavy",
1677            &entities,
1678        );
1679        assert!(!result.is_empty(), "Dart deferred import should resolve");
1680    }
1681
1682    #[test]
1683    fn dart_resolver_handles_part_directive() {
1684        let mut entities = HashMap::new();
1685        entities.insert(
1686            "models".to_string(),
1687            vec![("Item".into(), "item_id".into(), NodeType::Class)],
1688        );
1689        let result = resolve_dart_import("part 'src/models.dart'", &entities);
1690        assert!(!result.is_empty(), "Dart part directive should resolve");
1691    }
1692}