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.
9pub fn is_path_alias(specifier: &str) -> bool {
10    // `#` prefix is Node.js imports maps (package.json "imports" field)
11    if specifier.starts_with('#') {
12        return true;
13    }
14    // `~/` and `~~/` prefixes are common alias conventions (e.g., Nuxt, custom tsconfig)
15    if specifier.starts_with("~/") || specifier.starts_with("~~/") {
16        return true;
17    }
18    // `@/` is a very common path alias (e.g., `@/components/Foo`)
19    if specifier.starts_with("@/") {
20        return true;
21    }
22    // npm scoped packages MUST be lowercase (npm registry requirement).
23    // PascalCase `@Scope` or `@Scope/path` patterns are tsconfig path aliases,
24    // not npm packages. E.g., `@Components`, `@Hooks/useApi`, `@Services/auth`.
25    if specifier.starts_with('@') {
26        let scope = specifier.split('/').next().unwrap_or(specifier);
27        if scope.len() > 1 && scope.chars().nth(1).is_some_and(|c| c.is_ascii_uppercase()) {
28            return true;
29        }
30    }
31
32    false
33}
34
35/// Check if a specifier is a bare specifier (npm package or Node.js imports map entry).
36pub fn is_bare_specifier(specifier: &str) -> bool {
37    !specifier.starts_with('.')
38        && !specifier.starts_with('/')
39        && !specifier.contains("://")
40        && !specifier.starts_with("data:")
41}
42
43/// Extract the npm package name from a specifier.
44/// `@scope/pkg/foo/bar` -> `@scope/pkg`
45/// `lodash/merge` -> `lodash`
46pub fn extract_package_name(specifier: &str) -> String {
47    if specifier.starts_with('@') {
48        let parts: Vec<&str> = specifier.splitn(3, '/').collect();
49        if parts.len() >= 2 {
50            format!("{}/{}", parts[0], parts[1])
51        } else {
52            specifier.to_string()
53        }
54    } else {
55        specifier.split('/').next().unwrap_or(specifier).to_string()
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    #[test]
64    fn test_extract_package_name() {
65        assert_eq!(extract_package_name("react"), "react");
66        assert_eq!(extract_package_name("lodash/merge"), "lodash");
67        assert_eq!(extract_package_name("@scope/pkg"), "@scope/pkg");
68        assert_eq!(extract_package_name("@scope/pkg/foo"), "@scope/pkg");
69    }
70
71    #[test]
72    fn test_is_bare_specifier() {
73        assert!(is_bare_specifier("react"));
74        assert!(is_bare_specifier("@scope/pkg"));
75        assert!(is_bare_specifier("#internal/module"));
76        assert!(!is_bare_specifier("./utils"));
77        assert!(!is_bare_specifier("../lib"));
78        assert!(!is_bare_specifier("/absolute"));
79    }
80
81    mod proptests {
82        use super::*;
83        use proptest::prelude::*;
84
85        proptest! {
86            /// Any specifier starting with `.` or `/` must NOT be classified as a bare specifier.
87            #[test]
88            fn relative_paths_are_not_bare(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
89                let dot = format!(".{suffix}");
90                let slash = format!("/{suffix}");
91                prop_assert!(!is_bare_specifier(&dot), "'.{suffix}' was classified as bare");
92                prop_assert!(!is_bare_specifier(&slash), "'/{suffix}' was classified as bare");
93            }
94
95            /// Scoped packages (@scope/pkg) should extract exactly `@scope/pkg` — two segments.
96            #[test]
97            fn scoped_package_name_has_two_segments(
98                scope in "[a-z][a-z0-9-]{0,20}",
99                pkg in "[a-z][a-z0-9-]{0,20}",
100                subpath in "(/[a-z0-9-]{1,20}){0,3}",
101            ) {
102                let specifier = format!("@{scope}/{pkg}{subpath}");
103                let extracted = extract_package_name(&specifier);
104                let expected = format!("@{scope}/{pkg}");
105                prop_assert_eq!(extracted, expected);
106            }
107
108            /// Unscoped packages should extract exactly the first path segment.
109            #[test]
110            fn unscoped_package_name_is_first_segment(
111                pkg in "[a-z][a-z0-9-]{0,30}",
112                subpath in "(/[a-z0-9-]{1,20}){0,3}",
113            ) {
114                let specifier = format!("{pkg}{subpath}");
115                let extracted = extract_package_name(&specifier);
116                prop_assert_eq!(extracted, pkg);
117            }
118
119            /// is_bare_specifier and is_path_alias should never panic on arbitrary strings.
120            #[test]
121            fn bare_specifier_and_path_alias_no_panic(s in "[a-zA-Z0-9@#~/._-]{1,100}") {
122                let _ = is_bare_specifier(&s);
123                let _ = is_path_alias(&s);
124            }
125
126            /// `@/` prefix should always be detected as a path alias.
127            #[test]
128            fn at_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
129                let specifier = format!("@/{suffix}");
130                prop_assert!(is_path_alias(&specifier));
131            }
132
133            /// `~/` prefix should always be detected as a path alias.
134            #[test]
135            fn tilde_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
136                let specifier = format!("~/{suffix}");
137                prop_assert!(is_path_alias(&specifier));
138            }
139
140            /// `#` prefix should always be detected as a path alias (Node.js imports map).
141            #[test]
142            fn hash_prefix_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
143                let specifier = format!("#{suffix}");
144                prop_assert!(is_path_alias(&specifier));
145            }
146
147            /// Extracted package name from node_modules path should never be empty.
148            #[test]
149            fn node_modules_package_name_never_empty(
150                pkg in "[a-z][a-z0-9-]{0,20}",
151                file in "[a-z]{1,10}\\.(js|ts|mjs)",
152            ) {
153                let path = std::path::PathBuf::from(format!("/project/node_modules/{pkg}/{file}"));
154                if let Some(name) = crate::resolve::fallbacks::extract_package_name_from_node_modules_path(&path) {
155                    prop_assert!(!name.is_empty());
156                }
157            }
158        }
159    }
160}