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    // `#` prefix is Node.js imports maps (package.json "imports" field)
12    if specifier.starts_with('#') {
13        return true;
14    }
15    // `~/`, `~~/`, and `@@/` prefixes are common alias conventions
16    // (e.g., Nuxt, custom tsconfig)
17    if specifier.starts_with("~/") || specifier.starts_with("~~/") || specifier.starts_with("@@/") {
18        return true;
19    }
20    // `@/` is a very common path alias (e.g., `@/components/Foo`)
21    if specifier.starts_with("@/") {
22        return true;
23    }
24    // npm scoped packages MUST be lowercase (npm registry requirement).
25    // PascalCase `@Scope` or `@Scope/path` patterns are tsconfig path aliases,
26    // not npm packages. E.g., `@Components`, `@Hooks/useApi`, `@Services/auth`.
27    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/// Check if a specifier is a bare specifier (npm package or Node.js imports map entry).
38#[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/// Check if a string looks like a valid npm package name.
47///
48/// Rejects strings that are clearly not packages: shell variables (`$X`),
49/// pure numbers, strings starting with `!`, empty strings, etc.
50/// This prevents false "unlisted dependency" reports for test fixture
51/// artifacts like `$DIR` or `1`.
52#[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    // Reject shell variables, shebangs, and similar non-package prefixes
59    if first == b'$' || first == b'!' || first == b'#' {
60        return false;
61    }
62    // Reject bundler-internal specifiers (webpack loaders, turbopack barrel optimization)
63    if name.contains('?') || name.contains('!') || name.starts_with("__") {
64        return false;
65    }
66    // Pure numeric strings (like "1", "123") are not package names
67    if name.bytes().all(|b| b.is_ascii_digit()) {
68        return false;
69    }
70    // Must contain at least one letter or @ sign to be a plausible package name
71    if !name.bytes().any(|b| b.is_ascii_alphabetic() || b == b'@') {
72        return false;
73    }
74    // Reject strings with spaces or backslashes (not valid in npm names)
75    !name.contains(' ') && !name.contains('\\')
76}
77
78/// Extract the npm package name from a specifier.
79/// `@scope/pkg/foo/bar` -> `@scope/pkg`
80/// `lodash/merge` -> `lodash`
81#[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/// Normalize the body of a Deno `npm:` specifier into a plain npm package
96/// reference by dropping the `@<version>` selector while preserving the package
97/// name (including scope) and any subpath.
98///
99/// `rest` is the substring after the `npm:` scheme. Deno `npm:` specifiers are
100/// `<package>[@<version>][/<subpath>]`, so the version selector is the first
101/// `@` that follows the package name (after the scope's own `@scope/` for
102/// scoped packages). Examples:
103/// `@supabase/supabase-js@2` -> `@supabase/supabase-js`,
104/// `preact@10/hooks` -> `preact/hooks`, `express@^4.18.0` -> `express`,
105/// `foo` -> `foo`.
106#[must_use]
107pub fn normalize_npm_specifier(rest: &str) -> String {
108    // For scoped packages, skip past the `@scope/` segment so the leading scope
109    // `@` is not mistaken for the version selector.
110    let search_from = if rest.starts_with('@') {
111        match rest.find('/') {
112            Some(slash) => slash + 1,
113            // `@scope` alone carries no version or subpath.
114            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    // The version runs from the `@` until the next `/` (a subpath) or the end.
125    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        // Deno places the version before the subpath: `<pkg>@<ver>/<subpath>`.
160        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        // A bare scope with no `/name` is degenerate but must not panic.
177        assert_eq!(normalize_npm_specifier("@scope"), "@scope");
178    }
179
180    #[test]
181    fn normalize_npm_specifier_empty() {
182        // A bare `npm:` body normalizes to empty; the resolver maps this to an
183        // external file rather than emitting a finding for the empty specifier.
184        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    // ── is_path_alias ───────────────────────────────────────────────
205
206    #[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        // PascalCase scoped packages are tsconfig aliases, not npm packages
228        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        // Lowercase scoped packages are regular npm packages
236        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        // `~something` without a slash is not a path alias convention
251        assert!(!is_path_alias("~something"));
252    }
253
254    // ── is_valid_package_name ────────────────────────────────────────
255
256    #[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")); // starts with digit but has letters
264    }
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    // ── extract_package_name edge cases ─────────────────────────────
279
280    #[test]
281    fn extract_package_name_bare_scope_only() {
282        // Edge case: just `@scope` without a package name
283        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            /// Any specifier starting with `.` or `/` must NOT be classified as a bare specifier.
305            #[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            /// Scoped packages (@scope/pkg) should extract exactly `@scope/pkg` — two segments.
314            #[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            /// Unscoped packages should extract exactly the first path segment.
327            #[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            /// is_bare_specifier, is_path_alias, and is_valid_package_name should never panic on arbitrary strings.
338            #[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            /// Valid npm package names (lowercase letters, digits, hyphens, dots) must be accepted.
346            #[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            /// Shell variable specifiers ($...) must be rejected.
352            #[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            /// Pure numeric specifiers must be rejected.
359            #[test]
360            fn pure_numbers_rejected(n in "[0-9]{1,10}") {
361                prop_assert!(!is_valid_package_name(&n));
362            }
363
364            /// `@/` prefix should always be detected as a path alias.
365            #[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            /// `~/` prefix should always be detected as a path alias.
372            #[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            /// `#` prefix should always be detected as a path alias (Node.js imports map).
379            #[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            /// Extracted package name from node_modules path should never be empty.
386            #[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}