Skip to main content

slopguard_core/resolution/
manifest.rs

1//! Manifest reading and caching, shared by the per-language resolvers. Reads a
2//! source file's governing `Cargo.toml`, `package.json` and `tsconfig.json` from
3//! disk and exposes their declared dependencies and path aliases. Reads only
4//! manifests already on disk: no network, no `cargo`/`npm` invocation.
5
6use std::collections::{HashMap, HashSet};
7use std::fs::read_to_string;
8use std::path::{Path, PathBuf};
9
10use serde_json::Value as JsonValue;
11use toml::Table;
12
13/// Normalize a Cargo dependency key or crate name to its `use` form: hyphens
14/// become underscores (`ast-grep-core` -> `ast_grep_core`).
15fn normalize_crate(name: &str) -> String {
16    name.replace('-', "_")
17}
18
19/// Walk up from `start` (inclusive) looking for `filename`, returning the first
20/// match. Used to find the manifest that governs a source file.
21fn find_up(start: &Path, filename: &str) -> Option<PathBuf> {
22    let mut dir = Some(start);
23    while let Some(current) = dir {
24        let candidate = current.join(filename);
25        if candidate.is_file() {
26            return Some(candidate);
27        }
28        dir = current.parent();
29    }
30    None
31}
32
33/// Add the keys of a table's `[dependencies]`, `[dev-dependencies]` and
34/// `[build-dependencies]` sub-tables, normalized to their `use` form.
35fn add_dep_table_keys(parent: &Table, names: &mut HashSet<String>) {
36    for key in ["dependencies", "dev-dependencies", "build-dependencies"] {
37        if let Some(table) = parent.get(key).and_then(|v| v.as_table()) {
38            names.extend(table.keys().map(|k| normalize_crate(k)));
39        }
40    }
41}
42
43/// Collect every dependency name a parsed `Cargo.toml` makes usable: the plain,
44/// dev and build dependencies, `[workspace.dependencies]`, `[target.*]`
45/// variants, and the package's own name.
46fn collect_cargo_deps(table: &Table) -> HashSet<String> {
47    let mut names = HashSet::new();
48    add_dep_table_keys(table, &mut names);
49
50    if let Some(workspace) = table.get("workspace").and_then(|v| v.as_table()) {
51        if let Some(deps) = workspace.get("dependencies").and_then(|v| v.as_table()) {
52            names.extend(deps.keys().map(|k| normalize_crate(k)));
53        }
54    }
55
56    if let Some(targets) = table.get("target").and_then(|v| v.as_table()) {
57        for target in targets.values().filter_map(|v| v.as_table()) {
58            add_dep_table_keys(target, &mut names);
59        }
60    }
61
62    if let Some(name) = table
63        .get("package")
64        .and_then(|v| v.as_table())
65        .and_then(|p| p.get("name"))
66        .and_then(|v| v.as_str())
67    {
68        names.insert(normalize_crate(name));
69    }
70
71    names
72}
73
74/// Collect the dependency names declared in a parsed `package.json`.
75fn collect_package_json_deps(value: &JsonValue) -> HashSet<String> {
76    let mut names = HashSet::new();
77    for key in [
78        "dependencies",
79        "devDependencies",
80        "peerDependencies",
81        "optionalDependencies",
82    ] {
83        if let Some(obj) = value.get(key).and_then(|v| v.as_object()) {
84            names.extend(obj.keys().cloned());
85        }
86    }
87    names
88}
89
90/// Strip `//` and `/* */` comments and trailing commas from JSONC (tsconfig) so
91/// `serde_json` can parse it. Best-effort: string contents are preserved.
92fn strip_jsonc(input: &str) -> String {
93    let mut out = String::with_capacity(input.len());
94    let mut chars = input.chars().peekable();
95    let mut in_string = false;
96    let mut escaped = false;
97    while let Some(c) = chars.next() {
98        if in_string {
99            out.push(c);
100            if escaped {
101                escaped = false;
102            } else if c == '\\' {
103                escaped = true;
104            } else if c == '"' {
105                in_string = false;
106            }
107            continue;
108        }
109        match c {
110            '"' => {
111                in_string = true;
112                out.push(c);
113            }
114            '/' if chars.peek() == Some(&'/') => {
115                for next in chars.by_ref() {
116                    if next == '\n' {
117                        out.push('\n');
118                        break;
119                    }
120                }
121            }
122            '/' if chars.peek() == Some(&'*') => {
123                chars.next();
124                let mut prev = '\0';
125                for next in chars.by_ref() {
126                    if prev == '*' && next == '/' {
127                        break;
128                    }
129                    prev = next;
130                }
131            }
132            _ => out.push(c),
133        }
134    }
135    let chars: Vec<char> = out.chars().collect();
136    let mut cleaned = String::with_capacity(out.len());
137    for (i, &c) in chars.iter().enumerate() {
138        if c == ',' {
139            let next = chars[i + 1..].iter().find(|c| !c.is_whitespace()).copied();
140            if matches!(next, Some('}') | Some(']')) {
141                continue;
142            }
143        }
144        cleaned.push(c);
145    }
146    cleaned
147}
148
149/// The alias prefixes declared in a tsconfig's `compilerOptions.paths`. A
150/// wildcard alias `@app/*` is stored as prefix `@app/` with `wildcard = true`;
151/// an exact alias `@app` is stored as `@app` with `wildcard = false`.
152fn collect_ts_aliases(value: &JsonValue) -> Vec<(String, bool)> {
153    let Some(paths) = value
154        .get("compilerOptions")
155        .and_then(|o| o.get("paths"))
156        .and_then(|p| p.as_object())
157    else {
158        return Vec::new();
159    };
160    paths
161        .keys()
162        .map(|key| match key.strip_suffix('*') {
163            Some(prefix) => (prefix.to_string(), true),
164            None => (key.clone(), false),
165        })
166        .collect()
167}
168
169/// Reads and memoizes the manifests governing scanned files. Single-threaded:
170/// the resolution pass runs after the parallel per-file scan. Keyed by the
171/// file's directory, so files in the same directory reuse a parsed manifest.
172#[derive(Default)]
173pub struct ManifestResolver {
174    cargo: HashMap<PathBuf, Option<HashSet<String>>>,
175    package_json: HashMap<PathBuf, Option<HashSet<String>>>,
176    ts_aliases: HashMap<PathBuf, Vec<(String, bool)>>,
177}
178
179impl ManifestResolver {
180    pub fn new() -> Self {
181        Self::default()
182    }
183
184    /// The Cargo dependency names governing `dir`, or `None` when no reachable
185    /// `Cargo.toml` could be read (nothing can be asserted about imports).
186    pub(super) fn cargo_deps(&mut self, dir: &Path) -> Option<&HashSet<String>> {
187        if !self.cargo.contains_key(dir) {
188            let deps = find_up(dir, "Cargo.toml")
189                .and_then(|path| read_to_string(&path).ok())
190                .and_then(|text| text.parse::<Table>().ok())
191                .map(|table| collect_cargo_deps(&table));
192            self.cargo.insert(dir.to_path_buf(), deps);
193        }
194        self.cargo.get(dir).and_then(|entry| entry.as_ref())
195    }
196
197    /// The `package.json` dependency names governing `dir`, or `None` when no
198    /// reachable `package.json` could be read.
199    pub(super) fn package_json_deps(&mut self, dir: &Path) -> Option<&HashSet<String>> {
200        if !self.package_json.contains_key(dir) {
201            let deps = find_up(dir, "package.json")
202                .and_then(|path| read_to_string(&path).ok())
203                .and_then(|text| serde_json::from_str::<JsonValue>(&text).ok())
204                .map(|value| collect_package_json_deps(&value));
205            self.package_json.insert(dir.to_path_buf(), deps);
206        }
207        self.package_json.get(dir).and_then(|entry| entry.as_ref())
208    }
209
210    fn ts_aliases(&mut self, dir: &Path) -> &[(String, bool)] {
211        if !self.ts_aliases.contains_key(dir) {
212            let aliases = find_up(dir, "tsconfig.json")
213                .and_then(|path| read_to_string(&path).ok())
214                .and_then(|text| serde_json::from_str::<JsonValue>(&strip_jsonc(&text)).ok())
215                .map(|value| collect_ts_aliases(&value))
216                .unwrap_or_default();
217            self.ts_aliases.insert(dir.to_path_buf(), aliases);
218        }
219        self.ts_aliases.get(dir).map(Vec::as_slice).unwrap_or(&[])
220    }
221
222    /// Whether `spec` matches a tsconfig `paths` alias reachable from `dir`.
223    pub(super) fn ts_alias_matches(&mut self, dir: &Path, spec: &str) -> bool {
224        self.ts_aliases(dir).iter().any(|(prefix, wildcard)| {
225            if *wildcard {
226                spec.starts_with(prefix.as_str())
227            } else {
228                spec == prefix
229            }
230        })
231    }
232}