fallow_graph/resolve/
path_info.rs1#[must_use]
10pub fn is_path_alias(specifier: &str) -> bool {
11 if specifier.starts_with('#') {
12 return true;
13 }
14 if specifier.starts_with("~/") || specifier.starts_with("~~/") || specifier.starts_with("@@/") {
15 return true;
16 }
17 if specifier.starts_with("@/") {
18 return true;
19 }
20 if specifier.starts_with('@') {
21 let scope = specifier.split('/').next().unwrap_or(specifier);
22 if scope.len() > 1 && scope.chars().nth(1).is_some_and(|c| c.is_ascii_uppercase()) {
23 return true;
24 }
25 }
26
27 false
28}
29
30#[must_use]
32pub fn is_bare_specifier(specifier: &str) -> bool {
33 !specifier.starts_with('.')
34 && !specifier.starts_with('/')
35 && !specifier.contains("://")
36 && !specifier.starts_with("data:")
37}
38
39#[must_use]
46pub fn is_valid_package_name(name: &str) -> bool {
47 if name.is_empty() {
48 return false;
49 }
50 let first = name.as_bytes()[0];
51 if first == b'$' || first == b'!' || first == b'#' {
52 return false;
53 }
54 if name.contains('?') || name.contains('!') || name.starts_with("__") {
55 return false;
56 }
57 if name.bytes().all(|b| b.is_ascii_digit()) {
58 return false;
59 }
60 if !name.bytes().any(|b| b.is_ascii_alphabetic() || b == b'@') {
61 return false;
62 }
63 !name.contains(' ') && !name.contains('\\')
64}
65
66#[must_use]
70pub fn extract_package_name(specifier: &str) -> String {
71 if specifier.starts_with('@') {
72 let parts: Vec<&str> = specifier.splitn(3, '/').collect();
73 if parts.len() >= 2 {
74 format!("{}/{}", parts[0], parts[1])
75 } else {
76 specifier.to_string()
77 }
78 } else {
79 specifier.split('/').next().unwrap_or(specifier).to_string()
80 }
81}
82
83#[must_use]
95pub fn normalize_npm_specifier(rest: &str) -> String {
96 let search_from = if rest.starts_with('@') {
97 match rest.find('/') {
98 Some(slash) => slash + 1,
99 None => return rest.to_string(),
100 }
101 } else {
102 0
103 };
104
105 let Some(at_rel) = rest[search_from..].find('@') else {
106 return rest.to_string();
107 };
108 let at = search_from + at_rel;
109 let end = rest[at..].find('/').map_or(rest.len(), |slash| at + slash);
110 let mut out = String::with_capacity(rest.len() - (end - at));
111 out.push_str(&rest[..at]);
112 out.push_str(&rest[end..]);
113 out
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn test_extract_package_name() {
122 assert_eq!(extract_package_name("react"), "react");
123 assert_eq!(extract_package_name("lodash/merge"), "lodash");
124 assert_eq!(extract_package_name("@scope/pkg"), "@scope/pkg");
125 assert_eq!(extract_package_name("@scope/pkg/foo"), "@scope/pkg");
126 assert_eq!(extract_package_name("React"), "React");
127 assert_eq!(extract_package_name("@Scope/Package"), "@Scope/Package");
128 }
129
130 #[test]
131 fn normalize_npm_specifier_scoped_with_version() {
132 assert_eq!(
133 normalize_npm_specifier("@supabase/supabase-js@2"),
134 "@supabase/supabase-js"
135 );
136 }
137
138 #[test]
139 fn normalize_npm_specifier_unscoped_with_version() {
140 assert_eq!(normalize_npm_specifier("express@^4.18.0"), "express");
141 }
142
143 #[test]
144 fn normalize_npm_specifier_version_then_subpath() {
145 assert_eq!(normalize_npm_specifier("preact@10/hooks"), "preact/hooks");
146 assert_eq!(
147 normalize_npm_specifier("@scope/name@1.2.3/sub"),
148 "@scope/name/sub"
149 );
150 }
151
152 #[test]
153 fn normalize_npm_specifier_no_version() {
154 assert_eq!(normalize_npm_specifier("foo"), "foo");
155 assert_eq!(normalize_npm_specifier("lodash/merge"), "lodash/merge");
156 assert_eq!(normalize_npm_specifier("@scope/pkg/sub"), "@scope/pkg/sub");
157 }
158
159 #[test]
160 fn normalize_npm_specifier_scope_only() {
161 assert_eq!(normalize_npm_specifier("@scope"), "@scope");
162 }
163
164 #[test]
165 fn normalize_npm_specifier_empty() {
166 assert_eq!(normalize_npm_specifier(""), "");
167 }
168
169 #[test]
170 fn test_is_bare_specifier() {
171 assert!(is_bare_specifier("react"));
172 assert!(is_bare_specifier("@scope/pkg"));
173 assert!(is_bare_specifier("#internal/module"));
174 assert!(!is_bare_specifier("./utils"));
175 assert!(!is_bare_specifier("../lib"));
176 assert!(!is_bare_specifier("/absolute"));
177 }
178
179 #[test]
180 fn test_is_bare_specifier_url_specifiers() {
181 assert!(!is_bare_specifier("https://cdn.example.com/lib.js"));
182 assert!(!is_bare_specifier("http://example.com/module"));
183 assert!(!is_bare_specifier("data:text/javascript,export default 42"));
184 }
185
186 #[test]
187 fn path_alias_hash_prefix() {
188 assert!(is_path_alias("#internal/module"));
189 assert!(is_path_alias("#shared"));
190 }
191
192 #[test]
193 fn path_alias_tilde_prefix() {
194 assert!(is_path_alias("~/components/Button"));
195 assert!(is_path_alias("~~/utils/helpers"));
196 assert!(is_path_alias("@@/shared/utils"));
197 }
198
199 #[test]
200 fn path_alias_at_slash_prefix() {
201 assert!(is_path_alias("@/components/Button"));
202 assert!(is_path_alias("@/lib"));
203 }
204
205 #[test]
206 fn path_alias_pascal_case_scope() {
207 assert!(is_path_alias("@Components/Button"));
208 assert!(is_path_alias("@Hooks/useApi"));
209 assert!(is_path_alias("@Services/auth"));
210 }
211
212 #[test]
213 fn path_alias_lowercase_scope_is_not_alias() {
214 assert!(!is_path_alias("@babel/core"));
215 assert!(!is_path_alias("@types/react"));
216 assert!(!is_path_alias("@scope/pkg"));
217 }
218
219 #[test]
220 fn path_alias_plain_specifier_is_not_alias() {
221 assert!(!is_path_alias("react"));
222 assert!(!is_path_alias("lodash/merge"));
223 assert!(!is_path_alias("my-utils"));
224 }
225
226 #[test]
227 fn path_alias_tilde_without_slash_is_not_alias() {
228 assert!(!is_path_alias("~something"));
229 }
230
231 #[test]
232 fn valid_package_names() {
233 assert!(is_valid_package_name("react"));
234 assert!(is_valid_package_name("@scope/pkg"));
235 assert!(is_valid_package_name("lodash.get"));
236 assert!(is_valid_package_name("my-pkg"));
237 assert!(is_valid_package_name("@babel/core"));
238 assert!(is_valid_package_name("3d-view")); }
240
241 #[test]
242 fn invalid_package_names() {
243 assert!(!is_valid_package_name("$DIR"));
244 assert!(!is_valid_package_name("$ENV_VAR"));
245 assert!(!is_valid_package_name("1"));
246 assert!(!is_valid_package_name("123"));
247 assert!(!is_valid_package_name(""));
248 assert!(!is_valid_package_name("!important"));
249 assert!(!is_valid_package_name("has spaces"));
250 assert!(!is_valid_package_name("back\\slash"));
251 }
252
253 #[test]
254 fn extract_package_name_bare_scope_only() {
255 assert_eq!(extract_package_name("@scope"), "@scope");
256 }
257
258 #[test]
259 fn extract_package_name_deep_subpath() {
260 assert_eq!(
261 extract_package_name("@scope/pkg/deep/nested/path"),
262 "@scope/pkg"
263 );
264 }
265
266 #[test]
267 fn extract_package_name_single_name() {
268 assert_eq!(extract_package_name("react"), "react");
269 }
270
271 mod proptests {
272 use super::*;
273 use proptest::prelude::*;
274
275 proptest! {
276 #[test]
278 fn relative_paths_are_not_bare(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
279 let dot = format!(".{suffix}");
280 let slash = format!("/{suffix}");
281 prop_assert!(!is_bare_specifier(&dot), "'.{suffix}' was classified as bare");
282 prop_assert!(!is_bare_specifier(&slash), "'/{suffix}' was classified as bare");
283 }
284
285 #[test]
287 fn scoped_package_name_has_two_segments(
288 scope in "[a-z][a-z0-9-]{0,20}",
289 pkg in "[a-z][a-z0-9-]{0,20}",
290 subpath in "(/[a-z0-9-]{1,20}){0,3}",
291 ) {
292 let specifier = format!("@{scope}/{pkg}{subpath}");
293 let extracted = extract_package_name(&specifier);
294 let expected = format!("@{scope}/{pkg}");
295 prop_assert_eq!(extracted, expected);
296 }
297
298 #[test]
300 fn unscoped_package_name_is_first_segment(
301 pkg in "[a-z][a-z0-9-]{0,30}",
302 subpath in "(/[a-z0-9-]{1,20}){0,3}",
303 ) {
304 let specifier = format!("{pkg}{subpath}");
305 let extracted = extract_package_name(&specifier);
306 prop_assert_eq!(extracted, pkg);
307 }
308
309 #[test]
311 fn classification_functions_no_panic(s in "[a-zA-Z0-9@#~/._$!\\-]{1,100}") {
312 let _ = is_bare_specifier(&s);
313 let _ = is_path_alias(&s);
314 let _ = is_valid_package_name(&s);
315 }
316
317 #[test]
319 fn valid_npm_names_accepted(name in "[a-z][a-z0-9._-]{0,30}") {
320 prop_assert!(is_valid_package_name(&name));
321 }
322
323 #[test]
325 fn shell_variables_rejected(suffix in "[A-Z_]{1,20}") {
326 let specifier = format!("${suffix}");
327 prop_assert!(!is_valid_package_name(&specifier));
328 }
329
330 #[test]
332 fn pure_numbers_rejected(n in "[0-9]{1,10}") {
333 prop_assert!(!is_valid_package_name(&n));
334 }
335
336 #[test]
338 fn at_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
339 let specifier = format!("@/{suffix}");
340 prop_assert!(is_path_alias(&specifier));
341 }
342
343 #[test]
345 fn tilde_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
346 let specifier = format!("~/{suffix}");
347 prop_assert!(is_path_alias(&specifier));
348 }
349
350 #[test]
352 fn hash_prefix_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
353 let specifier = format!("#{suffix}");
354 prop_assert!(is_path_alias(&specifier));
355 }
356
357 #[test]
359 fn node_modules_path_yields_unscoped_package_name(
360 pkg in "[a-z][a-z0-9-]{0,20}",
361 file in "[a-z]{1,10}\\.(js|ts|mjs)",
362 ) {
363 let path = std::path::PathBuf::from(format!("/project/node_modules/{pkg}/{file}"));
364 prop_assert_eq!(
365 crate::resolve::fallbacks::extract_package_name_from_node_modules_path(&path),
366 Some(pkg)
367 );
368 }
369 }
370 }
371}