slopguard_core/resolution/
manifest.rs1use 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
13fn normalize_crate(name: &str) -> String {
16 name.replace('-', "_")
17}
18
19fn 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
33type WorkspaceRoot = Option<(PathBuf, Table)>;
36
37fn 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
47fn 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
55fn 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
71fn 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
82fn 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
96struct DepSource<'a> {
99 manifest: &'a Table,
100 dir: &'a Path,
101 workspace: Option<WorkspaceRoot>,
102}
103
104fn 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
128fn 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
138fn 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
180fn 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
196fn 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
255fn 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#[derive(Default)]
279pub struct ManifestResolver {
280 cargo: HashMap<PathBuf, Option<PathBuf>>,
283 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 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 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 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 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
356fn 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
363pub(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}