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("~~/") {
17 return true;
18 }
19 if specifier.starts_with("@/") {
21 return true;
22 }
23 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#[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#[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 if first == b'$' || first == b'!' || first == b'#' {
59 return false;
60 }
61 if name.contains('?') || name.contains('!') || name.starts_with("__") {
63 return false;
64 }
65 if name.bytes().all(|b| b.is_ascii_digit()) {
67 return false;
68 }
69 if !name.bytes().any(|b| b.is_ascii_alphabetic() || b == b'@') {
71 return false;
72 }
73 !name.contains(' ') && !name.contains('\\')
75}
76
77#[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 #[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 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 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 assert!(!is_path_alias("~something"));
170 }
171
172 #[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")); }
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 #[test]
199 fn extract_package_name_bare_scope_only() {
200 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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
278 fn pure_numbers_rejected(n in "[0-9]{1,10}") {
279 prop_assert!(!is_valid_package_name(&n));
280 }
281
282 #[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 #[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 #[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 #[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}