slopguard_core/resolution/
manifest.rs1use 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
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
33fn 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
43fn 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
74fn 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
90fn 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
149fn 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#[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 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 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 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}