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    #[test]
82    fn test_is_bare_specifier_url_specifiers() {
83        assert!(!is_bare_specifier("https://cdn.example.com/lib.js"));
84        assert!(!is_bare_specifier("http://example.com/module"));
85        assert!(!is_bare_specifier("data:text/javascript,export default 42"));
86    }
87
88    // ── is_path_alias ───────────────────────────────────────────────
89
90    #[test]
91    fn path_alias_hash_prefix() {
92        assert!(is_path_alias("#internal/module"));
93        assert!(is_path_alias("#shared"));
94    }
95
96    #[test]
97    fn path_alias_tilde_prefix() {
98        assert!(is_path_alias("~/components/Button"));
99        assert!(is_path_alias("~~/utils/helpers"));
100    }
101
102    #[test]
103    fn path_alias_at_slash_prefix() {
104        assert!(is_path_alias("@/components/Button"));
105        assert!(is_path_alias("@/lib"));
106    }
107
108    #[test]
109    fn path_alias_pascal_case_scope() {
110        // PascalCase scoped packages are tsconfig aliases, not npm packages
111        assert!(is_path_alias("@Components/Button"));
112        assert!(is_path_alias("@Hooks/useApi"));
113        assert!(is_path_alias("@Services/auth"));
114    }
115
116    #[test]
117    fn path_alias_lowercase_scope_is_not_alias() {
118        // Lowercase scoped packages are regular npm packages
119        assert!(!is_path_alias("@babel/core"));
120        assert!(!is_path_alias("@types/react"));
121        assert!(!is_path_alias("@scope/pkg"));
122    }
123
124    #[test]
125    fn path_alias_plain_specifier_is_not_alias() {
126        assert!(!is_path_alias("react"));
127        assert!(!is_path_alias("lodash/merge"));
128        assert!(!is_path_alias("my-utils"));
129    }
130
131    #[test]
132    fn path_alias_tilde_without_slash_is_not_alias() {
133        // `~something` without a slash is not a path alias convention
134        assert!(!is_path_alias("~something"));
135    }
136
137    // ── extract_package_name edge cases ─────────────────────────────
138
139    #[test]
140    fn extract_package_name_bare_scope_only() {
141        // Edge case: just `@scope` without a package name
142        assert_eq!(extract_package_name("@scope"), "@scope");
143    }
144
145    #[test]
146    fn extract_package_name_deep_subpath() {
147        assert_eq!(
148            extract_package_name("@scope/pkg/deep/nested/path"),
149            "@scope/pkg"
150        );
151    }
152
153    #[test]
154    fn extract_package_name_single_name() {
155        assert_eq!(extract_package_name("react"), "react");
156    }
157
158    mod proptests {
159        use super::*;
160        use proptest::prelude::*;
161
162        proptest! {
163            /// Any specifier starting with `.` or `/` must NOT be classified as a bare specifier.
164            #[test]
165            fn relative_paths_are_not_bare(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
166                let dot = format!(".{suffix}");
167                let slash = format!("/{suffix}");
168                prop_assert!(!is_bare_specifier(&dot), "'.{suffix}' was classified as bare");
169                prop_assert!(!is_bare_specifier(&slash), "'/{suffix}' was classified as bare");
170            }
171
172            /// Scoped packages (@scope/pkg) should extract exactly `@scope/pkg` — two segments.
173            #[test]
174            fn scoped_package_name_has_two_segments(
175                scope in "[a-z][a-z0-9-]{0,20}",
176                pkg in "[a-z][a-z0-9-]{0,20}",
177                subpath in "(/[a-z0-9-]{1,20}){0,3}",
178            ) {
179                let specifier = format!("@{scope}/{pkg}{subpath}");
180                let extracted = extract_package_name(&specifier);
181                let expected = format!("@{scope}/{pkg}");
182                prop_assert_eq!(extracted, expected);
183            }
184
185            /// Unscoped packages should extract exactly the first path segment.
186            #[test]
187            fn unscoped_package_name_is_first_segment(
188                pkg in "[a-z][a-z0-9-]{0,30}",
189                subpath in "(/[a-z0-9-]{1,20}){0,3}",
190            ) {
191                let specifier = format!("{pkg}{subpath}");
192                let extracted = extract_package_name(&specifier);
193                prop_assert_eq!(extracted, pkg);
194            }
195
196            /// is_bare_specifier and is_path_alias should never panic on arbitrary strings.
197            #[test]
198            fn bare_specifier_and_path_alias_no_panic(s in "[a-zA-Z0-9@#~/._-]{1,100}") {
199                let _ = is_bare_specifier(&s);
200                let _ = is_path_alias(&s);
201            }
202
203            /// `@/` prefix should always be detected as a path alias.
204            #[test]
205            fn at_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
206                let specifier = format!("@/{suffix}");
207                prop_assert!(is_path_alias(&specifier));
208            }
209
210            /// `~/` prefix should always be detected as a path alias.
211            #[test]
212            fn tilde_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
213                let specifier = format!("~/{suffix}");
214                prop_assert!(is_path_alias(&specifier));
215            }
216
217            /// `#` prefix should always be detected as a path alias (Node.js imports map).
218            #[test]
219            fn hash_prefix_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
220                let specifier = format!("#{suffix}");
221                prop_assert!(is_path_alias(&specifier));
222            }
223
224            /// Extracted package name from node_modules path should never be empty.
225            #[test]
226            fn node_modules_package_name_never_empty(
227                pkg in "[a-z][a-z0-9-]{0,20}",
228                file in "[a-z]{1,10}\\.(js|ts|mjs)",
229            ) {
230                let path = std::path::PathBuf::from(format!("/project/node_modules/{pkg}/{file}"));
231                if let Some(name) = crate::resolve::fallbacks::extract_package_name_from_node_modules_path(&path) {
232                    prop_assert!(!name.is_empty());
233                }
234            }
235        }
236    }
237}