Skip to main content

the_code_graph_parser/resolver/
go.rs

1use std::path::{Path, PathBuf};
2
3use domain::model::{Edge, EdgeKind, Language};
4
5use super::{ImportResolver, ResolveContext};
6use crate::ParseResult;
7
8/// Configuration for the Go resolver, loaded from go.mod.
9pub struct GoConfig {
10    pub module_path: Option<String>,
11}
12
13impl GoConfig {
14    pub fn load(project_root: &Path) -> Self {
15        GoConfig {
16            module_path: parse_go_mod(project_root),
17        }
18    }
19}
20
21/// Go import resolver — go.mod + module path resolution.
22pub struct GoResolver {
23    config: GoConfig,
24}
25
26impl GoResolver {
27    pub fn new(config: GoConfig) -> Self {
28        Self { config }
29    }
30}
31
32/// Parse go.mod from the filesystem and extract the module path.
33fn parse_go_mod(project_root: &Path) -> Option<String> {
34    let go_mod_path = project_root.join("go.mod");
35    let contents = std::fs::read_to_string(go_mod_path).ok()?;
36    for line in contents.lines() {
37        let trimmed = line.trim();
38        if let Some(rest) = trimmed.strip_prefix("module ") {
39            let module_path = rest.trim();
40            if !module_path.is_empty() {
41                return Some(module_path.to_string());
42            }
43        }
44    }
45    None
46}
47
48/// Check if an import path is from the Go standard library.
49/// Standard library packages have a first path element with no dots.
50fn is_stdlib(import_path: &str) -> bool {
51    let first = import_path.split('/').next().unwrap_or("");
52    !first.contains('.')
53}
54
55/// Resolve a local module import path to a directory in the project.
56/// Returns the resolved directory PathBuf if the import is local and files exist under it.
57fn resolve_local_import(
58    import_path: &str,
59    module_path: &str,
60    project_root: &Path,
61    file_tree: &[PathBuf],
62) -> Option<PathBuf> {
63    if !import_path.starts_with(module_path) {
64        return None;
65    }
66    // Strip module prefix and leading slash
67    let relative = import_path[module_path.len()..].trim_start_matches('/');
68    let dir = project_root.join(relative);
69
70    // Check if any file in file_tree lives under this directory
71    let has_files = file_tree.iter().any(|p| p.starts_with(&dir));
72    if has_files {
73        Some(dir)
74    } else {
75        None
76    }
77}
78
79impl ImportResolver for GoResolver {
80    fn languages(&self) -> &[Language] {
81        &[Language::Go]
82    }
83
84    fn resolve(
85        &self,
86        file_path: &Path,
87        parse_result: &ParseResult,
88        context: &ResolveContext,
89    ) -> domain::error::Result<Vec<Edge>> {
90        let source = file_path.to_string_lossy().to_string();
91
92        // Try to read go.mod; if absent we can still handle side-effect/dot imports
93        let module_path = self.config.module_path.clone();
94
95        let mut edges = Vec::new();
96
97        for import in &parse_result.imports {
98            let specifier = &import.specifier;
99
100            // Blank imports → SideEffectImport (always, regardless of path type)
101            if import.is_side_effect {
102                edges.push(Edge {
103                    kind: EdgeKind::SideEffectImport,
104                    source: source.clone(),
105                    target: specifier.clone(),
106                    metadata: None,
107                });
108                continue;
109            }
110
111            // Dot imports → DotImport
112            if import.is_namespace {
113                let target = if let Some(ref mp) = module_path {
114                    resolve_local_import(specifier, mp, &context.project_root, &context.file_tree)
115                        .map(|p| p.to_string_lossy().to_string())
116                        .unwrap_or_else(|| specifier.clone())
117                } else {
118                    specifier.clone()
119                };
120                edges.push(Edge {
121                    kind: EdgeKind::DotImport,
122                    source: source.clone(),
123                    target,
124                    metadata: None,
125                });
126                continue;
127            }
128
129            // Standard library → skip
130            if is_stdlib(specifier) {
131                continue;
132            }
133
134            // Local module import
135            if let Some(ref mp) = module_path {
136                if let Some(resolved) =
137                    resolve_local_import(specifier, mp, &context.project_root, &context.file_tree)
138                {
139                    edges.push(Edge {
140                        kind: EdgeKind::ImportsFrom,
141                        source: source.clone(),
142                        target: resolved.to_string_lossy().to_string(),
143                        metadata: None,
144                    });
145                    continue;
146                }
147            }
148
149            // External (third-party) → skip
150        }
151
152        Ok(edges)
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use std::collections::HashMap;
160
161    // ---------------------------------------------------------------------------
162    // AC46: Skip stdlib imports
163    // ---------------------------------------------------------------------------
164
165    #[test]
166    fn skips_stdlib_imports() {
167        let resolver = GoResolver::new(GoConfig { module_path: None });
168        let parse_result = ParseResult {
169            imports: vec![crate::RawImport {
170                specifier: "fmt".into(),
171                ..Default::default()
172            }],
173            ..Default::default()
174        };
175        let context = ResolveContext {
176            project_root: "/nonexistent".into(),
177            parsed_files: HashMap::new(),
178            file_tree: vec![],
179        };
180        let edges = resolver
181            .resolve(Path::new("main.go"), &parse_result, &context)
182            .unwrap();
183        assert!(edges.is_empty());
184    }
185
186    #[test]
187    fn skips_stdlib_multipart_path() {
188        let resolver = GoResolver::new(GoConfig { module_path: None });
189        let parse_result = ParseResult {
190            imports: vec![crate::RawImport {
191                specifier: "net/http".into(),
192                ..Default::default()
193            }],
194            ..Default::default()
195        };
196        let context = ResolveContext {
197            project_root: "/nonexistent".into(),
198            parsed_files: HashMap::new(),
199            file_tree: vec![],
200        };
201        let edges = resolver
202            .resolve(Path::new("main.go"), &parse_result, &context)
203            .unwrap();
204        assert!(edges.is_empty(), "net/http is stdlib and must be skipped");
205    }
206
207    // ---------------------------------------------------------------------------
208    // AC47: SideEffectImport for blank imports
209    // ---------------------------------------------------------------------------
210
211    #[test]
212    fn creates_side_effect_import_edge() {
213        let resolver = GoResolver::new(GoConfig { module_path: None });
214        let parse_result = ParseResult {
215            imports: vec![crate::RawImport {
216                specifier: "github.com/lib/pq".into(),
217                is_side_effect: true,
218                ..Default::default()
219            }],
220            ..Default::default()
221        };
222        let context = ResolveContext {
223            project_root: "/project".into(),
224            parsed_files: HashMap::new(),
225            file_tree: vec![],
226        };
227        let edges = resolver
228            .resolve(Path::new("main.go"), &parse_result, &context)
229            .unwrap();
230        assert_eq!(edges.len(), 1);
231        assert_eq!(edges[0].kind, EdgeKind::SideEffectImport);
232        assert_eq!(edges[0].source, "main.go");
233        assert_eq!(edges[0].target, "github.com/lib/pq");
234    }
235
236    #[test]
237    fn side_effect_import_targets_raw_specifier() {
238        let resolver = GoResolver::new(GoConfig { module_path: None });
239        let parse_result = ParseResult {
240            imports: vec![crate::RawImport {
241                specifier: "database/sql".into(),
242                is_side_effect: true,
243                ..Default::default()
244            }],
245            ..Default::default()
246        };
247        let context = ResolveContext {
248            project_root: "/project".into(),
249            parsed_files: HashMap::new(),
250            file_tree: vec![],
251        };
252        let edges = resolver
253            .resolve(Path::new("cmd/app/main.go"), &parse_result, &context)
254            .unwrap();
255        assert_eq!(edges.len(), 1);
256        assert_eq!(edges[0].kind, EdgeKind::SideEffectImport);
257        assert_eq!(edges[0].source, "cmd/app/main.go");
258    }
259
260    // ---------------------------------------------------------------------------
261    // AC48: DotImport for dot imports
262    // ---------------------------------------------------------------------------
263
264    #[test]
265    fn creates_dot_import_edge() {
266        let resolver = GoResolver::new(GoConfig { module_path: None });
267        let parse_result = ParseResult {
268            imports: vec![crate::RawImport {
269                specifier: "github.com/some/pkg".into(),
270                is_namespace: true,
271                ..Default::default()
272            }],
273            ..Default::default()
274        };
275        let context = ResolveContext {
276            project_root: "/project".into(),
277            parsed_files: HashMap::new(),
278            file_tree: vec![],
279        };
280        let edges = resolver
281            .resolve(Path::new("main.go"), &parse_result, &context)
282            .unwrap();
283        assert_eq!(edges.len(), 1);
284        assert_eq!(edges[0].kind, EdgeKind::DotImport);
285    }
286
287    // ---------------------------------------------------------------------------
288    // AC44 + AC45: go.mod parsing and local import resolution
289    // ---------------------------------------------------------------------------
290
291    #[test]
292    fn resolves_local_import_to_directory() {
293        // Set up a temporary directory with go.mod
294        let tmp = tempfile::tempdir().unwrap();
295        let project_root = tmp.path().to_path_buf();
296        std::fs::write(
297            project_root.join("go.mod"),
298            "module github.com/myorg/myapp\n\ngo 1.21\n",
299        )
300        .unwrap();
301        let resolver = GoResolver::new(GoConfig::load(&project_root));
302
303        // Simulate a file tree with files under internal/store
304        let store_dir = project_root.join("internal").join("store");
305        let store_file = store_dir.join("store.go");
306
307        let parse_result = ParseResult {
308            imports: vec![crate::RawImport {
309                specifier: "github.com/myorg/myapp/internal/store".into(),
310                ..Default::default()
311            }],
312            ..Default::default()
313        };
314        let context = ResolveContext {
315            project_root: project_root.clone(),
316            parsed_files: HashMap::new(),
317            file_tree: vec![store_file.clone()],
318        };
319
320        let edges = resolver
321            .resolve(Path::new("main.go"), &parse_result, &context)
322            .unwrap();
323
324        assert_eq!(edges.len(), 1);
325        assert_eq!(edges[0].kind, EdgeKind::ImportsFrom);
326        assert_eq!(edges[0].source, "main.go");
327        assert_eq!(edges[0].target, store_dir.to_string_lossy());
328    }
329
330    #[test]
331    fn skips_external_third_party_imports() {
332        let tmp = tempfile::tempdir().unwrap();
333        let project_root = tmp.path().to_path_buf();
334        std::fs::write(
335            project_root.join("go.mod"),
336            "module github.com/myorg/myapp\n\ngo 1.21\n",
337        )
338        .unwrap();
339        let resolver = GoResolver::new(GoConfig::load(&project_root));
340
341        let parse_result = ParseResult {
342            imports: vec![crate::RawImport {
343                specifier: "github.com/some/external".into(),
344                ..Default::default()
345            }],
346            ..Default::default()
347        };
348        let context = ResolveContext {
349            project_root,
350            parsed_files: HashMap::new(),
351            file_tree: vec![],
352        };
353
354        let edges = resolver
355            .resolve(Path::new("main.go"), &parse_result, &context)
356            .unwrap();
357        assert!(edges.is_empty(), "external imports must be skipped");
358    }
359
360    #[test]
361    fn returns_empty_when_go_mod_missing() {
362        let resolver = GoResolver::new(GoConfig { module_path: None });
363        let parse_result = ParseResult {
364            imports: vec![crate::RawImport {
365                specifier: "github.com/org/repo/pkg".into(),
366                ..Default::default()
367            }],
368            ..Default::default()
369        };
370        let context = ResolveContext {
371            project_root: "/nonexistent_project".into(),
372            parsed_files: HashMap::new(),
373            file_tree: vec![PathBuf::from("/nonexistent_project/pkg/file.go")],
374        };
375        let edges = resolver
376            .resolve(Path::new("main.go"), &parse_result, &context)
377            .unwrap();
378        // Without go.mod we can't resolve module-relative paths, so external is skipped
379        assert!(edges.is_empty());
380    }
381
382    #[test]
383    fn multiple_imports_mixed_types() {
384        let tmp = tempfile::tempdir().unwrap();
385        let project_root = tmp.path().to_path_buf();
386        std::fs::write(
387            project_root.join("go.mod"),
388            "module github.com/myorg/myapp\n\ngo 1.21\n",
389        )
390        .unwrap();
391        let resolver = GoResolver::new(GoConfig::load(&project_root));
392
393        let util_file = project_root.join("util").join("util.go");
394
395        let parse_result = ParseResult {
396            imports: vec![
397                // stdlib → skip
398                crate::RawImport {
399                    specifier: "fmt".into(),
400                    ..Default::default()
401                },
402                // external → skip
403                crate::RawImport {
404                    specifier: "github.com/external/lib".into(),
405                    ..Default::default()
406                },
407                // side effect
408                crate::RawImport {
409                    specifier: "github.com/lib/pq".into(),
410                    is_side_effect: true,
411                    ..Default::default()
412                },
413                // local
414                crate::RawImport {
415                    specifier: "github.com/myorg/myapp/util".into(),
416                    ..Default::default()
417                },
418            ],
419            ..Default::default()
420        };
421        let context = ResolveContext {
422            project_root: project_root.clone(),
423            parsed_files: HashMap::new(),
424            file_tree: vec![util_file],
425        };
426
427        let edges = resolver
428            .resolve(Path::new("main.go"), &parse_result, &context)
429            .unwrap();
430
431        assert_eq!(edges.len(), 2, "expected side-effect + local edges only");
432        assert!(edges.iter().any(|e| e.kind == EdgeKind::SideEffectImport));
433        assert!(edges.iter().any(|e| e.kind == EdgeKind::ImportsFrom));
434    }
435
436    // ---------------------------------------------------------------------------
437    // is_stdlib helper unit tests
438    // ---------------------------------------------------------------------------
439
440    #[test]
441    fn is_stdlib_detects_standard_packages() {
442        assert!(is_stdlib("fmt"));
443        assert!(is_stdlib("net/http"));
444        assert!(is_stdlib("encoding/json"));
445        assert!(is_stdlib("os"));
446        assert!(is_stdlib("sync"));
447    }
448
449    #[test]
450    fn is_stdlib_rejects_third_party() {
451        assert!(!is_stdlib("github.com/lib/pq"));
452        assert!(!is_stdlib("golang.org/x/sync/errgroup"));
453        assert!(!is_stdlib("gopkg.in/yaml.v3"));
454    }
455
456    // ---------------------------------------------------------------------------
457    // parse_go_mod unit tests
458    // ---------------------------------------------------------------------------
459
460    #[test]
461    fn parse_go_mod_extracts_module_path() {
462        let tmp = tempfile::tempdir().unwrap();
463        let project_root = tmp.path();
464        std::fs::write(
465            project_root.join("go.mod"),
466            "module github.com/myorg/myapp\n\ngo 1.21\n",
467        )
468        .unwrap();
469        let result = parse_go_mod(project_root);
470        assert_eq!(result, Some("github.com/myorg/myapp".to_string()));
471    }
472
473    #[test]
474    fn parse_go_mod_returns_none_when_missing() {
475        let result = parse_go_mod(Path::new("/nonexistent_dir_xyz"));
476        assert_eq!(result, None);
477    }
478
479    #[test]
480    fn parse_go_mod_handles_whitespace() {
481        let tmp = tempfile::tempdir().unwrap();
482        let project_root = tmp.path();
483        std::fs::write(
484            project_root.join("go.mod"),
485            "  module   github.com/example/repo  \n\ngo 1.20\n",
486        )
487        .unwrap();
488        // The trimmed line starts with "module " only if after trim it starts that way
489        // Our impl trims the whole line, so "module   github.com/..." → strip_prefix("module ")
490        // gives "  github.com/..." → trim → "github.com/..."
491        let result = parse_go_mod(project_root);
492        assert_eq!(result, Some("github.com/example/repo".to_string()));
493    }
494}
495
496#[cfg(test)]
497mod config_tests {
498    use super::*;
499
500    #[test]
501    fn go_config_loads_module_path() {
502        let dir = tempfile::tempdir().unwrap();
503        std::fs::write(
504            dir.path().join("go.mod"),
505            "module github.com/example/app\n\ngo 1.21\n",
506        )
507        .unwrap();
508        let config = GoConfig::load(dir.path());
509        assert_eq!(
510            config.module_path.as_deref(),
511            Some("github.com/example/app")
512        );
513    }
514
515    #[test]
516    fn go_config_none_without_go_mod() {
517        let dir = tempfile::tempdir().unwrap();
518        let config = GoConfig::load(dir.path());
519        assert!(config.module_path.is_none());
520    }
521}