Skip to main content

impactsense_parser/
go_resolve.rs

1//! Discover `go.mod` modules and resolve import paths to files on disk.
2use std::collections::HashSet;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use walkdir::WalkDir;
7
8/// One Go module root: `module` path from go.mod and directory containing go.mod.
9#[derive(Debug, Clone)]
10pub struct GoModule {
11    pub module_path: String,
12    pub root_dir: PathBuf,
13}
14
15/// `replace old => ../local` from go.mod: imports under `old/...` resolve under `local_root`.
16#[derive(Debug, Clone)]
17pub struct GoReplace {
18    pub from: String,
19    pub local_root: PathBuf,
20}
21
22/// Walk `root` for `go.mod` files and parse `module` lines.
23pub fn discover_go_modules(root: &Path, follow_symlinks: bool) -> std::io::Result<Vec<GoModule>> {
24    let mut out = Vec::new();
25    let walker = WalkDir::new(root).follow_links(follow_symlinks);
26    for entry in walker {
27        let entry = entry.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
28        if !entry.file_type().is_file() {
29            continue;
30        }
31        if entry.file_name() != "go.mod" {
32            continue;
33        }
34        let path = entry.path();
35        let src = fs::read_to_string(path)?;
36        if let Some(mp) = parse_go_mod_module(&src) {
37            out.push(GoModule {
38                module_path: mp,
39                root_dir: path.parent().unwrap_or(path).to_path_buf(),
40            });
41        }
42    }
43    out.sort_by(|a, b| b.module_path.len().cmp(&a.module_path.len()));
44    Ok(out)
45}
46
47/// Collect `replace` directives from every `go.mod` under `root`.
48pub fn discover_go_replaces(root: &Path, follow_symlinks: bool) -> std::io::Result<Vec<GoReplace>> {
49    let mut out = Vec::new();
50    let walker = WalkDir::new(root).follow_links(follow_symlinks);
51    for entry in walker {
52        let entry = entry.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
53        if !entry.file_type().is_file() || entry.file_name() != "go.mod" {
54            continue;
55        }
56        let path = entry.path();
57        let parent = path.parent().unwrap_or(path);
58        let src = fs::read_to_string(path)?;
59        out.extend(parse_go_mod_replaces(&src, parent));
60    }
61    out.sort_by(|a, b| b.from.len().cmp(&a.from.len()));
62    Ok(out)
63}
64
65fn parse_go_mod_replaces(src: &str, go_mod_parent: &Path) -> Vec<GoReplace> {
66    let mut out = Vec::new();
67    let mut in_replace_block = false;
68    for raw in src.lines() {
69        let line = raw.split("//").next().unwrap_or("").trim();
70        if line.is_empty() {
71            continue;
72        }
73        if line.starts_with("replace (") || line == "replace (" {
74            in_replace_block = true;
75            continue;
76        }
77        if in_replace_block {
78            if line == ")" {
79                in_replace_block = false;
80                continue;
81            }
82            if let Some(rep) = parse_one_replace_line(line, go_mod_parent, false) {
83                out.push(rep);
84            }
85            continue;
86        }
87        if let Some(rest) = line.strip_prefix("replace") {
88            let rest = rest.trim();
89            if rest == "(" {
90                in_replace_block = true;
91                continue;
92            }
93            if let Some(rep) = parse_one_replace_line(rest, go_mod_parent, true) {
94                out.push(rep);
95            }
96        }
97    }
98    out
99}
100
101fn parse_one_replace_line(line: &str, go_mod_parent: &Path, had_replace_keyword: bool) -> Option<GoReplace> {
102    let line = line.trim().trim_end_matches(',');
103    if !line.contains("=>") {
104        return None;
105    }
106    let (lhs, rhs) = line.split_once("=>")?;
107    let mut lhs = lhs.trim();
108    if had_replace_keyword && lhs.starts_with('(') {
109        lhs = lhs.trim_start_matches('(').trim();
110    }
111    let from = strip_optional_module_version(lhs);
112    let rhs = rhs.trim().trim_end_matches(')');
113    let local_root = local_root_from_replace_rhs(go_mod_parent, rhs)?;
114    if from.is_empty() {
115        return None;
116    }
117    Some(GoReplace { from, local_root })
118}
119
120fn strip_optional_module_version(lhs: &str) -> String {
121    let parts: Vec<&str> = lhs.split_whitespace().collect();
122    if parts.len() >= 2 {
123        let v = parts[1];
124        if v.starts_with('v') && v.chars().nth(1).map(|c| c.is_ascii_digit()).unwrap_or(false) {
125            return parts[0].to_string();
126        }
127    }
128    lhs.split_whitespace().next().unwrap_or(lhs).to_string()
129}
130
131fn looks_like_local_replace_path(token: &str) -> bool {
132    let t = token.trim();
133    if t.is_empty() {
134        return false;
135    }
136    if t.contains('/') || t.contains('\\') {
137        return true;
138    }
139    if t.starts_with('.') {
140        return true;
141    }
142    if t.len() >= 3 && t.as_bytes().get(1) == Some(&b':') {
143        return true;
144    }
145    if !t.contains('.') {
146        return true;
147    }
148    false
149}
150
151fn local_root_from_replace_rhs(go_mod_parent: &Path, rhs: &str) -> Option<PathBuf> {
152    let token = rhs.split_whitespace().next()?;
153    if !looks_like_local_replace_path(token) {
154        return None;
155    }
156    let rel = token.trim().trim_start_matches("./");
157    Some(go_mod_parent.join(rel))
158}
159
160fn parse_go_mod_module(src: &str) -> Option<String> {
161    for raw in src.lines() {
162        let line = raw.split("//").next().unwrap_or("").trim();
163        if let Some(rest) = line.strip_prefix("module") {
164            let m = rest.trim().trim_matches('"').trim();
165            if !m.is_empty() {
166                return Some(m.to_string());
167            }
168        }
169    }
170    None
171}
172
173/// First path segment looks like a domain (third-party / vanity), not a local module alias.
174pub fn is_likely_third_party_go_import(import_path: &str) -> bool {
175    let first = import_path.trim().split('/').next().unwrap_or("");
176    first.contains('.')
177}
178
179fn norm_path_slash(p: &str) -> String {
180    p.replace('\\', "/")
181}
182
183fn resolved_path_slash(path: &Path) -> String {
184    let p = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
185    norm_path_slash(&p.display().to_string())
186}
187
188/// Physical absolute path (slash-normalized) for a known file string, which may be repo-relative.
189fn resolved_known_file_path_slash(known_path: &str, repo_root: Option<&Path>) -> String {
190    let p = Path::new(known_path);
191    let joined = if p.is_absolute() {
192        p.to_path_buf()
193    } else if let Some(r) = repo_root {
194        r.join(known_path)
195    } else {
196        p.to_path_buf()
197    };
198    resolved_path_slash(&joined)
199}
200
201fn dir_path_slash(dir: &Path) -> String {
202    resolved_path_slash(dir)
203}
204
205/// True if known path (possibly repo-relative) lies under `dir` on disk.
206fn file_is_under_dir(file_path: &str, dir: &Path, repo_root: Option<&Path>) -> bool {
207    let f = resolved_known_file_path_slash(file_path, repo_root);
208    let d = dir_path_slash(dir);
209    f == d || f.starts_with(&(d.clone() + "/"))
210}
211
212/// Resolve import to a scanned `.go` file path using `replace`, go.mod roots, then substring fallback.
213pub fn resolve_go_import_to_known_go_file(
214    import_path: &str,
215    known_paths: &HashSet<String>,
216    modules: &[GoModule],
217    replaces: &[GoReplace],
218    repo_root: Option<&Path>,
219) -> Option<String> {
220    let norm = import_path.trim().replace('\\', "/");
221
222    for r in replaces {
223        if norm == r.from {
224            if let Some(p) = pick_shortest_go_in_dir(known_paths, &r.local_root, repo_root) {
225                return Some(p);
226            }
227        }
228        let prefix = format!("{}/", r.from);
229        if norm.starts_with(&prefix) {
230            let suffix = &norm[prefix.len()..];
231            let pkg_dir = r
232                .local_root
233                .join(suffix.replace('/', std::path::MAIN_SEPARATOR_STR));
234            if let Some(p) = pick_shortest_go_in_dir(known_paths, &pkg_dir, repo_root) {
235                return Some(p);
236            }
237        }
238    }
239
240    for m in modules {
241        if norm == m.module_path {
242            return pick_shortest_go_in_dir(known_paths, &m.root_dir, repo_root);
243        }
244        let prefix = format!("{}/", m.module_path);
245        if norm.starts_with(&prefix) {
246            let suffix = &norm[prefix.len()..];
247            let pkg_dir = m.root_dir.join(suffix.replace('/', std::path::MAIN_SEPARATOR_STR));
248            if let Some(p) = pick_shortest_go_in_dir(known_paths, &pkg_dir, repo_root) {
249                return Some(p);
250            }
251        }
252    }
253
254    known_paths
255        .iter()
256        .filter(|p| {
257            let pn = norm_path_slash(p);
258            pn.ends_with(".go") && pn.contains(&norm)
259        })
260        .min_by_key(|p| p.len())
261        .cloned()
262}
263
264fn pick_shortest_go_in_dir(
265    known_paths: &HashSet<String>,
266    dir: &Path,
267    repo_root: Option<&Path>,
268) -> Option<String> {
269    known_paths
270        .iter()
271        .filter(|p| p.ends_with(".go") && file_is_under_dir(p, dir, repo_root))
272        .min_by_key(|p| p.len())
273        .cloned()
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use std::collections::HashSet;
280
281    #[test]
282    fn parses_module_line() {
283        let g = r#"
284// comment
285module kronos
286
287go 1.21
288"#;
289        assert_eq!(parse_go_mod_module(g).as_deref(), Some("kronos"));
290    }
291
292    #[test]
293    fn resolves_import_via_module_root() {
294        let tmp = tempfile::tempdir().unwrap();
295        let root = tmp.path().join("kronos-preprod");
296        fs::create_dir_all(root.join("connectors/mongoConnector")).unwrap();
297        let go_file = root.join("connectors/mongoConnector/mongoConnector.go");
298        fs::write(tmp.path().join("kronos-preprod/go.mod"), "module kronos\n").unwrap();
299        fs::write(&go_file, "package mongoConnector\n").unwrap();
300
301        let modules = discover_go_modules(tmp.path(), false).unwrap();
302        assert_eq!(modules.len(), 1);
303        assert_eq!(modules[0].module_path, "kronos");
304
305        let mut known = HashSet::new();
306        let rel = go_file.strip_prefix(tmp.path()).unwrap_or(go_file.as_path());
307        known.insert(rel.to_string_lossy().replace('\\', "/"));
308
309        let resolved = resolve_go_import_to_known_go_file(
310            "kronos/connectors/mongoConnector",
311            &known,
312            &modules,
313            &[],
314            Some(tmp.path()),
315        )
316        .expect("expected go.mod-aware resolution");
317        assert!(resolved.ends_with("mongoConnector.go"));
318        assert!(resolved.contains("connectors"));
319    }
320
321    #[test]
322    fn resolves_import_via_go_mod_replace() {
323        let tmp = tempfile::tempdir().unwrap();
324        let svc = tmp.path().join("kronos-preprod");
325        fs::create_dir_all(svc.join("handlers")).unwrap();
326        let proto_pkg = svc.join("gen/kronos/proto");
327        fs::create_dir_all(&proto_pkg).unwrap();
328        let go_mod = r#"
329module github.com/example/kronos-preprod
330
331go 1.21
332
333replace kronos => ./gen/kronos
334"#;
335        fs::write(svc.join("go.mod"), go_mod).unwrap();
336        let stub = proto_pkg.join("models.pb.go");
337        fs::write(&stub, "package proto\n").unwrap();
338        fs::write(svc.join("handlers/h.go"), "package handlers\n").unwrap();
339
340        let modules = discover_go_modules(tmp.path(), false).unwrap();
341        let replaces = discover_go_replaces(tmp.path(), false).unwrap();
342        assert_eq!(replaces.len(), 1);
343        assert_eq!(replaces[0].from, "kronos");
344
345        let mut known = HashSet::new();
346        let rel = stub.strip_prefix(tmp.path()).unwrap_or(stub.as_path());
347        known.insert(rel.to_string_lossy().replace('\\', "/"));
348
349        let resolved = resolve_go_import_to_known_go_file(
350            "kronos/proto",
351            &known,
352            &modules,
353            &replaces,
354            Some(tmp.path()),
355        )
356            .expect("replace should map kronos/proto to gen/kronos/proto");
357        assert!(resolved.ends_with("models.pb.go"));
358    }
359}