Skip to main content

comindly_core/
packages.rs

1//! Importable module names for repo files: Python source roots (`pyproject.toml`, `src/` layout)
2//! and TS/JS packages (`package.json` name + entry point).
3
4use 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    /// tsconfig/jsconfig path mappings, deepest directory first.
16    tsconfigs: Vec<TsConfig>,
17    /// Repo-relative Python source roots, longest first; `""` is the repo root.
18    py_roots: Vec<String>,
19    /// TS/JS packages, deepest directory first.
20    ts_pkgs: Vec<TsPackage>,
21    /// Go modules as (repo-relative dir, module path), deepest first.
22    go_mods: Vec<(String, String)>,
23    /// Rust crates as (repo-relative dir, crate name with `_`), deepest first.
24    crates: Vec<(String, String)>,
25}
26
27#[derive(Debug, Clone)]
28struct TsConfig {
29    /// Repo-relative directory of the tsconfig (scope of files it applies to).
30    dir: String,
31    /// Repo-relative directory `paths` and bare specifiers resolve against.
32    base: Option<String>,
33    paths: Vec<(String, Vec<String>)>,
34}
35
36#[derive(Debug, Clone)]
37struct TsPackage {
38    /// Repo-relative package directory (`""` for the repo root).
39    dir: String,
40    name: String,
41    /// Repo-relative entry file, if it exists in the source tree.
42    entry: Option<String>,
43}
44
45impl RepoPackages {
46    /// Scan a repo for `pyproject.toml` and `package.json` manifests.
47    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    /// The repo file a non-relative TS specifier maps to via the governing tsconfig.
115    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    /// The source file a module path denotes (extension or `/index` added), if it exists.
139    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    /// Names a file is importable by; empty when it belongs to no known package.
158    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    /// `crate::a::b` path of a Rust file under its crate's `src/` (binaries excluded).
179    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    /// Import path of the package (directory) a Go file belongs to.
195    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
223/// True when `rel` lies inside repo-relative directory `dir` (`""` contains everything).
224fn 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
241/// Source roots declared by a `pyproject.toml` in repo-relative `dir`.
242fn 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
295/// Path mappings from a tsconfig/jsconfig, following local `extends` chains.
296fn 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                // Without baseUrl, `paths` are relative to the config that declares them.
319                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; // package-provided base config
344        }
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
359/// Strip `//` and `/* */` comments and trailing commas from JSONC, leaving strings intact.
360fn 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
410/// A `package.json` with a name; the entry is mapped back to a source file when possible.
411fn 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        // Built output (`dist/index.js`) usually mirrors `src/index.ts`.
434        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}