fallow_graph/resolve/
path_info.rs1#[must_use]
10pub fn is_path_alias(specifier: &str) -> bool {
11 if specifier.starts_with('#') {
13 return true;
14 }
15 if specifier.starts_with("~/") || specifier.starts_with("~~/") || specifier.starts_with("@@/") {
18 return true;
19 }
20 if specifier.starts_with("@/") {
22 return true;
23 }
24 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#[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#[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 if first == b'$' || first == b'!' || first == b'#' {
60 return false;
61 }
62 if name.contains('?') || name.contains('!') || name.starts_with("__") {
64 return false;
65 }
66 if name.bytes().all(|b| b.is_ascii_digit()) {
68 return false;
69 }
70 if !name.bytes().any(|b| b.is_ascii_alphabetic() || b == b'@') {
72 return false;
73 }
74 !name.contains(' ') && !name.contains('\\')
76}
77
78#[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#[cfg(test)]
96mod tests {
97 use super::*;
98
99 #[test]
100 fn test_extract_package_name() {
101 assert_eq!(extract_package_name("react"), "react");
102 assert_eq!(extract_package_name("lodash/merge"), "lodash");
103 assert_eq!(extract_package_name("@scope/pkg"), "@scope/pkg");
104 assert_eq!(extract_package_name("@scope/pkg/foo"), "@scope/pkg");
105 }
106
107 #[test]
108 fn test_is_bare_specifier() {
109 assert!(is_bare_specifier("react"));
110 assert!(is_bare_specifier("@scope/pkg"));
111 assert!(is_bare_specifier("#internal/module"));
112 assert!(!is_bare_specifier("./utils"));
113 assert!(!is_bare_specifier("../lib"));
114 assert!(!is_bare_specifier("/absolute"));
115 }
116
117 #[test]
118 fn test_is_bare_specifier_url_specifiers() {
119 assert!(!is_bare_specifier("https://cdn.example.com/lib.js"));
120 assert!(!is_bare_specifier("http://example.com/module"));
121 assert!(!is_bare_specifier("data:text/javascript,export default 42"));
122 }
123
124 #[test]
127 fn path_alias_hash_prefix() {
128 assert!(is_path_alias("#internal/module"));
129 assert!(is_path_alias("#shared"));
130 }
131
132 #[test]
133 fn path_alias_tilde_prefix() {
134 assert!(is_path_alias("~/components/Button"));
135 assert!(is_path_alias("~~/utils/helpers"));
136 assert!(is_path_alias("@@/shared/utils"));
137 }
138
139 #[test]
140 fn path_alias_at_slash_prefix() {
141 assert!(is_path_alias("@/components/Button"));
142 assert!(is_path_alias("@/lib"));
143 }
144
145 #[test]
146 fn path_alias_pascal_case_scope() {
147 assert!(is_path_alias("@Components/Button"));
149 assert!(is_path_alias("@Hooks/useApi"));
150 assert!(is_path_alias("@Services/auth"));
151 }
152
153 #[test]
154 fn path_alias_lowercase_scope_is_not_alias() {
155 assert!(!is_path_alias("@babel/core"));
157 assert!(!is_path_alias("@types/react"));
158 assert!(!is_path_alias("@scope/pkg"));
159 }
160
161 #[test]
162 fn path_alias_plain_specifier_is_not_alias() {
163 assert!(!is_path_alias("react"));
164 assert!(!is_path_alias("lodash/merge"));
165 assert!(!is_path_alias("my-utils"));
166 }
167
168 #[test]
169 fn path_alias_tilde_without_slash_is_not_alias() {
170 assert!(!is_path_alias("~something"));
172 }
173
174 #[test]
177 fn valid_package_names() {
178 assert!(is_valid_package_name("react"));
179 assert!(is_valid_package_name("@scope/pkg"));
180 assert!(is_valid_package_name("lodash.get"));
181 assert!(is_valid_package_name("my-pkg"));
182 assert!(is_valid_package_name("@babel/core"));
183 assert!(is_valid_package_name("3d-view")); }
185
186 #[test]
187 fn invalid_package_names() {
188 assert!(!is_valid_package_name("$DIR"));
189 assert!(!is_valid_package_name("$ENV_VAR"));
190 assert!(!is_valid_package_name("1"));
191 assert!(!is_valid_package_name("123"));
192 assert!(!is_valid_package_name(""));
193 assert!(!is_valid_package_name("!important"));
194 assert!(!is_valid_package_name("has spaces"));
195 assert!(!is_valid_package_name("back\\slash"));
196 }
197
198 #[test]
201 fn extract_package_name_bare_scope_only() {
202 assert_eq!(extract_package_name("@scope"), "@scope");
204 }
205
206 #[test]
207 fn extract_package_name_deep_subpath() {
208 assert_eq!(
209 extract_package_name("@scope/pkg/deep/nested/path"),
210 "@scope/pkg"
211 );
212 }
213
214 #[test]
215 fn extract_package_name_single_name() {
216 assert_eq!(extract_package_name("react"), "react");
217 }
218
219 mod proptests {
220 use super::*;
221 use proptest::prelude::*;
222
223 proptest! {
224 #[test]
226 fn relative_paths_are_not_bare(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
227 let dot = format!(".{suffix}");
228 let slash = format!("/{suffix}");
229 prop_assert!(!is_bare_specifier(&dot), "'.{suffix}' was classified as bare");
230 prop_assert!(!is_bare_specifier(&slash), "'/{suffix}' was classified as bare");
231 }
232
233 #[test]
235 fn scoped_package_name_has_two_segments(
236 scope in "[a-z][a-z0-9-]{0,20}",
237 pkg in "[a-z][a-z0-9-]{0,20}",
238 subpath in "(/[a-z0-9-]{1,20}){0,3}",
239 ) {
240 let specifier = format!("@{scope}/{pkg}{subpath}");
241 let extracted = extract_package_name(&specifier);
242 let expected = format!("@{scope}/{pkg}");
243 prop_assert_eq!(extracted, expected);
244 }
245
246 #[test]
248 fn unscoped_package_name_is_first_segment(
249 pkg in "[a-z][a-z0-9-]{0,30}",
250 subpath in "(/[a-z0-9-]{1,20}){0,3}",
251 ) {
252 let specifier = format!("{pkg}{subpath}");
253 let extracted = extract_package_name(&specifier);
254 prop_assert_eq!(extracted, pkg);
255 }
256
257 #[test]
259 fn classification_functions_no_panic(s in "[a-zA-Z0-9@#~/._$!\\-]{1,100}") {
260 let _ = is_bare_specifier(&s);
261 let _ = is_path_alias(&s);
262 let _ = is_valid_package_name(&s);
263 }
264
265 #[test]
267 fn valid_npm_names_accepted(name in "[a-z][a-z0-9._-]{0,30}") {
268 prop_assert!(is_valid_package_name(&name));
269 }
270
271 #[test]
273 fn shell_variables_rejected(suffix in "[A-Z_]{1,20}") {
274 let specifier = format!("${suffix}");
275 prop_assert!(!is_valid_package_name(&specifier));
276 }
277
278 #[test]
280 fn pure_numbers_rejected(n in "[0-9]{1,10}") {
281 prop_assert!(!is_valid_package_name(&n));
282 }
283
284 #[test]
286 fn at_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
287 let specifier = format!("@/{suffix}");
288 prop_assert!(is_path_alias(&specifier));
289 }
290
291 #[test]
293 fn tilde_slash_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
294 let specifier = format!("~/{suffix}");
295 prop_assert!(is_path_alias(&specifier));
296 }
297
298 #[test]
300 fn hash_prefix_is_path_alias(suffix in "[a-zA-Z0-9_/.-]{0,80}") {
301 let specifier = format!("#{suffix}");
302 prop_assert!(is_path_alias(&specifier));
303 }
304
305 #[test]
307 fn node_modules_package_name_never_empty(
308 pkg in "[a-z][a-z0-9-]{0,20}",
309 file in "[a-z]{1,10}\\.(js|ts|mjs)",
310 ) {
311 let path = std::path::PathBuf::from(format!("/project/node_modules/{pkg}/{file}"));
312 if let Some(name) = crate::resolve::fallbacks::extract_package_name_from_node_modules_path(&path) {
313 prop_assert!(!name.is_empty());
314 }
315 }
316 }
317 }
318}