1use std::path::{Path, PathBuf};
5
6use ignore::WalkBuilder;
7
8use crate::model::Language;
9
10const TS_EXTS: &[&str] = &[".ts", ".tsx", ".d.ts", ".js", ".jsx", ".mjs", ".cjs"];
11
12#[derive(Debug, Default, Clone)]
13pub struct RepoPackages {
14 root: PathBuf,
15 tsconfigs: Vec<TsConfig>,
17 py_roots: Vec<String>,
19 ts_pkgs: Vec<TsPackage>,
21 go_mods: Vec<(String, String)>,
23 crates: Vec<(String, String)>,
25}
26
27#[derive(Debug, Clone)]
28struct TsConfig {
29 dir: String,
31 base: Option<String>,
33 paths: Vec<(String, Vec<String>)>,
34}
35
36#[derive(Debug, Clone)]
37struct TsPackage {
38 dir: String,
40 name: String,
41 entry: Option<String>,
43}
44
45impl RepoPackages {
46 pub fn discover(root: &Path) -> Self {
48 let mut py_roots = vec![String::new()];
49 let mut ts_pkgs = Vec::new();
50 let mut tsconfigs = Vec::new();
51 let mut go_mods = Vec::new();
52 let mut crates = Vec::new();
53 let manifests = WalkBuilder::new(root)
54 .hidden(false)
55 .filter_entry(|e| e.file_name() != "node_modules")
56 .build()
57 .filter_map(|e| e.ok())
58 .filter(|e| {
59 let n = e.file_name();
60 n == "pyproject.toml"
61 || n == "package.json"
62 || n == "tsconfig.json"
63 || n == "jsconfig.json"
64 || n == "go.mod"
65 || n == "Cargo.toml"
66 });
67 for m in manifests {
68 let Ok(rel_dir) = m.path().parent().unwrap_or(root).strip_prefix(root) else {
69 continue;
70 };
71 let dir = rel_dir.to_string_lossy().replace('\\', "/");
72 let Ok(text) = std::fs::read_to_string(m.path()) else {
73 continue;
74 };
75 if m.file_name() == "pyproject.toml" {
76 py_roots.extend(python_roots(root, &dir, &text));
77 } else if m.file_name() == "Cargo.toml" {
78 if let Some(name) = text
79 .parse::<toml::Table>()
80 .ok()
81 .and_then(|v| v.get("package")?.get("name")?.as_str().map(str::to_string))
82 {
83 crates.push((dir, name.replace('-', "_")));
84 }
85 } else if m.file_name() == "go.mod" {
86 if let Some(module) = text.lines().find_map(|l| l.trim().strip_prefix("module ")) {
87 go_mods.push((dir, module.trim().trim_matches('"').to_string()));
88 }
89 } else if m.file_name() == "package.json" {
90 ts_pkgs.extend(ts_package(root, &dir, &text));
91 } else {
92 tsconfigs.extend(ts_config(root, &dir, &text));
93 }
94 }
95 if root.join("src").is_dir() {
96 py_roots.push("src".into());
97 }
98 py_roots.sort_by_key(|r| std::cmp::Reverse(r.len()));
99 py_roots.dedup();
100 ts_pkgs.sort_by_key(|p| std::cmp::Reverse(p.dir.len()));
101 tsconfigs.sort_by_key(|c: &TsConfig| std::cmp::Reverse(c.dir.len()));
102 go_mods.sort_by_key(|(d, _)| std::cmp::Reverse(d.len()));
103 crates.sort_by_key(|(d, _)| std::cmp::Reverse(d.len()));
104 RepoPackages {
105 root: root.to_path_buf(),
106 go_mods,
107 crates,
108 tsconfigs,
109 py_roots,
110 ts_pkgs,
111 }
112 }
113
114 pub fn ts_alias(&self, from: &str, spec: &str) -> Option<String> {
116 let cfg = self.tsconfigs.iter().find(|c| under(from, &c.dir))?;
117 let base = cfg.base.as_deref().unwrap_or(&cfg.dir);
118 for (pat, targets) in &cfg.paths {
119 let star = match pat.split_once('*') {
120 Some((pre, post)) => spec
121 .strip_prefix(pre)
122 .and_then(|r| r.strip_suffix(post))
123 .map(str::to_string),
124 None => (pat == spec).then(String::new),
125 };
126 let Some(star) = star else { continue };
127 for t in targets {
128 if let Some(f) = self.existing(&join(base, &t.replace('*', &star))) {
129 return Some(f);
130 }
131 }
132 }
133 cfg.base
134 .as_deref()
135 .and_then(|b| self.existing(&join(b, spec)))
136 }
137
138 fn existing(&self, stem: &str) -> Option<String> {
140 let stem = strip_ts_ext(stem);
141 [
142 ".ts",
143 ".tsx",
144 ".d.ts",
145 ".js",
146 ".jsx",
147 ".mjs",
148 "/index.ts",
149 "/index.tsx",
150 "/index.js",
151 ]
152 .iter()
153 .map(|e| format!("{stem}{e}"))
154 .find(|p| self.root.join(p).is_file())
155 }
156
157 pub fn module_names(&self, rel: &str, lang: &Language) -> Vec<String> {
159 match lang {
160 Language::Python => self.python_module(rel).into_iter().collect(),
161 Language::TypeScript | Language::JavaScript => self.ts_specifiers(rel),
162 Language::Go => self.go_package(rel).into_iter().collect(),
163 Language::Rust => self.rust_module(rel).into_iter().collect(),
164 _ => Vec::new(),
165 }
166 }
167
168 fn python_module(&self, rel: &str) -> Option<String> {
169 let root = self.py_roots.iter().find(|r| under(rel, r))?;
170 let tail = rel.strip_prefix(root.as_str())?.trim_start_matches('/');
171 let stem = tail
172 .strip_suffix(".pyi")
173 .or_else(|| tail.strip_suffix(".py"))?;
174 let stem = stem.strip_suffix("/__init__").unwrap_or(stem);
175 (stem != "__init__").then(|| stem.replace('/', "."))
176 }
177
178 fn rust_module(&self, rel: &str) -> Option<String> {
180 let (dir, name) = self.crates.iter().find(|(d, _)| under(rel, d))?;
181 let tail = rel.strip_prefix(dir.as_str())?.trim_start_matches('/');
182 let tail = tail.strip_prefix("src/")?;
183 if tail.starts_with("bin/") {
184 return None;
185 }
186 if matches!(tail, "lib.rs" | "main.rs") {
187 return Some(name.clone());
188 }
189 let stem = tail.strip_suffix(".rs")?;
190 let stem = stem.strip_suffix("/mod").unwrap_or(stem);
191 Some(format!("{name}::{}", stem.replace('/', "::")))
192 }
193
194 fn go_package(&self, rel: &str) -> Option<String> {
196 let (dir, module) = self.go_mods.iter().find(|(d, _)| under(rel, d))?;
197 let file_dir = rel.rsplit_once('/').map_or("", |(d, _)| d);
198 let tail = file_dir.strip_prefix(dir.as_str())?.trim_start_matches('/');
199 Some(if tail.is_empty() {
200 module.clone()
201 } else {
202 format!("{module}/{tail}")
203 })
204 }
205
206 fn ts_specifiers(&self, rel: &str) -> Vec<String> {
207 let Some(pkg) = self.ts_pkgs.iter().find(|p| under(rel, &p.dir)) else {
208 return Vec::new();
209 };
210 let tail = rel.strip_prefix(pkg.dir.as_str()).unwrap_or(rel);
211 let stem = strip_ts_ext(tail.trim_start_matches('/'));
212 let mut out = vec![format!("{}/{}", pkg.name, stem)];
213 if let Some(dir) = stem.strip_suffix("/index") {
214 out.push(format!("{}/{}", pkg.name, dir));
215 }
216 if pkg.entry.as_deref() == Some(rel) {
217 out.push(pkg.name.clone());
218 }
219 out
220 }
221}
222
223fn under(rel: &str, dir: &str) -> bool {
225 dir.is_empty() || rel.strip_prefix(dir).is_some_and(|t| t.starts_with('/'))
226}
227
228pub fn strip_ts_ext(p: &str) -> &str {
229 TS_EXTS.iter().find_map(|e| p.strip_suffix(e)).unwrap_or(p)
230}
231
232fn join(dir: &str, rel: &str) -> String {
233 let rel = rel.trim_start_matches("./").trim_end_matches('/');
234 match (dir.is_empty(), rel.is_empty() || rel == ".") {
235 (_, true) => dir.to_string(),
236 (true, false) => rel.to_string(),
237 (false, false) => format!("{dir}/{rel}"),
238 }
239}
240
241fn python_roots(root: &Path, dir: &str, text: &str) -> Vec<String> {
243 let mut out = vec![dir.to_string()];
244 let Ok(v) = text.parse::<toml::Table>() else {
245 return out;
246 };
247 let tool = v.get("tool");
248 let strs = |x: Option<&toml::Value>| -> Vec<String> {
249 x.and_then(|a| a.as_array())
250 .map(|a| {
251 a.iter()
252 .filter_map(|s| s.as_str().map(str::to_string))
253 .collect()
254 })
255 .unwrap_or_default()
256 };
257 let find = tool
258 .and_then(|t| t.get("setuptools"))
259 .and_then(|s| s.get("packages"))
260 .and_then(|p| p.get("find"));
261 out.extend(
262 strs(find.and_then(|f| f.get("where")))
263 .iter()
264 .map(|w| join(dir, w)),
265 );
266 if let Some(pkgs) = tool
267 .and_then(|t| t.get("poetry"))
268 .and_then(|p| p.get("packages"))
269 .and_then(|p| p.as_array())
270 {
271 for p in pkgs {
272 if let Some(from) = p.get("from").and_then(|f| f.as_str()) {
273 out.push(join(dir, from));
274 }
275 }
276 }
277 let hatch = tool
278 .and_then(|t| t.get("hatch"))
279 .and_then(|h| h.get("build"))
280 .and_then(|b| b.get("targets"))
281 .and_then(|t| t.get("wheel"))
282 .and_then(|w| w.get("packages"));
283 for p in strs(hatch) {
284 if let Some((parent, _)) = p.trim_end_matches('/').rsplit_once('/') {
285 out.push(join(dir, parent));
286 }
287 }
288 let src = join(dir, "src");
289 if root.join(&src).is_dir() {
290 out.push(src);
291 }
292 out
293}
294
295fn ts_config(root: &Path, dir: &str, text: &str) -> Option<TsConfig> {
297 let mut cfg = TsConfig {
298 dir: dir.to_string(),
299 base: None,
300 paths: Vec::new(),
301 };
302 let (mut cur_dir, mut cur_text) = (dir.to_string(), text.to_string());
303 for _ in 0..5 {
304 let v: serde_json::Value = serde_json::from_str(&strip_jsonc(&cur_text)).ok()?;
305 let opts = v.get("compilerOptions");
306 let base_url = opts
307 .and_then(|o| o.get("baseUrl"))
308 .and_then(|b| b.as_str())
309 .map(|b| join(&cur_dir, b));
310 if cfg.base.is_none() {
311 cfg.base = base_url.clone();
312 }
313 if cfg.paths.is_empty() {
314 if let Some(p) = opts
315 .and_then(|o| o.get("paths"))
316 .and_then(|p| p.as_object())
317 {
318 let anchor = base_url.unwrap_or_else(|| cur_dir.clone());
320 if cfg.base.is_none() {
321 cfg.base = Some(anchor);
322 }
323 cfg.paths = p
324 .iter()
325 .map(|(k, ts)| {
326 let ts = ts
327 .as_array()
328 .map(|a| {
329 a.iter()
330 .filter_map(|t| t.as_str().map(str::to_string))
331 .collect()
332 })
333 .unwrap_or_default();
334 (k.clone(), ts)
335 })
336 .collect();
337 }
338 }
339 let Some(ext) = v.get("extends").and_then(|e| e.as_str()) else {
340 break;
341 };
342 if !ext.starts_with('.') {
343 break; }
345 let path = join(&cur_dir, ext);
346 let path = if path.ends_with(".json") {
347 path
348 } else {
349 format!("{path}.json")
350 };
351 cur_text = std::fs::read_to_string(root.join(&path)).ok()?;
352 cur_dir = path
353 .rsplit_once('/')
354 .map_or(String::new(), |(d, _)| d.to_string());
355 }
356 (cfg.base.is_some() || !cfg.paths.is_empty()).then_some(cfg)
357}
358
359fn strip_jsonc(s: &str) -> String {
361 let mut out = String::with_capacity(s.len());
362 let mut chars = s.chars().peekable();
363 let mut in_str = false;
364 while let Some(c) = chars.next() {
365 if in_str {
366 out.push(c);
367 if c == '\\' {
368 out.extend(chars.next());
369 } else if c == '"' {
370 in_str = false;
371 }
372 continue;
373 }
374 match (c, chars.peek()) {
375 ('"', _) => {
376 in_str = true;
377 out.push(c);
378 }
379 ('/', Some('/')) => {
380 for n in chars.by_ref() {
381 if n == '\n' {
382 out.push('\n');
383 break;
384 }
385 }
386 }
387 ('/', Some('*')) => {
388 chars.next();
389 let mut prev = ' ';
390 for n in chars.by_ref() {
391 if prev == '*' && n == '/' {
392 break;
393 }
394 prev = n;
395 }
396 }
397 (',', _) => {
398 let rest: String = chars.clone().collect();
399 let next = rest.trim_start().chars().next();
400 if !matches!(next, Some('}') | Some(']')) {
401 out.push(c);
402 }
403 }
404 _ => out.push(c),
405 }
406 }
407 out
408}
409
410fn ts_package(root: &Path, dir: &str, text: &str) -> Option<TsPackage> {
412 let v: serde_json::Value = serde_json::from_str(text).ok()?;
413 let name = v.get("name")?.as_str()?.to_string();
414 let exports_dot = v.get("exports").and_then(|e| match e {
415 serde_json::Value::String(s) => Some(s.clone()),
416 serde_json::Value::Object(o) => o.get(".").and_then(|d| match d {
417 serde_json::Value::String(s) => Some(s.clone()),
418 serde_json::Value::Object(c) => ["types", "import", "default", "require"]
419 .iter()
420 .find_map(|k| c.get(*k).and_then(|x| x.as_str()).map(str::to_string)),
421 _ => None,
422 }),
423 _ => None,
424 });
425 let declared = ["types", "typings", "module", "main"]
426 .iter()
427 .filter_map(|k| v.get(*k).and_then(|x| x.as_str()).map(str::to_string))
428 .chain(exports_dot);
429 let mut candidates: Vec<String> = Vec::new();
430 for d in declared {
431 let stem = strip_ts_ext(d.trim_start_matches("./")).to_string();
432 candidates.push(stem.clone());
433 for out_dir in ["dist/", "lib/", "build/", "out/"] {
435 if let Some(rest) = stem.strip_prefix(out_dir) {
436 candidates.push(format!("src/{rest}"));
437 }
438 }
439 }
440 candidates.extend(["src/index".to_string(), "index".to_string()]);
441 let entry = candidates.iter().find_map(|stem| {
442 [".ts", ".tsx", ".js", ".jsx", ".mjs"]
443 .iter()
444 .map(|e| join(dir, &format!("{stem}{e}")))
445 .find(|p| root.join(p).is_file())
446 });
447 Some(TsPackage {
448 dir: dir.to_string(),
449 name,
450 entry,
451 })
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 #[test]
459 fn jsonc_comments_and_trailing_commas() {
460 let src = "{\n // c\n \"a\": \"x//y\", /* b */ \"p\": [1, 2,],\n}";
461 let v: serde_json::Value = serde_json::from_str(&strip_jsonc(src)).unwrap();
462 assert_eq!(v["a"], "x//y");
463 assert_eq!(v["p"], serde_json::json!([1, 2]));
464 }
465
466 #[test]
467 fn pyproject_source_roots() {
468 let text =
469 "[project]\nname = \"x\"\n\n[tool.setuptools.packages.find]\nwhere = [\"lib\"]\n";
470 let roots = python_roots(Path::new("/nonexistent"), "svc", text);
471 assert_eq!(roots, ["svc", "svc/lib"]);
472 }
473
474 #[test]
475 fn fixture_module_names() {
476 let common = RepoPackages::discover(Path::new(concat!(
477 env!("CARGO_MANIFEST_DIR"),
478 "/../comindly-engine/tests/fixtures/org/pkg-common"
479 )));
480 assert_eq!(
481 common.module_names("src/acme/common/http.py", &Language::Python),
482 vec!["acme.common.http"]
483 );
484 assert_eq!(
485 common.module_names("src/acme/common/__init__.py", &Language::Python),
486 vec!["acme.common"]
487 );
488
489 let svc = RepoPackages::discover(Path::new(concat!(
490 env!("CARGO_MANIFEST_DIR"),
491 "/../comindly-engine/tests/fixtures/org/service-a"
492 )));
493 assert_eq!(
494 svc.module_names("service_a/users.py", &Language::Python),
495 vec!["service_a.users"]
496 );
497
498 let kit = RepoPackages::discover(Path::new(concat!(
499 env!("CARGO_MANIFEST_DIR"),
500 "/../comindly-engine/tests/fixtures/org/ui-kit"
501 )));
502 assert_eq!(
503 kit.module_names("src/index.ts", &Language::TypeScript),
504 vec!["@acme/ui-kit/src/index", "@acme/ui-kit/src", "@acme/ui-kit"]
505 );
506 assert_eq!(
507 kit.module_names("src/button.ts", &Language::TypeScript),
508 vec!["@acme/ui-kit/src/button"]
509 );
510 }
511}