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 (e.g., Nuxt, custom tsconfig)
16    if specifier.starts_with("~/") || specifier.starts_with("~~/") {
17        return true;
18    }
19    // `@/` is a very common path alias (e.g., `@/components/Foo`)
20    if specifier.starts_with("@/") {
21        return true;
22    }
23    // npm scoped packages MUST be lowercase (npm registry requirement).
24    // PascalCase `@Scope` or `@Scope/path` patterns are tsconfig path aliases,
25    // not npm packages. E.g., `@Components`, `@Hooks/useApi`, `@Services/auth`.
26    if specifier.starts_with('@') {
27        let scope = specifier.split('/').next().unwrap_or(specifier);
28        if scope.len() > 1 && scope.chars().nth(1).is_some_and(|c| c.is_ascii_uppercase()) {
29            return true;
30        }
31    }
32
33    false
34}
35
36/// Check if a specifier is a bare specifier (npm package or Node.js imports map entry).
37#[must_use]
38pub fn is_bare_specifier(specifier: &str) -> bool {
39    !specifier.starts_with('.')
40        && !specifier.starts_with('/')
41        && !specifier.contains("://")
42        && !specifier.starts_with("data:")
43}
44
45/// Extract the npm package name from a specifier.
46/// `@scope/pkg/foo/bar` -> `@scope/pkg`
47/// `lodash/merge` -> `lodash`
48#[must_use]
49pub fn extract_package_name(specifier: &str) -> String {
50    if specifier.starts_with('@') {
51        let parts: Vec<&str> = specifier.splitn(3, '/').collect();
52        if parts.len() >= 2 {
53            format!("{}/{}", parts[0], parts[1])
54        } else {
55            specifier.to_string()
56        }
57    } else {
58        specifier.split('/').next().unwrap_or(specifier).to_string()
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    #[test]
67    fn test_extract_package_name() {
68        assert_eq!(extract_package_name("react"), "react");
69        assert_eq!(extract_package_name("lodash/merge"), "lodash");
70        assert_eq!(extract_package_name("@scope/pkg"), "@scope/pkg");
71        assert_eq!(extract_package_name("@scope/pkg/foo"), "@scope/pkg");
72    }
73
74    #[test]
75    fn test_is_bare_specifier() {
76        assert!(is_bare_specifier("react"));
77        assert!(is_bare_specifier("@scope/pkg"));
78        assert!(is_bare_specifier("#internal/module"));
79        assert!(!is_bare_specifier("./utils"));
80        assert!(!is_bare_specifier("../lib"));
81        assert!(!is_bare_specifier("/absolute"));
82    }
83
84    #[test]
85    fn test_is_bare_specifier_url_specifiers() {
86        assert!(!is_bare_specifier("https://cdn.example.com/lib.js"));
87        assert!(!is_bare_specifier("http://example.com/module"));
88        assert!(!is_bare_specifier("data:text/javascript,export default 42"));
89    }
90
91    // ── is_path_alias ───────────────────────────────────────────────
92
93    #[test]
94    fn path_alias_hash_prefix() {
95        assert!(is_path_alias("#internal/module"));
96        assert!(is_path_alias("#shared"));
97    }
98
99    #[test]
100    fn path_alias_tilde_prefix() {
101        assert!(is_path_alias("~/components/Button"));
102        assert!(is_path_alias("~~/utils/helpers"));
103    }
104
105    #[test]
106    fn path_alias_at_slash_prefix() {
107        assert!(is_path_alias("@/components/Button"));
108        assert!(is_path_alias("@/lib"));
109    }
110
111    #[test]
112    fn path_alias_pascal_case_scope() {
113        // PascalCase scoped packages are tsconfig aliases, not npm packages
114        assert!(is_path_alias("@Components/Button"));
115        assert!(is_path_alias("@Hooks/useApi"));
116        assert!(is_path_alias("@Services/auth"));
117    }
118
119    #[test]
120    fn path_alias_lowercase_scope_is_not_alias() {
121        // Lowercase scoped packages are regular npm packages
122        assert!(!is_path_alias("@babel/core"));
123        assert!(!is_path_alias("@types/react"));
124        assert!(!is_path_alias("@scope/pkg"));
125    }
126
127    #[test]
128    fn path_alias_plain_specifier_is_not_alias() {
129        assert!(!is_path_alias("react"));
130        assert!(!is_path_alias("lodash/merge"));
131        assert!(!is_path_alias("my-utils"));
132    }
133
134    #[test]
135    fn path_alias_tilde_without_slash_is_not_alias() {
136        // `~something` without a slash is not a path alias convention
137        assert!(!is_path_alias("~something"));
138    }
139
140    // ── extract_package_name edge cases ─────────────────────────────
141
142    #[test]
143    fn extract_package_name_bare_scope_only() {
144        // Edge case: just `@scope` without a package name
145        assert_eq!(extract_package_name("@scope"), "@scope");
146    }
147
148    #[test]
149    fn extract_package_name_deep_subpath() {
150        assert_eq!(
151            extract_package_name("@scope/pkg/deep/nested/path"),
152            "@scope/pkg"
153        );
154    }
155
156    #[test]
157    fn extract_package_name_single_name() {
158        assert_eq!(extract_package_name("react"), "react");
159    }
160
161    mod proptests {
162        use super::*;
163        use proptest::prelude::*;
164
165        proptest! {
166            /// Any specifier starting with `.` or `/` must NOT be classified as a bare specifier.
167            #[test]
168            fn relative_paths_are_not_bare(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
169                let dot = format!(".{suffix}");
170                let slash = format!("/{suffix}");
171                prop_assert!(!is_bare_specifier(&dot), "'.{suffix}' was classified as bare");
172                prop_assert!(!is_bare_specifier(&slash), "'/{suffix}' was classified as bare");
173            }
174
175            /// Scoped packages (@scope/pkg) should extract exactly `@scope/pkg` — two segments.
176            #[test]
177            fn scoped_package_name_has_two_segments(
178                scope in "[a-z][a-z0-9-]{0,20}",
179                pkg in "[a-z][a-z0-9-]{0,20}",
180                subpath in "(/[a-z0-9-]{1,20}){0,3}",
181            ) {
182                let specifier = format!("@{scope}/{pkg}{subpath}");
183                let extracted = extract_package_name(&specifier);
184                let expected = format!("@{scope}/{pkg}");
185                prop_assert_eq!(extracted, expected);
186            }
187
188            /// Unscoped packages should extract exactly the first path segment.
189            #[test]
190            fn unscoped_package_name_is_first_segment(
191                pkg in "[a-z][a-z0-9-]{0,30}",
192                subpath in "(/[a-z0-9-]{1,20}){0,3}",
193            ) {
194                let specifier = format!("{pkg}{subpath}");
195                let extracted = extract_package_name(&specifier);
196                prop_assert_eq!(extracted, pkg);
197            }
198
199            /// is_bare_specifier and is_path_alias should never panic on arbitrary strings.
200            #[test]
201            fn bare_specifier_and_path_alias_no_panic(s in "[a-zA-Z0-9@#~/._-]{1,100}") {
202                let _ = is_bare_specifier(&s);
203                let _ = is_path_alias(&s);
204            }
205
206            /// `@/` prefix should always be detected as a path alias.
207            #[test]
208            fn at_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
209                let specifier = format!("@/{suffix}");
210                prop_assert!(is_path_alias(&specifier));
211            }
212
213            /// `~/` prefix should always be detected as a path alias.
214            #[test]
215            fn tilde_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
216                let specifier = format!("~/{suffix}");
217                prop_assert!(is_path_alias(&specifier));
218            }
219
220            /// `#` prefix should always be detected as a path alias (Node.js imports map).
221            #[test]
222            fn hash_prefix_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
223                let specifier = format!("#{suffix}");
224                prop_assert!(is_path_alias(&specifier));
225            }
226
227            /// Extracted package name from node_modules path should never be empty.
228            #[test]
229            fn node_modules_package_name_never_empty(
230                pkg in "[a-z][a-z0-9-]{0,20}",
231                file in "[a-z]{1,10}\\.(js|ts|mjs)",
232            ) {
233                let path = std::path::PathBuf::from(format!("/project/node_modules/{pkg}/{file}"));
234                if let Some(name) = crate::resolve::fallbacks::extract_package_name_from_node_modules_path(&path) {
235                    prop_assert!(!name.is_empty());
236                }
237            }
238        }
239    }
240}