Skip to main content

fallow_graph/resolve/
path_info.rs

1//! Specifier classification: bare specifiers, path aliases, and package name extraction.
2
3/// Check if a bare specifier looks like a path alias rather than an npm package.
4///
5/// Path aliases (e.g., `@/components`, `~/lib`, `#internal`, `~~/utils`) are resolved
6/// via tsconfig.json `paths` or package.json `imports`. They should not be cached
7/// (resolution depends on the importing file's tsconfig context) and should return
8/// `Unresolvable` (not `NpmPackage`) when resolution fails.
9#[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/// Check if a specifier is a bare specifier (npm package or Node.js imports map entry).
31#[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/// Check if a string looks like a valid npm package name.
40///
41/// Rejects strings that are clearly not packages: shell variables (`$X`),
42/// pure numbers, strings starting with `!`, empty strings, etc.
43/// This prevents false "unlisted dependency" reports for test fixture
44/// artifacts like `$DIR` or `1`.
45#[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/// Extract the npm package name from a specifier.
67/// `@scope/pkg/foo/bar` -> `@scope/pkg`
68/// `lodash/merge` -> `lodash`
69#[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/// Normalize the body of a Deno `npm:` specifier into a plain npm package
84/// reference by dropping the `@<version>` selector while preserving the package
85/// name (including scope) and any subpath.
86///
87/// `rest` is the substring after the `npm:` scheme. Deno `npm:` specifiers are
88/// `<package>[@<version>][/<subpath>]`, so the version selector is the first
89/// `@` that follows the package name (after the scope's own `@scope/` for
90/// scoped packages). Examples:
91/// `@supabase/supabase-js@2` -> `@supabase/supabase-js`,
92/// `preact@10/hooks` -> `preact/hooks`, `express@^4.18.0` -> `express`,
93/// `foo` -> `foo`.
94#[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")); // starts with digit but has letters
239    }
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            /// Any specifier starting with `.` or `/` must NOT be classified as a bare specifier.
277            #[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            /// Scoped packages (@scope/pkg) should extract exactly `@scope/pkg` — two segments.
286            #[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            /// Unscoped packages should extract exactly the first path segment.
299            #[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            /// is_bare_specifier, is_path_alias, and is_valid_package_name should never panic on arbitrary strings.
310            #[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            /// Valid npm package names (lowercase letters, digits, hyphens, dots) must be accepted.
318            #[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            /// Shell variable specifiers ($...) must be rejected.
324            #[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            /// Pure numeric specifiers must be rejected.
331            #[test]
332            fn pure_numbers_rejected(n in "[0-9]{1,10}") {
333                prop_assert!(!is_valid_package_name(&n));
334            }
335
336            /// `@/` prefix should always be detected as a path alias.
337            #[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            /// `~/` prefix should always be detected as a path alias.
344            #[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            /// `#` prefix should always be detected as a path alias (Node.js imports map).
351            #[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            /// A file directly under an unscoped `node_modules` package yields that package name.
358            #[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}