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/// Check if a string looks like a valid npm package name.
46///
47/// Rejects strings that are clearly not packages: shell variables (`$X`),
48/// pure numbers, strings starting with `!`, empty strings, etc.
49/// This prevents false "unlisted dependency" reports for test fixture
50/// artifacts like `$DIR` or `1`.
51#[must_use]
52pub fn is_valid_package_name(name: &str) -> bool {
53    if name.is_empty() {
54        return false;
55    }
56    let first = name.as_bytes()[0];
57    // Reject shell variables, shebangs, and similar non-package prefixes
58    if first == b'$' || first == b'!' || first == b'#' {
59        return false;
60    }
61    // Reject bundler-internal specifiers (webpack loaders, turbopack barrel optimization)
62    if name.contains('?') || name.contains('!') || name.starts_with("__") {
63        return false;
64    }
65    // Pure numeric strings (like "1", "123") are not package names
66    if name.bytes().all(|b| b.is_ascii_digit()) {
67        return false;
68    }
69    // Must contain at least one letter or @ sign to be a plausible package name
70    if !name.bytes().any(|b| b.is_ascii_alphabetic() || b == b'@') {
71        return false;
72    }
73    // Reject strings with spaces or backslashes (not valid in npm names)
74    !name.contains(' ') && !name.contains('\\')
75}
76
77/// Extract the npm package name from a specifier.
78/// `@scope/pkg/foo/bar` -> `@scope/pkg`
79/// `lodash/merge` -> `lodash`
80#[must_use]
81pub fn extract_package_name(specifier: &str) -> String {
82    if specifier.starts_with('@') {
83        let parts: Vec<&str> = specifier.splitn(3, '/').collect();
84        if parts.len() >= 2 {
85            format!("{}/{}", parts[0], parts[1])
86        } else {
87            specifier.to_string()
88        }
89    } else {
90        specifier.split('/').next().unwrap_or(specifier).to_string()
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn test_extract_package_name() {
100        assert_eq!(extract_package_name("react"), "react");
101        assert_eq!(extract_package_name("lodash/merge"), "lodash");
102        assert_eq!(extract_package_name("@scope/pkg"), "@scope/pkg");
103        assert_eq!(extract_package_name("@scope/pkg/foo"), "@scope/pkg");
104    }
105
106    #[test]
107    fn test_is_bare_specifier() {
108        assert!(is_bare_specifier("react"));
109        assert!(is_bare_specifier("@scope/pkg"));
110        assert!(is_bare_specifier("#internal/module"));
111        assert!(!is_bare_specifier("./utils"));
112        assert!(!is_bare_specifier("../lib"));
113        assert!(!is_bare_specifier("/absolute"));
114    }
115
116    #[test]
117    fn test_is_bare_specifier_url_specifiers() {
118        assert!(!is_bare_specifier("https://cdn.example.com/lib.js"));
119        assert!(!is_bare_specifier("http://example.com/module"));
120        assert!(!is_bare_specifier("data:text/javascript,export default 42"));
121    }
122
123    // ── is_path_alias ───────────────────────────────────────────────
124
125    #[test]
126    fn path_alias_hash_prefix() {
127        assert!(is_path_alias("#internal/module"));
128        assert!(is_path_alias("#shared"));
129    }
130
131    #[test]
132    fn path_alias_tilde_prefix() {
133        assert!(is_path_alias("~/components/Button"));
134        assert!(is_path_alias("~~/utils/helpers"));
135    }
136
137    #[test]
138    fn path_alias_at_slash_prefix() {
139        assert!(is_path_alias("@/components/Button"));
140        assert!(is_path_alias("@/lib"));
141    }
142
143    #[test]
144    fn path_alias_pascal_case_scope() {
145        // PascalCase scoped packages are tsconfig aliases, not npm packages
146        assert!(is_path_alias("@Components/Button"));
147        assert!(is_path_alias("@Hooks/useApi"));
148        assert!(is_path_alias("@Services/auth"));
149    }
150
151    #[test]
152    fn path_alias_lowercase_scope_is_not_alias() {
153        // Lowercase scoped packages are regular npm packages
154        assert!(!is_path_alias("@babel/core"));
155        assert!(!is_path_alias("@types/react"));
156        assert!(!is_path_alias("@scope/pkg"));
157    }
158
159    #[test]
160    fn path_alias_plain_specifier_is_not_alias() {
161        assert!(!is_path_alias("react"));
162        assert!(!is_path_alias("lodash/merge"));
163        assert!(!is_path_alias("my-utils"));
164    }
165
166    #[test]
167    fn path_alias_tilde_without_slash_is_not_alias() {
168        // `~something` without a slash is not a path alias convention
169        assert!(!is_path_alias("~something"));
170    }
171
172    // ── is_valid_package_name ────────────────────────────────────────
173
174    #[test]
175    fn valid_package_names() {
176        assert!(is_valid_package_name("react"));
177        assert!(is_valid_package_name("@scope/pkg"));
178        assert!(is_valid_package_name("lodash.get"));
179        assert!(is_valid_package_name("my-pkg"));
180        assert!(is_valid_package_name("@babel/core"));
181        assert!(is_valid_package_name("3d-view")); // starts with digit but has letters
182    }
183
184    #[test]
185    fn invalid_package_names() {
186        assert!(!is_valid_package_name("$DIR"));
187        assert!(!is_valid_package_name("$ENV_VAR"));
188        assert!(!is_valid_package_name("1"));
189        assert!(!is_valid_package_name("123"));
190        assert!(!is_valid_package_name(""));
191        assert!(!is_valid_package_name("!important"));
192        assert!(!is_valid_package_name("has spaces"));
193        assert!(!is_valid_package_name("back\\slash"));
194    }
195
196    // ── extract_package_name edge cases ─────────────────────────────
197
198    #[test]
199    fn extract_package_name_bare_scope_only() {
200        // Edge case: just `@scope` without a package name
201        assert_eq!(extract_package_name("@scope"), "@scope");
202    }
203
204    #[test]
205    fn extract_package_name_deep_subpath() {
206        assert_eq!(
207            extract_package_name("@scope/pkg/deep/nested/path"),
208            "@scope/pkg"
209        );
210    }
211
212    #[test]
213    fn extract_package_name_single_name() {
214        assert_eq!(extract_package_name("react"), "react");
215    }
216
217    mod proptests {
218        use super::*;
219        use proptest::prelude::*;
220
221        proptest! {
222            /// Any specifier starting with `.` or `/` must NOT be classified as a bare specifier.
223            #[test]
224            fn relative_paths_are_not_bare(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
225                let dot = format!(".{suffix}");
226                let slash = format!("/{suffix}");
227                prop_assert!(!is_bare_specifier(&dot), "'.{suffix}' was classified as bare");
228                prop_assert!(!is_bare_specifier(&slash), "'/{suffix}' was classified as bare");
229            }
230
231            /// Scoped packages (@scope/pkg) should extract exactly `@scope/pkg` — two segments.
232            #[test]
233            fn scoped_package_name_has_two_segments(
234                scope in "[a-z][a-z0-9-]{0,20}",
235                pkg in "[a-z][a-z0-9-]{0,20}",
236                subpath in "(/[a-z0-9-]{1,20}){0,3}",
237            ) {
238                let specifier = format!("@{scope}/{pkg}{subpath}");
239                let extracted = extract_package_name(&specifier);
240                let expected = format!("@{scope}/{pkg}");
241                prop_assert_eq!(extracted, expected);
242            }
243
244            /// Unscoped packages should extract exactly the first path segment.
245            #[test]
246            fn unscoped_package_name_is_first_segment(
247                pkg in "[a-z][a-z0-9-]{0,30}",
248                subpath in "(/[a-z0-9-]{1,20}){0,3}",
249            ) {
250                let specifier = format!("{pkg}{subpath}");
251                let extracted = extract_package_name(&specifier);
252                prop_assert_eq!(extracted, pkg);
253            }
254
255            /// is_bare_specifier, is_path_alias, and is_valid_package_name should never panic on arbitrary strings.
256            #[test]
257            fn classification_functions_no_panic(s in "[a-zA-Z0-9@#~/._$!\\-]{1,100}") {
258                let _ = is_bare_specifier(&s);
259                let _ = is_path_alias(&s);
260                let _ = is_valid_package_name(&s);
261            }
262
263            /// Valid npm package names (lowercase letters, digits, hyphens, dots) must be accepted.
264            #[test]
265            fn valid_npm_names_accepted(name in "[a-z][a-z0-9._-]{0,30}") {
266                prop_assert!(is_valid_package_name(&name));
267            }
268
269            /// Shell variable specifiers ($...) must be rejected.
270            #[test]
271            fn shell_variables_rejected(suffix in "[A-Z_]{1,20}") {
272                let specifier = format!("${suffix}");
273                prop_assert!(!is_valid_package_name(&specifier));
274            }
275
276            /// Pure numeric specifiers must be rejected.
277            #[test]
278            fn pure_numbers_rejected(n in "[0-9]{1,10}") {
279                prop_assert!(!is_valid_package_name(&n));
280            }
281
282            /// `@/` prefix should always be detected as a path alias.
283            #[test]
284            fn at_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
285                let specifier = format!("@/{suffix}");
286                prop_assert!(is_path_alias(&specifier));
287            }
288
289            /// `~/` prefix should always be detected as a path alias.
290            #[test]
291            fn tilde_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
292                let specifier = format!("~/{suffix}");
293                prop_assert!(is_path_alias(&specifier));
294            }
295
296            /// `#` prefix should always be detected as a path alias (Node.js imports map).
297            #[test]
298            fn hash_prefix_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
299                let specifier = format!("#{suffix}");
300                prop_assert!(is_path_alias(&specifier));
301            }
302
303            /// Extracted package name from node_modules path should never be empty.
304            #[test]
305            fn node_modules_package_name_never_empty(
306                pkg in "[a-z][a-z0-9-]{0,20}",
307                file in "[a-z]{1,10}\\.(js|ts|mjs)",
308            ) {
309                let path = std::path::PathBuf::from(format!("/project/node_modules/{pkg}/{file}"));
310                if let Some(name) = crate::resolve::fallbacks::extract_package_name_from_node_modules_path(&path) {
311                    prop_assert!(!name.is_empty());
312                }
313            }
314        }
315    }
316}