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