the_code_graph_parser/resolver/
go.rs1use std::path::{Path, PathBuf};
2
3use domain::model::{Edge, EdgeKind, Language};
4
5use super::{ImportResolver, ResolveContext};
6use crate::ParseResult;
7
8pub 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
21pub struct GoResolver {
23 config: GoConfig,
24}
25
26impl GoResolver {
27 pub fn new(config: GoConfig) -> Self {
28 Self { config }
29 }
30}
31
32fn 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
48fn is_stdlib(import_path: &str) -> bool {
51 let first = import_path.split('/').next().unwrap_or("");
52 !first.contains('.')
53}
54
55fn 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 let relative = import_path[module_path.len()..].trim_start_matches('/');
68 let dir = project_root.join(relative);
69
70 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 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 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 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 if is_stdlib(specifier) {
131 continue;
132 }
133
134 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 }
151
152 Ok(edges)
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use std::collections::HashMap;
160
161 #[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 #[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 #[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 #[test]
292 fn resolves_local_import_to_directory() {
293 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 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 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 crate::RawImport {
399 specifier: "fmt".into(),
400 ..Default::default()
401 },
402 crate::RawImport {
404 specifier: "github.com/external/lib".into(),
405 ..Default::default()
406 },
407 crate::RawImport {
409 specifier: "github.com/lib/pq".into(),
410 is_side_effect: true,
411 ..Default::default()
412 },
413 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 #[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 #[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 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}