Skip to main content

stylex_path_resolver/resolvers/
mod.rs

1use 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        // NOTE: This is a workaround for the case when the file is located in the node_modules directory and pnpm is package manager
102
103        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        // Try multiple import specifier variations to handle exports field matching.
146        // Prefer the version with an explicit extension first, to align with typical module
147        // resolution behavior when both extension-less and extension-qualified paths exist.
148        let import_specifiers = if potential_file_path.is_empty() {
149          vec![name.to_string()]
150        } else {
151          // Try full path with extension first, then without extension
152          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            // Compute relative path from cwd to the resolved file
164            if let Some(relative_resolved) = pathdiff::diff_paths(&resolved_path, cwd) {
165              // Use the relative path (which may start with ../ for parent directories)
166              potential_package_path = relative_resolved;
167            } else {
168              // Fallback: Convert to node_modules-relative path
169              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
193/// Creates an oxc_resolver with the appropriate options for resolving import paths.
194/// Lazy static resolver instance - created once and reused for all resolution calls.
195/// This is thread-safe and avoids the overhead of creating a new resolver for each call.
196static RESOLVER: Lazy<Resolver> = Lazy::new(|| {
197  let options = ResolveOptions {
198    extensions: EXTENSIONS.iter().map(|e| e.to_string()).collect(),
199    // Condition names for package.json exports field resolution.
200    // Order matters: more specific conditions should come before general ones.
201    // Note: "types" is excluded because StyleX resolves runtime code, not type definitions.
202    // - "import": ESM imports (prioritized over CommonJS)
203    // - "require": CommonJS require
204    // - "node": Node.js environment (StyleX runs at build time in Node)
205    // - "development"/"production": Environment-specific exports
206    // - "default": Fallback condition
207    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    // Resolve symlinks to their real paths (important for pnpm)
216    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  // Handle relative imports
249  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  // Handle absolute imports (starting with /)
288  if import_path_str.starts_with('/') {
289    let path_without_slash = import_path_str.trim_start_matches('/');
290
291    // First try aliased paths
292    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    // Then try root path
299    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  // Handle aliased imports (skip the first one which is the original path)
306  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  // Use oxc_resolver for node_modules resolution
314  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    // Try to convert to pnpm path if applicable
324    let pnpm_path = try_resolve_pnpm_path(&resolved_path);
325    return Ok(pnpm_path.clean());
326  }
327
328  // Fallback: try resolving from root path as well
329  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    // Try to convert to pnpm path if applicable
336    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
343/// Tries to find the corresponding pnpm path for a resolved node_modules path.
344/// pnpm stores packages in node_modules/.pnpm/<package-name>@<version>/node_modules/<package-name>
345/// This function checks if such a path exists and returns it if found.
346fn try_resolve_pnpm_path(resolved_path: &Path) -> PathBuf {
347  let resolved_str = resolved_path.to_string_lossy();
348
349  // Check if the path contains node_modules (but not .pnpm, which means it's already a pnpm path)
350  if !resolved_str.contains("node_modules") || resolved_str.contains(".pnpm") {
351    return resolved_path.to_path_buf();
352  }
353
354  // Find the node_modules directory and the package path after it
355  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  // Skip if already in .pnpm
362  if after_nm.starts_with(".pnpm") {
363    return resolved_path.to_path_buf();
364  }
365
366  // Extract package name and rest of path (handle scoped packages like @org/pkg)
367  let (package_name, rest_path) = if after_nm.starts_with('@') {
368    // Scoped package: @org/pkg/file.js
369    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    // Regular package: pkg/file.js
379    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  // Construct the package directory path in node_modules
386  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  // Check if .pnpm directory exists (indicating pnpm is being used)
390  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  // Try to follow the symlink to get the real pnpm path
396  // pnpm creates: node_modules/<pkg> -> .pnpm/<pkg>@<version>/node_modules/<pkg>
397  if let Ok(real_package_dir) = fs::read_link(&package_dir) {
398    // The symlink target is relative, resolve it against the node_modules directory
399    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    // Append the rest of the path to the real package directory
406    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  // Fallback: try to find the package in .pnpm by constructing the expected path
423  // This handles cases where symlinks aren't available (e.g., some Windows configs)
424  let pnpm_pkg_name = package_name.replace('/', "+");
425
426  // Try to read the package.json to get the version for direct path construction
427  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    // Construct the expected pnpm path: .pnpm/<pkg>@<version>/node_modules/<pkg>
439    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
458/// Tries to resolve a path by checking various file extensions.
459/// Handles three cases:
460/// 1. Path already has a valid extension (e.g., `.js`, `.ts`) - use as-is
461/// 2. Path has a partial extension (e.g., `.stylex`) - append additional extensions
462/// 3. Path has no extension - try each extension
463///
464/// Returns `Some(path)` if a valid file is found, `None` otherwise.
465fn try_resolve_with_extensions(base_path: &Path) -> Option<PathBuf> {
466  // First check if the path exists as-is
467  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    // Skip if the path already ends with this extension (we already checked it above)
475    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      // Check if this is already a valid extension
482      if EXTENSIONS
483        .iter()
484        .any(|e| e.ends_with(existing_ext_str.as_ref()))
485      {
486        // Already has a valid extension and was checked above; no further resolution possible
487        return None;
488      } else {
489        // Has a partial extension like .stylex, append the new extension
490        PathBuf::from(format!("{}{}", base_path.display(), ext))
491      }
492    } else {
493      // No extension, append one
494      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}