Skip to main content

mati_core/analysis/resolvers/
go.rs

1//! Go import resolver.
2//!
3//! Go imports use fully-qualified module paths (`fmt`, `net/http`,
4//! `github.com/user/repo/pkg`). This resolver parses `go.mod` to find
5//! the module path, then strips it from import paths to resolve internal
6//! imports to repo-relative `.go` files.
7//!
8//! Resolution algorithm:
9//! 1. Walk up from the importing file to find the nearest `go.mod`
10//! 2. Read the `module` declaration to get the module path
11//! 3. If the import starts with the module path, strip the prefix and
12//!    resolve the remainder as a directory containing `.go` files
13//! 4. Stdlib imports (single-segment) and third-party imports (different
14//!    module path) return `None`
15
16use super::{FileIndex, LanguageResolver};
17use crate::analysis::parser::ImportStatement;
18use crate::analysis::walker::Language;
19
20pub struct GoResolver;
21
22impl LanguageResolver for GoResolver {
23    fn resolve(
24        &self,
25        import: &ImportStatement,
26        importing_file: &str,
27        file_index: &FileIndex,
28    ) -> Option<String> {
29        // Single-segment imports are stdlib (fmt, os, io, etc.)
30        if !import.path.contains('/') {
31            return None;
32        }
33
34        // Step 1: Find the nearest go.mod file by walking up from importing_file
35        let module_path = self.find_module_path(importing_file, file_index)?;
36
37        // Step 2: Check if the import starts with the module path
38        let Some(relative) = import.path.strip_prefix(&module_path) else {
39            return None; // external (stdlib, third-party, or unknown module)
40        };
41        // strip_prefix might leave a leading "/" or be empty for the root package
42        let relative = relative.trim_start_matches('/');
43
44        // Step 3: Resolve to any non-test .go file in the target directory
45        let prefix = if relative.is_empty() {
46            String::new()
47        } else {
48            format!("{relative}/")
49        };
50        let candidates = file_index.files_with_prefix(&prefix);
51        candidates
52            .into_iter()
53            .find(|f| f.ends_with(".go") && !f.ends_with("_test.go"))
54            .cloned()
55    }
56
57    fn language(&self) -> Language {
58        Language::Go
59    }
60
61    fn name(&self) -> &'static str {
62        "go"
63    }
64}
65
66impl Default for GoResolver {
67    fn default() -> Self {
68        Self
69    }
70}
71
72impl GoResolver {
73    pub fn new() -> Self {
74        Self
75    }
76
77    /// Walk up from the importing file's directory looking for go.mod,
78    /// then read its module declaration. Returns None if no go.mod is
79    /// found or it has no module line.
80    fn find_module_path(&self, importing_file: &str, file_index: &FileIndex) -> Option<String> {
81        use std::path::Path;
82        let mut current = Path::new(importing_file).parent();
83        while let Some(dir) = current {
84            let candidate = if dir.as_os_str().is_empty() {
85                "go.mod".to_string()
86            } else {
87                format!("{}/go.mod", dir.display())
88            };
89            if file_index.contains(&candidate) {
90                if let Some(content) = file_index.read_file(&candidate) {
91                    return Self::parse_module_line(&content);
92                }
93            }
94            current = dir.parent();
95        }
96        None
97    }
98
99    /// Parse the module line from a go.mod file.
100    /// Example: "module github.com/acme/project" -> "github.com/acme/project"
101    fn parse_module_line(content: &str) -> Option<String> {
102        for line in content.lines() {
103            let trimmed = line.trim();
104            if let Some(rest) = trimmed.strip_prefix("module ") {
105                // Strip optional trailing comment
106                let rest = rest.split("//").next().unwrap_or("");
107                // Strip optional quotes (Go supports `module "foo"`)
108                let rest = rest.trim().trim_matches('"');
109                if !rest.is_empty() {
110                    return Some(rest.to_string());
111                }
112            }
113        }
114        None
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::analysis::parser::import::ImportKind;
122    use tempfile::TempDir;
123
124    fn idx(paths: &[&str]) -> FileIndex {
125        FileIndex::new(paths.iter().map(|s| s.to_string()))
126    }
127
128    fn import(path: &str) -> ImportStatement {
129        ImportStatement::new(path, ImportKind::Normal, 1)
130    }
131
132    /// Create a go.mod file in a TempDir and return a FileIndex with root set.
133    fn setup_go_project(dir: &TempDir, module_name: &str, files: &[&str]) -> FileIndex {
134        let go_mod_content = format!("module {module_name}\n\ngo 1.21\n");
135        std::fs::write(dir.path().join("go.mod"), &go_mod_content).unwrap();
136
137        // Create all the Go files
138        for file in files {
139            let path = dir.path().join(file);
140            if let Some(parent) = path.parent() {
141                std::fs::create_dir_all(parent).unwrap();
142            }
143            std::fs::write(&path, "package main\n").unwrap();
144        }
145
146        let mut all_files: Vec<String> = vec!["go.mod".to_string()];
147        all_files.extend(files.iter().map(|s| s.to_string()));
148        FileIndex::new_with_root(dir.path().to_path_buf(), all_files)
149    }
150
151    // ── Preserved original tests ───────────────────────────────────────────
152
153    #[test]
154    fn stdlib_single_segment_skipped() {
155        let file_index = idx(&["main.go"]);
156        assert_eq!(
157            GoResolver.resolve(&import("fmt"), "main.go", &file_index),
158            None
159        );
160    }
161
162    #[test]
163    fn external_domain_skipped_without_gomod() {
164        // Without go.mod readable, all multi-segment imports return None
165        let file_index = idx(&["main.go"]);
166        assert_eq!(
167            GoResolver.resolve(&import("github.com/user/pkg"), "main.go", &file_index),
168            None
169        );
170    }
171
172    #[test]
173    fn nonexistent_package_returns_none() {
174        let file_index = idx(&["main.go"]);
175        assert_eq!(
176            GoResolver.resolve(&import("internal/missing"), "main.go", &file_index),
177            None
178        );
179    }
180
181    // ── go.mod parsing tests ───────────────────────────────────────────────
182
183    #[test]
184    fn parses_simple_gomod_module_line() {
185        let content = "module github.com/acme/project\n\ngo 1.21\n";
186        assert_eq!(
187            GoResolver::parse_module_line(content),
188            Some("github.com/acme/project".into())
189        );
190    }
191
192    #[test]
193    fn parses_gomod_with_comment_on_module_line() {
194        let content = "module github.com/acme/project // my project\n\ngo 1.21\n";
195        assert_eq!(
196            GoResolver::parse_module_line(content),
197            Some("github.com/acme/project".into())
198        );
199    }
200
201    #[test]
202    fn parses_gomod_with_require_block() {
203        let content = "module github.com/acme/project\n\ngo 1.21\n\nrequire (\n\tgithub.com/pkg/errors v0.9.1\n)\n";
204        assert_eq!(
205            GoResolver::parse_module_line(content),
206            Some("github.com/acme/project".into())
207        );
208    }
209
210    #[test]
211    fn parses_gomod_with_quoted_module() {
212        let content = "module \"github.com/acme/project\"\n\ngo 1.21\n";
213        assert_eq!(
214            GoResolver::parse_module_line(content),
215            Some("github.com/acme/project".into())
216        );
217    }
218
219    // ── go.mod discovery tests ─────────────────────────────────────────────
220
221    #[test]
222    fn walks_up_from_nested_file_to_find_gomod() {
223        let dir = TempDir::new().unwrap();
224        let file_index = setup_go_project(
225            &dir,
226            "github.com/acme/project",
227            &["cmd/server/main.go", "internal/auth/auth.go"],
228        );
229
230        let module = GoResolver.find_module_path("cmd/server/main.go", &file_index);
231        assert_eq!(module, Some("github.com/acme/project".into()));
232    }
233
234    // ── Resolution with go.mod ─────────────────────────────────────────────
235
236    #[test]
237    fn resolves_internal_import_with_module_path_prefix() {
238        let dir = TempDir::new().unwrap();
239        let file_index = setup_go_project(
240            &dir,
241            "github.com/acme/project",
242            &["main.go", "auth/auth.go", "auth/token.go"],
243        );
244
245        let result = GoResolver.resolve(
246            &import("github.com/acme/project/auth"),
247            "main.go",
248            &file_index,
249        );
250        assert!(result.is_some(), "should resolve internal import");
251        let resolved = result.unwrap();
252        assert!(
253            resolved.starts_with("auth/") && resolved.ends_with(".go"),
254            "expected auth/*.go, got: {resolved}"
255        );
256    }
257
258    #[test]
259    fn rejects_stdlib_import() {
260        let dir = TempDir::new().unwrap();
261        let file_index = setup_go_project(&dir, "github.com/acme/project", &["main.go"]);
262        assert_eq!(
263            GoResolver.resolve(&import("net/http"), "main.go", &file_index),
264            None,
265            "stdlib multi-segment imports should not resolve"
266        );
267    }
268
269    #[test]
270    fn rejects_third_party_import() {
271        let dir = TempDir::new().unwrap();
272        let file_index = setup_go_project(&dir, "github.com/acme/project", &["main.go"]);
273        assert_eq!(
274            GoResolver.resolve(
275                &import("github.com/other/library/pkg"),
276                "main.go",
277                &file_index,
278            ),
279            None,
280            "third-party imports should not resolve"
281        );
282    }
283
284    #[test]
285    fn skips_test_go_files_in_package_resolution() {
286        let dir = TempDir::new().unwrap();
287        let go_mod = "module github.com/acme/project\n\ngo 1.21\n";
288        std::fs::write(dir.path().join("go.mod"), go_mod).unwrap();
289
290        // Only _test.go files in the package
291        std::fs::create_dir_all(dir.path().join("auth")).unwrap();
292        std::fs::write(dir.path().join("auth/auth_test.go"), "package auth\n").unwrap();
293
294        let file_index = FileIndex::new_with_root(
295            dir.path().to_path_buf(),
296            vec![
297                "go.mod".to_string(),
298                "main.go".to_string(),
299                "auth/auth_test.go".to_string(),
300            ],
301        );
302
303        let result = GoResolver.resolve(
304            &import("github.com/acme/project/auth"),
305            "main.go",
306            &file_index,
307        );
308        assert_eq!(result, None, "_test.go files should be skipped");
309    }
310
311    #[test]
312    fn resolves_nested_package_import() {
313        let dir = TempDir::new().unwrap();
314        let file_index = setup_go_project(
315            &dir,
316            "github.com/acme/project",
317            &[
318                "main.go",
319                "internal/auth/handler.go",
320                "internal/db/client.go",
321            ],
322        );
323
324        let result = GoResolver.resolve(
325            &import("github.com/acme/project/internal/auth"),
326            "main.go",
327            &file_index,
328        );
329        assert_eq!(result, Some("internal/auth/handler.go".into()));
330
331        let result = GoResolver.resolve(
332            &import("github.com/acme/project/internal/db"),
333            "main.go",
334            &file_index,
335        );
336        assert_eq!(result, Some("internal/db/client.go".into()));
337    }
338
339    #[test]
340    fn no_gomod_returns_none_for_all_multi_segment() {
341        // Without go.mod in the index, nothing can resolve
342        let file_index = idx(&["main.go", "internal/auth/auth.go"]);
343        assert_eq!(
344            GoResolver.resolve(&import("internal/auth"), "main.go", &file_index),
345            None
346        );
347    }
348}