impactsense_parser/
go_resolve.rs1use std::collections::HashSet;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use walkdir::WalkDir;
7
8#[derive(Debug, Clone)]
10pub struct GoModule {
11 pub module_path: String,
12 pub root_dir: PathBuf,
13}
14
15#[derive(Debug, Clone)]
17pub struct GoReplace {
18 pub from: String,
19 pub local_root: PathBuf,
20}
21
22pub 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
47pub 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
173pub 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
188fn 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
205fn 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
212pub 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}