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::{absolute, 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/// A workspace root: the directory of the manifest declaring `[workspace]` and
34/// its parsed table. Looked up lazily, only when a dependency inherits from it.
35type WorkspaceRoot = Option<(PathBuf, Table)>;
36
37/// The normalized `[lib] name` declared in `table`, if any.
38fn table_lib_name(table: &Table) -> Option<String> {
39    table
40        .get("lib")
41        .and_then(|v| v.as_table())
42        .and_then(|lib| lib.get("name"))
43        .and_then(|v| v.as_str())
44        .map(normalize_crate)
45}
46
47/// The normalized `[lib] name` of the crate whose manifest lives in `dir`, or
48/// `None` when the manifest is unreadable or declares no library name.
49fn lib_name(dir: &Path) -> Option<String> {
50    let text = read_to_string(dir.join("Cargo.toml")).ok()?;
51    let table = text.parse::<Table>().ok()?;
52    table_lib_name(&table)
53}
54
55/// The nearest manifest declaring a `[workspace]`: `table` itself (located in
56/// `manifest_dir`) or the first ancestor directory whose `Cargo.toml` has one.
57fn workspace_root(manifest_dir: &Path, table: &Table) -> WorkspaceRoot {
58    if table.get("workspace").is_some_and(|v| v.is_table()) {
59        return Some((manifest_dir.to_path_buf(), table.clone()));
60    }
61    manifest_dir.ancestors().skip(1).find_map(|dir| {
62        let text = read_to_string(dir.join("Cargo.toml")).ok()?;
63        let parsed = text.parse::<Table>().ok()?;
64        parsed
65            .get("workspace")
66            .is_some_and(|v| v.is_table())
67            .then(|| (dir.to_path_buf(), parsed))
68    })
69}
70
71/// The `[lib] name` of a path dependency declared as `spec` in a manifest
72/// located in `base_dir`. A renamed dependency (`package =`) is skipped: its key
73/// stays the import name.
74fn path_dependency_lib_name(spec: &Table, base_dir: &Path) -> Option<String> {
75    if spec.contains_key("package") {
76        return None;
77    }
78    let path = spec.get("path").and_then(|v| v.as_str())?;
79    lib_name(&base_dir.join(path))
80}
81
82/// The `[lib] name` of the path dependency `key` declared in the workspace
83/// root's `[workspace.dependencies]`, resolved against that root.
84fn inherited_lib_name(root: &WorkspaceRoot, key: &str) -> Option<String> {
85    let (root_dir, root_table) = root.as_ref()?;
86    let entry = root_table
87        .get("workspace")
88        .and_then(|v| v.as_table())
89        .and_then(|w| w.get("dependencies"))
90        .and_then(|v| v.as_table())
91        .and_then(|d| d.get(key))
92        .and_then(|v| v.as_table())?;
93    path_dependency_lib_name(entry, root_dir)
94}
95
96/// Where a manifest's dependency names are being collected from: its parsed
97/// table, its directory, and the lazily resolved workspace root.
98struct DepSource<'a> {
99    manifest: &'a Table,
100    dir: &'a Path,
101    workspace: Option<WorkspaceRoot>,
102}
103
104/// Add the names a dependency table makes importable: each key normalized, plus
105/// the `[lib] name` of path dependencies, including those inherited through
106/// `workspace = true` (resolved against the workspace root that declares them).
107fn add_dependencies(deps: &Table, source: &mut DepSource<'_>, names: &mut HashSet<String>) {
108    for (key, value) in deps {
109        names.insert(normalize_crate(key));
110        let Some(spec) = value.as_table() else {
111            continue;
112        };
113        if spec.contains_key("package") {
114            continue;
115        }
116        if spec.contains_key("path") {
117            names.extend(path_dependency_lib_name(spec, source.dir));
118        } else if spec.get("workspace").and_then(|v| v.as_bool()) == Some(true) {
119            let (manifest, dir) = (source.manifest, source.dir);
120            let root = source
121                .workspace
122                .get_or_insert_with(|| workspace_root(dir, manifest));
123            names.extend(inherited_lib_name(root, key));
124        }
125    }
126}
127
128/// Add the dependencies of a table's `[dependencies]`, `[dev-dependencies]` and
129/// `[build-dependencies]` sub-tables.
130fn add_dep_tables(parent: &Table, source: &mut DepSource<'_>, names: &mut HashSet<String>) {
131    for key in ["dependencies", "dev-dependencies", "build-dependencies"] {
132        if let Some(table) = parent.get(key).and_then(|v| v.as_table()) {
133            add_dependencies(table, source, names);
134        }
135    }
136}
137
138/// Collect every dependency name a parsed `Cargo.toml` (located in
139/// `manifest_dir`) makes usable: the plain, dev and build dependencies,
140/// `[workspace.dependencies]`, `[target.*]` variants, the package's own name,
141/// its `[lib] name`, and the `[lib] name` of each path dependency.
142fn collect_cargo_deps(table: &Table, manifest_dir: &Path) -> HashSet<String> {
143    let mut names = HashSet::new();
144    let mut source = DepSource {
145        manifest: table,
146        dir: manifest_dir,
147        workspace: None,
148    };
149    add_dep_tables(table, &mut source, &mut names);
150
151    if let Some(deps) = table
152        .get("workspace")
153        .and_then(|v| v.as_table())
154        .and_then(|w| w.get("dependencies"))
155        .and_then(|v| v.as_table())
156    {
157        add_dependencies(deps, &mut source, &mut names);
158    }
159
160    if let Some(targets) = table.get("target").and_then(|v| v.as_table()) {
161        for target in targets.values().filter_map(|v| v.as_table()) {
162            add_dep_tables(target, &mut source, &mut names);
163        }
164    }
165
166    if let Some(name) = table
167        .get("package")
168        .and_then(|v| v.as_table())
169        .and_then(|p| p.get("name"))
170        .and_then(|v| v.as_str())
171    {
172        names.insert(normalize_crate(name));
173    }
174
175    names.extend(table_lib_name(table));
176
177    names
178}
179
180/// Collect the dependency names declared in a parsed `package.json`.
181fn collect_package_json_deps(value: &JsonValue) -> HashSet<String> {
182    let mut names = HashSet::new();
183    for key in [
184        "dependencies",
185        "devDependencies",
186        "peerDependencies",
187        "optionalDependencies",
188    ] {
189        if let Some(obj) = value.get(key).and_then(|v| v.as_object()) {
190            names.extend(obj.keys().cloned());
191        }
192    }
193    names
194}
195
196/// Strip `//` and `/* */` comments and trailing commas from JSONC (tsconfig) so
197/// `serde_json` can parse it. Best-effort: string contents are preserved.
198fn strip_jsonc(input: &str) -> String {
199    let mut out = String::with_capacity(input.len());
200    let mut chars = input.chars().peekable();
201    let mut in_string = false;
202    let mut escaped = false;
203    while let Some(c) = chars.next() {
204        if in_string {
205            out.push(c);
206            if escaped {
207                escaped = false;
208            } else if c == '\\' {
209                escaped = true;
210            } else if c == '"' {
211                in_string = false;
212            }
213            continue;
214        }
215        match c {
216            '"' => {
217                in_string = true;
218                out.push(c);
219            }
220            '/' if chars.peek() == Some(&'/') => {
221                for next in chars.by_ref() {
222                    if next == '\n' {
223                        out.push('\n');
224                        break;
225                    }
226                }
227            }
228            '/' if chars.peek() == Some(&'*') => {
229                chars.next();
230                let mut prev = '\0';
231                for next in chars.by_ref() {
232                    if prev == '*' && next == '/' {
233                        break;
234                    }
235                    prev = next;
236                }
237            }
238            _ => out.push(c),
239        }
240    }
241    let chars: Vec<char> = out.chars().collect();
242    let mut cleaned = String::with_capacity(out.len());
243    for (i, &c) in chars.iter().enumerate() {
244        if c == ',' {
245            let next = chars[i + 1..].iter().find(|c| !c.is_whitespace()).copied();
246            if matches!(next, Some('}') | Some(']')) {
247                continue;
248            }
249        }
250        cleaned.push(c);
251    }
252    cleaned
253}
254
255/// The alias prefixes declared in a tsconfig's `compilerOptions.paths`. A
256/// wildcard alias `@app/*` is stored as prefix `@app/` with `wildcard = true`;
257/// an exact alias `@app` is stored as `@app` with `wildcard = false`.
258fn collect_ts_aliases(value: &JsonValue) -> Vec<(String, bool)> {
259    let Some(paths) = value
260        .get("compilerOptions")
261        .and_then(|o| o.get("paths"))
262        .and_then(|p| p.as_object())
263    else {
264        return Vec::new();
265    };
266    paths
267        .keys()
268        .map(|key| match key.strip_suffix('*') {
269            Some(prefix) => (prefix.to_string(), true),
270            None => (key.clone(), false),
271        })
272        .collect()
273}
274
275/// Reads and memoizes the manifests governing scanned files. Single-threaded:
276/// the resolution pass runs after the parallel per-file scan. Keyed by the
277/// file's directory, so files in the same directory reuse a parsed manifest.
278#[derive(Default)]
279pub struct ManifestResolver {
280    /// Source directory -> the `Cargo.toml` governing it (`None` when none is
281    /// reachable or readable).
282    cargo: HashMap<PathBuf, Option<PathBuf>>,
283    /// `Cargo.toml` path -> its collected dependency names, so the directories
284    /// of one crate share a single read of its manifest and path dependencies.
285    cargo_by_manifest: HashMap<PathBuf, HashSet<String>>,
286    package_json: HashMap<PathBuf, Option<HashSet<String>>>,
287    ts_aliases: HashMap<PathBuf, Vec<(String, bool)>>,
288}
289
290impl ManifestResolver {
291    pub fn new() -> Self {
292        Self::default()
293    }
294
295    /// The Cargo dependency names governing `dir`, or `None` when no reachable
296    /// `Cargo.toml` could be read (nothing can be asserted about imports).
297    pub(super) fn cargo_deps(&mut self, dir: &Path) -> Option<&HashSet<String>> {
298        if !self.cargo.contains_key(dir) {
299            let manifest = find_up(dir, "Cargo.toml").and_then(|path| {
300                if self.cargo_by_manifest.contains_key(&path) {
301                    return Some(path);
302                }
303                let table = read_to_string(&path).ok()?.parse::<Table>().ok()?;
304                // Absolute, so the walk up to a workspace root declared above
305                // the current directory succeeds when `dir` is relative.
306                let manifest_dir = path.parent().unwrap_or_else(|| Path::new("."));
307                let manifest_dir =
308                    absolute(manifest_dir).unwrap_or_else(|_| manifest_dir.to_path_buf());
309                let deps = collect_cargo_deps(&table, &manifest_dir);
310                self.cargo_by_manifest.insert(path.clone(), deps);
311                Some(path)
312            });
313            self.cargo.insert(dir.to_path_buf(), manifest);
314        }
315        let manifest = self.cargo.get(dir)?.as_ref()?;
316        self.cargo_by_manifest.get(manifest)
317    }
318
319    /// The `package.json` dependency names governing `dir`, or `None` when no
320    /// reachable `package.json` could be read.
321    pub(super) fn package_json_deps(&mut self, dir: &Path) -> Option<&HashSet<String>> {
322        if !self.package_json.contains_key(dir) {
323            let deps = find_up(dir, "package.json")
324                .and_then(|path| read_to_string(&path).ok())
325                .and_then(|text| serde_json::from_str::<JsonValue>(&text).ok())
326                .map(|value| collect_package_json_deps(&value));
327            self.package_json.insert(dir.to_path_buf(), deps);
328        }
329        self.package_json.get(dir).and_then(|entry| entry.as_ref())
330    }
331
332    fn ts_aliases(&mut self, dir: &Path) -> &[(String, bool)] {
333        if !self.ts_aliases.contains_key(dir) {
334            let aliases = find_up(dir, "tsconfig.json")
335                .and_then(|path| read_to_string(&path).ok())
336                .and_then(|text| serde_json::from_str::<JsonValue>(&strip_jsonc(&text)).ok())
337                .map(|value| collect_ts_aliases(&value))
338                .unwrap_or_default();
339            self.ts_aliases.insert(dir.to_path_buf(), aliases);
340        }
341        self.ts_aliases.get(dir).map(Vec::as_slice).unwrap_or(&[])
342    }
343
344    /// Whether `spec` matches a tsconfig `paths` alias reachable from `dir`.
345    pub(super) fn ts_alias_matches(&mut self, dir: &Path, spec: &str) -> bool {
346        self.ts_aliases(dir).iter().any(|(prefix, wildcard)| {
347            if *wildcard {
348                spec.starts_with(prefix.as_str())
349            } else {
350                spec == prefix
351            }
352        })
353    }
354}
355
356/// Whether a dependency spec enables the `feature` (only a table spec can).
357fn spec_has_feature(spec: &toml::Value, feature: &str) -> bool {
358    spec.get("features")
359        .and_then(|f| f.as_array())
360        .is_some_and(|features| features.iter().any(|f| f.as_str() == Some(feature)))
361}
362
363/// The crate the strum derive macros (`Display`, `IntoStaticStr`, ...) are
364/// reachable through for code in `dir`, read from the governing `Cargo.toml`:
365/// `strum` when its `derive` feature is on (in the member's spec, or in the
366/// `[workspace.dependencies]` entry it inherits), else `strum_macros` when it
367/// is a dependency, else `None`. Only `[dependencies]` counts: the fixed code
368/// is production code.
369pub(crate) fn strum_derive_crate(dir: &Path) -> Option<&'static str> {
370    let path = find_up(dir, "Cargo.toml")?;
371    let table = read_to_string(&path).ok()?.parse::<Table>().ok()?;
372    let deps = table.get("dependencies")?.as_table()?;
373    if let Some(spec) = deps.get("strum") {
374        let mut derive = spec_has_feature(spec, "derive");
375        if !derive && spec.get("workspace").and_then(|v| v.as_bool()) == Some(true) {
376            let manifest_dir = path.parent().unwrap_or_else(|| Path::new("."));
377            let manifest_dir =
378                absolute(manifest_dir).unwrap_or_else(|_| manifest_dir.to_path_buf());
379            derive = workspace_root(&manifest_dir, &table).is_some_and(|(_, root)| {
380                root.get("workspace")
381                    .and_then(|w| w.get("dependencies"))
382                    .and_then(|d| d.get("strum"))
383                    .is_some_and(|s| spec_has_feature(s, "derive"))
384            });
385        }
386        if derive {
387            return Some("strum");
388        }
389    }
390    deps.contains_key("strum_macros").then_some("strum_macros")
391}