mati_core/analysis/resolvers/
go.rs1use 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 if !import.path.contains('/') {
31 return None;
32 }
33
34 let module_path = self.find_module_path(importing_file, file_index)?;
36
37 let Some(relative) = import.path.strip_prefix(&module_path) else {
39 return None; };
41 let relative = relative.trim_start_matches('/');
43
44 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 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 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 let rest = rest.split("//").next().unwrap_or("");
107 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 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 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 #[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 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 #[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 #[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 #[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 resolution_is_deterministic_across_fresh_indexes() {
260 let dir = TempDir::new().unwrap();
261 let files = [
262 "main.go",
263 "pkg/alpha.go",
264 "pkg/beta.go",
265 "pkg/gamma.go",
266 "pkg/package_test.go",
267 ];
268 setup_go_project(&dir, "github.com/acme/project", &files);
269
270 let resolved: Vec<Option<String>> = (0..64)
271 .map(|_| {
272 let mut paths = vec!["go.mod".to_string()];
273 paths.extend(files.iter().map(|path| (*path).to_string()));
274 let file_index = FileIndex::new_with_root(dir.path().to_path_buf(), paths);
275 GoResolver.resolve(
276 &import("github.com/acme/project/pkg"),
277 "main.go",
278 &file_index,
279 )
280 })
281 .collect();
282
283 assert!(
284 resolved.windows(2).all(|pair| pair[0] == pair[1]),
285 "identical Go imports resolved to different targets: {resolved:?}"
286 );
287 assert_eq!(resolved[0].as_deref(), Some("pkg/alpha.go"));
288 }
289
290 #[test]
291 fn rejects_stdlib_import() {
292 let dir = TempDir::new().unwrap();
293 let file_index = setup_go_project(&dir, "github.com/acme/project", &["main.go"]);
294 assert_eq!(
295 GoResolver.resolve(&import("net/http"), "main.go", &file_index),
296 None,
297 "stdlib multi-segment imports should not resolve"
298 );
299 }
300
301 #[test]
302 fn rejects_third_party_import() {
303 let dir = TempDir::new().unwrap();
304 let file_index = setup_go_project(&dir, "github.com/acme/project", &["main.go"]);
305 assert_eq!(
306 GoResolver.resolve(
307 &import("github.com/other/library/pkg"),
308 "main.go",
309 &file_index,
310 ),
311 None,
312 "third-party imports should not resolve"
313 );
314 }
315
316 #[test]
317 fn skips_test_go_files_in_package_resolution() {
318 let dir = TempDir::new().unwrap();
319 let go_mod = "module github.com/acme/project\n\ngo 1.21\n";
320 std::fs::write(dir.path().join("go.mod"), go_mod).unwrap();
321
322 std::fs::create_dir_all(dir.path().join("auth")).unwrap();
324 std::fs::write(dir.path().join("auth/auth_test.go"), "package auth\n").unwrap();
325
326 let file_index = FileIndex::new_with_root(
327 dir.path().to_path_buf(),
328 vec![
329 "go.mod".to_string(),
330 "main.go".to_string(),
331 "auth/auth_test.go".to_string(),
332 ],
333 );
334
335 let result = GoResolver.resolve(
336 &import("github.com/acme/project/auth"),
337 "main.go",
338 &file_index,
339 );
340 assert_eq!(result, None, "_test.go files should be skipped");
341 }
342
343 #[test]
344 fn resolves_nested_package_import() {
345 let dir = TempDir::new().unwrap();
346 let file_index = setup_go_project(
347 &dir,
348 "github.com/acme/project",
349 &[
350 "main.go",
351 "internal/auth/handler.go",
352 "internal/db/client.go",
353 ],
354 );
355
356 let result = GoResolver.resolve(
357 &import("github.com/acme/project/internal/auth"),
358 "main.go",
359 &file_index,
360 );
361 assert_eq!(result, Some("internal/auth/handler.go".into()));
362
363 let result = GoResolver.resolve(
364 &import("github.com/acme/project/internal/db"),
365 "main.go",
366 &file_index,
367 );
368 assert_eq!(result, Some("internal/db/client.go".into()));
369 }
370
371 #[test]
372 fn no_gomod_returns_none_for_all_multi_segment() {
373 let file_index = idx(&["main.go", "internal/auth/auth.go"]);
375 assert_eq!(
376 GoResolver.resolve(&import("internal/auth"), "main.go", &file_index),
377 None
378 );
379 }
380}