stylex_path_resolver/resolvers/
mod.rs1use fancy_regex::Regex;
2use log::{debug, warn};
3use once_cell::sync::Lazy;
4use oxc_resolver::{ResolveOptions, Resolver};
5use path_clean::PathClean;
6use rustc_hash::FxHashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use stylex_macros::stylex_panic;
10
11use crate::{
12 package_json::{PackageJsonExtended, get_package_json_deps},
13 utils::{contains_subpath, relative_path},
14};
15
16mod tests;
17
18pub const EXTENSIONS: [&str; 8] = [".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs", ".mdx", ".md"];
19
20pub static FILE_PATTERN: Lazy<Regex> =
21 Lazy::new(|| Regex::new(r#"\.(jsx?|tsx?|mdx?|mjs|cjs)$"#).unwrap());
22
23pub fn resolve_path(
24 processing_file: &Path,
25 root_dir: &Path,
26 package_json_seen: &mut FxHashMap<String, PackageJsonExtended>,
27) -> String {
28 let is_match = FILE_PATTERN
29 .is_match(processing_file.to_str().unwrap())
30 .unwrap_or_else(|err| {
31 warn!(
32 "Error matching FILE_PATTERN for '{}': {}. Skipping pattern match.",
33 processing_file.to_str().unwrap(),
34 err
35 );
36
37 false
38 });
39 if !is_match {
40 let processing_path = if cfg!(test) {
41 processing_file
42 .strip_prefix(root_dir.parent().unwrap().parent().unwrap())
43 .unwrap()
44 .to_path_buf()
45 } else {
46 processing_file.to_path_buf()
47 };
48
49 stylex_panic!(
50 r#"Resolve path must be a file, but got: {}"#,
51 processing_path.display()
52 );
53 }
54
55 let cwd = if cfg!(test) {
56 root_dir.to_path_buf()
57 } else {
58 "cwd".into()
59 };
60
61 let mut path_by_package_json =
62 match resolve_from_package_json(processing_file, root_dir, &cwd, package_json_seen) {
63 Ok(value) => value,
64 Err(value) => return value,
65 };
66
67 if path_by_package_json.starts_with(&cwd) {
68 path_by_package_json = path_by_package_json
69 .strip_prefix(cwd)
70 .unwrap()
71 .to_path_buf();
72 }
73
74 let resolved_path_by_package_name = path_by_package_json.clean().display().to_string();
75
76 if cfg!(test) {
77 let cwd_resolved_path = format!("{}/{}", root_dir.display(), resolved_path_by_package_name);
78
79 assert!(
80 fs::metadata(&cwd_resolved_path).is_ok(),
81 "Path resolution failed: {}",
82 resolved_path_by_package_name
83 );
84 }
85
86 resolved_path_by_package_name
87}
88
89fn resolve_from_package_json(
90 processing_file: &Path,
91 root_dir: &Path,
92 cwd: &Path,
93 package_json_seen: &mut FxHashMap<String, PackageJsonExtended>,
94) -> Result<PathBuf, String> {
95 let resolved_path = match processing_file.strip_prefix(root_dir) {
96 Ok(stripped) => stripped.to_path_buf(),
97 Err(_) => {
98 let processing_file_str = processing_file.to_string_lossy();
99
100 if let Some(node_modules_index) = processing_file_str.rfind("node_modules") {
101 let resolved_path_from_node_modules = processing_file_str
104 .split_at(node_modules_index)
105 .1
106 .to_string();
107
108 if !resolved_path_from_node_modules.is_empty() {
109 return Err(resolved_path_from_node_modules);
110 }
111 }
112
113 let relative_package_path = relative_path(processing_file, root_dir);
114
115 get_package_path_by_package_json(cwd, &relative_package_path, package_json_seen)
116 },
117 };
118
119 Ok(resolved_path)
120}
121
122fn get_package_path_by_package_json(
123 cwd: &Path,
124 relative_package_path: &Path,
125 package_json_seen: &mut FxHashMap<String, PackageJsonExtended>,
126) -> PathBuf {
127 let package_dependencies = get_package_json_deps(cwd, package_json_seen);
128
129 let mut potential_package_path: PathBuf = PathBuf::default();
130
131 for (name, _) in package_dependencies.iter() {
132 let potential_path_section = name.split("/").last().unwrap_or_default();
133
134 if contains_subpath(relative_package_path, Path::new(&potential_path_section)) {
135 let relative_package_path_str = relative_package_path.display().to_string();
136
137 let potential_file_path = relative_package_path_str
138 .split(potential_path_section)
139 .last()
140 .unwrap_or_default();
141
142 if !potential_file_path.is_empty()
143 || relative_package_path_str.ends_with(format!("/{}", potential_path_section).as_str())
144 {
145 let import_specifiers = if potential_file_path.is_empty() {
149 vec![name.to_string()]
150 } else {
151 let path_with_ext = format!("{}{}", name, potential_file_path);
153 let path_without_ext = Path::new(&path_with_ext)
154 .with_extension("")
155 .to_string_lossy()
156 .to_string();
157 vec![path_with_ext, path_without_ext]
158 };
159
160 for import_specifier in import_specifiers {
161 if let Ok(resolution) = RESOLVER.resolve(cwd, &import_specifier) {
162 let resolved_path = resolution.full_path();
163 if let Some(relative_resolved) = pathdiff::diff_paths(&resolved_path, cwd) {
165 potential_package_path = relative_resolved;
167 } else {
168 let resolved_str = resolved_path.to_string_lossy();
170 if let Some(node_modules_idx) = resolved_str.rfind("node_modules") {
171 potential_package_path = PathBuf::from(&resolved_str[node_modules_idx..]);
172 } else {
173 potential_package_path = resolved_path;
174 }
175 }
176 break;
177 }
178 }
179
180 if potential_package_path.as_os_str().is_empty() {
181 potential_package_path =
182 PathBuf::from(format!("node_modules/{}{}", name, potential_file_path));
183 }
184
185 break;
186 }
187 }
188 }
189
190 potential_package_path
191}
192
193static RESOLVER: Lazy<Resolver> = Lazy::new(|| {
197 let options = ResolveOptions {
198 extensions: EXTENSIONS.iter().map(|e| e.to_string()).collect(),
199 condition_names: vec![
208 "import".to_string(),
209 "require".to_string(),
210 "node".to_string(),
211 "development".to_string(),
212 "production".to_string(),
213 "default".to_string(),
214 ],
215 symlinks: true,
217 ..Default::default()
218 };
219
220 Resolver::new(options)
221});
222
223fn file_not_found_error(import_path: &str) -> std::io::Error {
224 std::io::Error::new(
225 std::io::ErrorKind::NotFound,
226 format!("File not found for import: {}", import_path),
227 )
228}
229
230pub fn resolve_file_path(
231 import_path_str: &str,
232 source_file_path: &str,
233 root_path: &str,
234 aliases: &FxHashMap<String, Vec<String>>,
235 package_json_seen: &mut FxHashMap<String, PackageJsonExtended>,
236) -> std::io::Result<PathBuf> {
237 let source_file_dir = Path::new(source_file_path).parent().ok_or_else(|| {
238 std::io::Error::new(
239 std::io::ErrorKind::InvalidInput,
240 format!(
241 "Source file path '{}' has no parent directory",
242 source_file_path
243 ),
244 )
245 })?;
246 let root_path = Path::new(root_path);
247
248 if import_path_str.starts_with('.') {
250 if FILE_PATTERN
251 .is_match(import_path_str)
252 .unwrap_or_else(|err| {
253 warn!(
254 "Error matching FILE_PATTERN for '{}': {}. Skipping pattern match.",
255 import_path_str, err
256 );
257 false
258 })
259 {
260 let resolved = PathBuf::from(resolve_path(
261 &source_file_dir.join(import_path_str),
262 root_path,
263 package_json_seen,
264 ));
265 let full_path = root_path.join(&resolved).clean();
266 if fs::metadata(&full_path).is_ok() {
267 return Ok(full_path);
268 }
269 } else {
270 for ext in EXTENSIONS.iter() {
271 let import_path_str_with_ext = format!("{}{}", import_path_str, ext);
272 let resolved = PathBuf::from(resolve_path(
273 &source_file_dir.join(&import_path_str_with_ext),
274 root_path,
275 package_json_seen,
276 ));
277 let full_path = root_path.join(&resolved).clean();
278 if fs::metadata(&full_path).is_ok() {
279 return Ok(full_path);
280 }
281 }
282 }
283
284 return Err(file_not_found_error(import_path_str));
285 }
286
287 if import_path_str.starts_with('/') {
289 let path_without_slash = import_path_str.trim_start_matches('/');
290
291 for aliased_path in possible_aliased_paths(import_path_str, aliases) {
293 if let Some(resolved) = try_resolve_with_extensions(&aliased_path) {
294 return Ok(resolved);
295 }
296 }
297
298 let root_based_path = root_path.join(path_without_slash);
300 if let Some(resolved) = try_resolve_with_extensions(&root_based_path) {
301 return Ok(resolved);
302 }
303 }
304
305 let aliased_paths = possible_aliased_paths(import_path_str, aliases);
307 for aliased_path in aliased_paths.iter().skip(1) {
308 if let Some(resolved) = try_resolve_with_extensions(aliased_path) {
309 return Ok(resolved);
310 }
311 }
312
313 debug!(
315 "Resolving import '{}' from directory '{}'",
316 import_path_str,
317 source_file_dir.display()
318 );
319
320 if let Ok(resolution) = RESOLVER.resolve(source_file_dir, import_path_str) {
321 let resolved_path = resolution.full_path();
322 debug!("oxc_resolver resolved to: {}", resolved_path.display());
323 let pnpm_path = try_resolve_pnpm_path(&resolved_path);
325 return Ok(pnpm_path.clean());
326 }
327
328 if let Ok(resolution) = RESOLVER.resolve(root_path, import_path_str) {
330 let resolved_path = resolution.full_path();
331 debug!(
332 "oxc_resolver resolved from root to: {}",
333 resolved_path.display()
334 );
335 let pnpm_path = try_resolve_pnpm_path(&resolved_path);
337 return Ok(pnpm_path.clean());
338 }
339
340 Err(file_not_found_error(import_path_str))
341}
342
343fn try_resolve_pnpm_path(resolved_path: &Path) -> PathBuf {
347 let resolved_str = resolved_path.to_string_lossy();
348
349 if !resolved_str.contains("node_modules") || resolved_str.contains(".pnpm") {
351 return resolved_path.to_path_buf();
352 }
353
354 let Some(nm_idx) = resolved_str.find("node_modules/") else {
356 return resolved_path.to_path_buf();
357 };
358
359 let after_nm = &resolved_str[nm_idx + "node_modules/".len()..];
360
361 if after_nm.starts_with(".pnpm") {
363 return resolved_path.to_path_buf();
364 }
365
366 let (package_name, rest_path) = if after_nm.starts_with('@') {
368 let parts: Vec<&str> = after_nm.splitn(3, '/').collect();
370 if parts.len() >= 2 {
371 let pkg_name = format!("{}/{}", parts[0], parts[1]);
372 let rest = if parts.len() > 2 { parts[2] } else { "" };
373 (pkg_name, rest)
374 } else {
375 return resolved_path.to_path_buf();
376 }
377 } else {
378 let parts: Vec<&str> = after_nm.splitn(2, '/').collect();
380 let pkg_name = parts[0].to_string();
381 let rest = if parts.len() > 1 { parts[1] } else { "" };
382 (pkg_name, rest)
383 };
384
385 let node_modules_base = &resolved_str[..nm_idx + "node_modules".len()];
387 let package_dir = Path::new(node_modules_base).join(&package_name);
388
389 let pnpm_dir = Path::new(node_modules_base).join(".pnpm");
391 if !pnpm_dir.exists() || !pnpm_dir.is_dir() {
392 return resolved_path.to_path_buf();
393 }
394
395 if let Ok(real_package_dir) = fs::read_link(&package_dir) {
398 let real_package_path = if real_package_dir.is_absolute() {
400 real_package_dir
401 } else {
402 Path::new(node_modules_base).join(&real_package_dir).clean()
403 };
404
405 let pnpm_path = if rest_path.is_empty() {
407 real_package_path
408 } else {
409 real_package_path.join(rest_path)
410 };
411
412 if pnpm_path.exists() {
413 debug!(
414 "Converted to pnpm path via symlink: {} -> {}",
415 resolved_path.display(),
416 pnpm_path.display()
417 );
418 return pnpm_path;
419 }
420 }
421
422 let pnpm_pkg_name = package_name.replace('/', "+");
425
426 let package_json_path = package_dir.join("package.json");
428 if let Some(version) = fs::read_to_string(&package_json_path)
429 .ok()
430 .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
431 .and_then(|pkg_json| {
432 pkg_json
433 .get("version")
434 .and_then(|v| v.as_str())
435 .map(String::from)
436 })
437 {
438 let pnpm_package_path = pnpm_dir
440 .join(format!("{}@{}", pnpm_pkg_name, version))
441 .join("node_modules")
442 .join(&package_name)
443 .join(rest_path);
444
445 if pnpm_package_path.exists() {
446 debug!(
447 "Converted to pnpm path via package.json: {} -> {}",
448 resolved_path.display(),
449 pnpm_package_path.display()
450 );
451 return pnpm_package_path;
452 }
453 }
454
455 resolved_path.to_path_buf()
456}
457
458fn try_resolve_with_extensions(base_path: &Path) -> Option<PathBuf> {
466 if fs::metadata(base_path).is_ok() {
468 return Some(base_path.to_path_buf().clean());
469 }
470
471 let path_str = base_path.to_string_lossy();
472
473 for ext in EXTENSIONS.iter() {
474 if path_str.ends_with(ext) {
476 continue;
477 }
478
479 let path_to_check = if let Some(existing_ext) = base_path.extension() {
480 let existing_ext_str = existing_ext.to_string_lossy();
481 if EXTENSIONS
483 .iter()
484 .any(|e| e.ends_with(existing_ext_str.as_ref()))
485 {
486 return None;
488 } else {
489 PathBuf::from(format!("{}{}", base_path.display(), ext))
491 }
492 } else {
493 PathBuf::from(format!("{}{}", base_path.display(), ext))
495 };
496
497 if fs::metadata(&path_to_check).is_ok() {
498 return Some(path_to_check.clean());
499 }
500 }
501
502 None
503}
504
505fn possible_aliased_paths(
506 import_path_str: &str,
507 aliases: &FxHashMap<String, Vec<String>>,
508) -> Vec<PathBuf> {
509 let mut result = vec![PathBuf::from(import_path_str)];
510
511 if aliases.is_empty() {
512 return result;
513 }
514
515 for (alias, values) in aliases.iter() {
516 if let Some((before, after)) = alias.split_once('*') {
517 if import_path_str.starts_with(before) && import_path_str.ends_with(after) {
518 let replacement_string =
519 &import_path_str[before.len()..import_path_str.len() - after.len()];
520 for value in values {
521 result.push(PathBuf::from(value.replace('*', replacement_string)));
522 }
523 }
524 } else if alias == import_path_str {
525 result.extend(values.iter().map(PathBuf::from));
526 }
527 }
528
529 result
530}