1#[cfg(debug_assertions)]
4use std::sync::atomic::{AtomicU32, Ordering};
5use std::{
6 collections::{HashMap, HashSet, VecDeque},
7 env, io,
8 path::{Component, Path, PathBuf},
9 sync::Arc,
10};
11
12use compact_str::CompactString;
13use oxc_resolver::{
14 FileSystem, FileSystemOs, ResolveContext, ResolveError, ResolveOptions, ResolverGeneric,
15 TsconfigDiscovery, TsconfigOptions, TsconfigReferences,
16};
17use parking_lot::Mutex;
18use serde_json::Value;
19
20use super::{
21 FailedKind, ResolutionCompleteness, ResolutionOutcome, Resolve, Resolved, UnresolvedReason,
22};
23use crate::imports::{ImportKind, RawImport};
24
25const MAX_TSCONFIG_BYTES: usize = 1024 * 1024;
26const MAX_TSCONFIG_EXTENDS_ENTRIES: usize = 32;
27const MAX_TSCONFIG_EXTENDS_VISITS: usize = 256;
28
29#[derive(Debug, Clone)]
31pub struct JsResolveOptions {
32 pub tsconfig: Option<PathBuf>,
35 pub condition_names: Vec<String>,
37 pub extensions: Vec<String>,
39}
40
41impl Default for JsResolveOptions {
42 fn default() -> Self {
43 Self {
44 tsconfig: None,
45 condition_names: vec!["import".into(), "require".into()],
48 extensions: vec![
49 ".ts".into(),
50 ".tsx".into(),
51 ".mts".into(),
52 ".cts".into(),
53 ".js".into(),
54 ".jsx".into(),
55 ".mjs".into(),
56 ".cjs".into(),
57 ],
58 }
59 }
60}
61
62pub fn js_resolver(options: JsResolveOptions) -> Box<dyn Resolve> {
64 build_js_resolver(FileSystemOs::new(), options)
65}
66
67pub fn js_resolver_with_fs<FS: FileSystem + 'static>(
69 fs: FS,
70 options: JsResolveOptions,
71) -> Box<dyn Resolve> {
72 build_js_resolver(fs, options)
73}
74
75fn build_js_resolver<FS: FileSystem + 'static>(
76 fs: FS,
77 options: JsResolveOptions,
78) -> Box<dyn Resolve> {
79 let (import_options, require_options, configured_tsconfig) = resolver_options(options);
80 let file_system = SharedFileSystem::from_file_system(fs);
81 let import_resolver =
82 ResolverGeneric::new_with_file_system(file_system.clone(), import_options);
83 let require_resolver = import_resolver.clone_with_options(require_options);
84 Box::new(JsResolver {
85 import_resolver,
86 require_resolver,
87 file_system,
88 configured_tsconfig,
89 dependency_memo: Mutex::new(HashMap::new()),
90 #[cfg(debug_assertions)]
91 in_flight: AtomicU32::new(0),
92 })
93}
94
95struct JsResolver {
96 import_resolver: ResolverGeneric<SharedFileSystem>,
97 require_resolver: ResolverGeneric<SharedFileSystem>,
98 file_system: SharedFileSystem,
99 configured_tsconfig: Option<PathBuf>,
100 dependency_memo: Mutex<HashMap<ResolutionMemoKey, Vec<CompactString>>>,
101 #[cfg(debug_assertions)]
102 in_flight: AtomicU32,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Hash)]
106struct ResolutionMemoKey {
107 from_dir: PathBuf,
108 specifier: CompactString,
109 kind: ImportKind,
110}
111
112impl Resolve for JsResolver {
113 fn resolve(&self, from_file: &str, import: &RawImport) -> ResolutionOutcome {
114 #[cfg(debug_assertions)]
115 let _in_flight = InFlightResolve::enter(&self.in_flight);
116
117 if matches!(import.kind, ImportKind::RustUse | ImportKind::RustMod) {
118 return unresolved(UnresolvedReason::Unsupported, Vec::new(), Vec::new());
119 }
120
121 let from_path = Path::new(from_file);
122 debug_assert!(
123 from_path.is_absolute(),
124 "from_file must be absolute: {from_file}"
125 );
126 if !from_path.is_absolute() {
127 return unresolved(
128 failed(FailedKind::InvalidSpecifier, "from_file must be absolute"),
129 Vec::new(),
130 Vec::new(),
131 );
132 }
133
134 let Some(parent) = from_path.parent() else {
135 return unresolved(
136 failed(
137 FailedKind::InvalidSpecifier,
138 "from_file must have a parent directory",
139 ),
140 Vec::new(),
141 Vec::new(),
142 );
143 };
144 let memo_key = ResolutionMemoKey {
145 from_dir: parent.to_path_buf(),
146 specifier: import.specifier.clone(),
147 kind: import.kind,
148 };
149 let resolver = self.resolver_for(import.kind);
150
151 let mut dependency_paths: Vec<PathBuf> = self.configured_tsconfig.iter().cloned().collect();
152 let mut notes = Vec::new();
153 let mut tsconfig_tracking_truncated = false;
154 let tsconfig = match resolver.find_tsconfig(from_path) {
155 Ok(tsconfig) => tsconfig,
156 Err(error) => {
157 if let Some(configured_tsconfig) = &self.configured_tsconfig {
158 let tracking = self.track_tsconfig_chain(configured_tsconfig);
159 tsconfig_tracking_truncated |= tracking.truncated;
160 dependency_paths.extend(tracking.dependencies);
161 notes.extend(tracking.notes);
162 }
163 dependency_paths.extend(error_dependency_paths(&error));
164 let mut outcome = unresolved(
165 classify_error(error),
166 collect_dependencies(ResolveContext::default(), dependency_paths),
167 notes,
168 );
169 if tsconfig_tracking_truncated {
170 outcome.completeness = ResolutionCompleteness::Partial;
171 }
172 return self.replay_dependencies(memo_key, outcome);
173 }
174 };
175 if let Some(tsconfig) = &tsconfig {
176 let tracking = self.track_tsconfig_chain(tsconfig.path());
177 tsconfig_tracking_truncated |= tracking.truncated;
178 dependency_paths.extend(tracking.dependencies);
179 notes.extend(tracking.notes);
180 }
181
182 let mut context = ResolveContext::default();
183 let resolution = resolver.resolve_with_context(
184 parent,
185 import.specifier.as_str(),
186 tsconfig.as_deref(),
187 &mut context,
188 );
189
190 let mut outcome = match resolution {
191 Ok(resolution) => {
192 let package_json = resolution.package_json();
193 dependency_paths
194 .extend(package_json.map(|package_json| package_json.path().to_path_buf()));
195 ResolutionOutcome {
196 resolved: classify_resolution(
197 import.specifier.as_str(),
198 resolution.path(),
199 package_json.and_then(|package_json| package_json.name()),
200 ),
201 dependencies: collect_dependencies(context, dependency_paths),
202 notes,
203 completeness: ResolutionCompleteness::Complete,
204 }
205 }
206 Err(error) => {
207 dependency_paths.extend(error_dependency_paths(&error));
208 unresolved(
209 classify_error(error),
210 collect_dependencies(context, dependency_paths),
211 notes,
212 )
213 }
214 };
215 if tsconfig_tracking_truncated {
216 outcome.completeness = ResolutionCompleteness::Partial;
217 }
218 self.replay_dependencies(memo_key, outcome)
219 }
220
221 fn clear_cache(&self) {
222 #[cfg(debug_assertions)]
223 debug_assert_eq!(
224 self.in_flight.load(Ordering::Acquire),
225 0,
226 "clear_cache must not overlap an in-flight resolve"
227 );
228 self.import_resolver.clear_cache();
229 self.require_resolver.clear_cache();
230 self.dependency_memo.lock().clear();
231 }
232}
233
234impl JsResolver {
235 fn resolver_for(&self, kind: ImportKind) -> &ResolverGeneric<SharedFileSystem> {
236 match kind {
237 ImportKind::CommonJs | ImportKind::TsImportRequire => &self.require_resolver,
238 _ => &self.import_resolver,
239 }
240 }
241
242 fn replay_dependencies(
243 &self,
244 key: ResolutionMemoKey,
245 mut outcome: ResolutionOutcome,
246 ) -> ResolutionOutcome {
247 let mut memo = self.dependency_memo.lock();
248 if let Some(dependencies) = memo.get(&key) {
249 outcome.dependencies.extend(dependencies.iter().cloned());
250 }
251 normalize_dependencies(&mut outcome.dependencies);
252 memo.insert(key, outcome.dependencies.clone());
253 outcome
254 }
255
256 fn track_tsconfig_chain(&self, leaf: &Path) -> TsconfigTracking {
257 let extends_resolver = self
258 .import_resolver
259 .clone_with_options(tsconfig_extends_options());
260 let mut tracking = TsconfigTracking::default();
261 let mut pending = VecDeque::from([(absolute_path(leaf), Vec::new())]);
262 let mut visited = HashSet::new();
263
264 while let Some((config_path, mut ancestry)) = pending.pop_front() {
265 if ancestry.contains(&config_path) {
266 tracking.notes.push(
267 format!(
268 "tsconfig extends cycle while tracking dependencies: {}",
269 config_path.display()
270 )
271 .into(),
272 );
273 continue;
274 }
275 if visited.contains(&config_path) {
276 continue;
277 }
278 if visited.len() == MAX_TSCONFIG_EXTENDS_VISITS {
279 tracking.truncated = true;
280 tracking.notes.push(
281 format!(
282 "tsconfig extends visit budget of {MAX_TSCONFIG_EXTENDS_VISITS} configs \
283 exhausted while tracking dependencies; {} configs remain pending",
284 pending.len() + 1
285 )
286 .into(),
287 );
288 break;
289 }
290 visited.insert(config_path.clone());
291 ancestry.push(config_path.clone());
292 tracking.dependencies.push(config_path.clone());
293
294 let mut source = match self.file_system.read_to_string(&config_path) {
295 Ok(source) => source,
296 Err(error) => {
297 tracking.notes.push(
298 format!(
299 "could not read tsconfig extends from {}: {error}",
300 config_path.display()
301 )
302 .into(),
303 );
304 continue;
305 }
306 };
307 if source.len() > MAX_TSCONFIG_BYTES {
308 tracking.truncated = true;
309 tracking.notes.push(
310 format!(
311 "tsconfig extends file {} exceeds the size limit of \
312 {MAX_TSCONFIG_BYTES} bytes ({} bytes)",
313 config_path.display(),
314 source.len()
315 )
316 .into(),
317 );
318 continue;
319 }
320 if let Err(error) = json_strip_comments::strip(&mut source) {
321 tracking.notes.push(
322 format!(
323 "could not strip JSONC syntax from tsconfig extends in {}: {error}",
324 config_path.display()
325 )
326 .into(),
327 );
328 continue;
329 }
330 let value: Value = match serde_json::from_str(&source) {
331 Ok(value) => value,
332 Err(error) => {
333 tracking.notes.push(
334 format!(
335 "could not parse tsconfig extends from {}: {error}",
336 config_path.display()
337 )
338 .into(),
339 );
340 continue;
341 }
342 };
343 let specifiers = match extends_specifiers(&value) {
344 Ok(specifiers) => specifiers,
345 Err(detail) => {
346 tracking.notes.push(
347 format!(
348 "invalid tsconfig extends in {}: {detail}",
349 config_path.display()
350 )
351 .into(),
352 );
353 continue;
354 }
355 };
356 let mut specifiers = specifiers;
357 if specifiers.len() > MAX_TSCONFIG_EXTENDS_ENTRIES {
358 tracking.truncated = true;
359 tracking.notes.push(
360 format!(
361 "tsconfig extends entry limit of {MAX_TSCONFIG_EXTENDS_ENTRIES} exceeded \
362 in {}; only the first {MAX_TSCONFIG_EXTENDS_ENTRIES} of {} entries were \
363 tracked",
364 config_path.display(),
365 specifiers.len()
366 )
367 .into(),
368 );
369 specifiers.truncate(MAX_TSCONFIG_EXTENDS_ENTRIES);
370 }
371 if specifiers.is_empty() {
372 continue;
373 }
374 let Some(directory) = config_path.parent() else {
375 tracking.notes.push(
376 format!(
377 "tsconfig has no parent directory while tracking extends: {}",
378 config_path.display()
379 )
380 .into(),
381 );
382 continue;
383 };
384 for specifier in specifiers {
385 let package_style = is_package_style_extends(&specifier);
386 let target_path =
387 (!package_style).then(|| extends_target_path(directory, &specifier));
388 let absolute_specifier = target_path.as_deref().map(Path::to_string_lossy);
389 let resolution_specifier =
390 absolute_specifier.as_deref().unwrap_or(specifier.as_str());
391 let mut context = ResolveContext::default();
392 let resolution = extends_resolver.resolve_with_context(
393 directory,
394 resolution_specifier,
395 None,
396 &mut context,
397 );
398 if !package_style {
399 tracking.dependencies.extend(context.file_dependencies);
400 tracking.dependencies.extend(context.missing_dependencies);
401 }
402
403 match resolution {
404 Ok(resolution) => {
405 tracking.dependencies.extend(
406 resolution
407 .package_json()
408 .map(|package_json| package_json.path().to_path_buf()),
409 );
410 pending.push_back((absolute_path(resolution.path()), ancestry.clone()));
411 }
412 Err(error) => {
413 tracking.dependencies.extend(target_path);
414 let kind = if package_style { "package-style " } else { "" };
415 tracking.notes.push(
416 format!(
417 "{kind}tsconfig extends {specifier:?} from {} could not be resolved: {error}",
418 config_path.display()
419 )
420 .into(),
421 );
422 }
423 }
424 }
425 }
426
427 tracking
428 }
429}
430
431fn resolver_options(
432 options: JsResolveOptions,
433) -> (ResolveOptions, ResolveOptions, Option<PathBuf>) {
434 let JsResolveOptions {
435 tsconfig,
436 condition_names,
437 extensions,
438 } = options;
439 let configured_tsconfig = tsconfig.map(|path| absolute_path(&path));
440 let tsconfig = configured_tsconfig.clone().map(|config_file| {
441 TsconfigDiscovery::Manual(TsconfigOptions {
442 config_file,
443 references: TsconfigReferences::Auto,
444 })
445 });
446 let common_conditions: Vec<String> = condition_names
447 .into_iter()
448 .filter(|condition| condition != "import" && condition != "require")
449 .collect();
450 let import_options = ResolveOptions {
451 tsconfig: tsconfig.clone(),
452 condition_names: family_conditions("import", &common_conditions),
453 extensions: extensions.clone(),
454 ..ResolveOptions::default()
455 };
456 let require_options = ResolveOptions {
457 tsconfig,
458 condition_names: family_conditions("require", &common_conditions),
459 extensions,
460 ..ResolveOptions::default()
461 };
462 (import_options, require_options, configured_tsconfig)
463}
464
465fn family_conditions(family: &str, common: &[String]) -> Vec<String> {
466 std::iter::once(family.to_owned())
467 .chain(common.iter().cloned())
468 .collect()
469}
470
471fn tsconfig_extends_options() -> ResolveOptions {
472 ResolveOptions {
473 tsconfig: None,
474 condition_names: vec!["node".into(), "import".into()],
475 extensions: vec![".json".into()],
476 main_files: vec!["tsconfig".into()],
477 ..ResolveOptions::default()
478 }
479}
480
481fn collect_dependencies(context: ResolveContext, additional: Vec<PathBuf>) -> Vec<CompactString> {
482 let mut dependencies: Vec<CompactString> = context
483 .file_dependencies
484 .into_iter()
485 .chain(context.missing_dependencies)
486 .chain(additional)
487 .map(|path| absolute_path(&path))
488 .map(|path| path_string(&path))
489 .collect();
490 dependencies.sort_unstable();
491 dependencies.dedup();
492 dependencies
493}
494
495fn extends_specifiers(value: &Value) -> Result<Vec<String>, &'static str> {
496 match value.get("extends") {
497 None => Ok(Vec::new()),
498 Some(Value::String(specifier)) => Ok(vec![specifier.clone()]),
499 Some(Value::Array(specifiers)) => specifiers
500 .iter()
501 .map(|specifier| {
502 specifier
503 .as_str()
504 .map(str::to_owned)
505 .ok_or("extends array entries must be strings")
506 })
507 .collect(),
508 Some(_) => Err("extends must be a string or an array of strings"),
509 }
510}
511
512fn is_package_style_extends(specifier: &str) -> bool {
513 !Path::new(specifier).is_absolute() && !specifier.starts_with('.')
514}
515
516fn extends_target_path(directory: &Path, specifier: &str) -> PathBuf {
517 let target = Path::new(specifier);
518 if target.is_absolute() {
519 target.to_path_buf()
520 } else {
521 normalize_path(&absolute_path(&directory.join(target)))
522 }
523}
524
525fn normalize_path(path: &Path) -> PathBuf {
526 let mut normalized = PathBuf::new();
527 for component in path.components() {
528 match component {
529 Component::CurDir => {}
530 Component::ParentDir => {
531 normalized.pop();
532 }
533 Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
534 normalized.push(component.as_os_str());
535 }
536 }
537 }
538 normalized
539}
540
541fn classify_error(error: ResolveError) -> UnresolvedReason {
542 if is_not_found_error(&error) {
543 UnresolvedReason::NotFound
544 } else {
545 let kind = match &error {
546 ResolveError::TsconfigNotFound(_)
547 | ResolveError::TsconfigSelfReference(_)
548 | ResolveError::TsconfigCircularExtend(_)
549 | ResolveError::TsconfigLoadFailed { .. }
550 | ResolveError::Json(_)
551 | ResolveError::InvalidPackageTarget(_, _, _)
552 | ResolveError::InvalidPackageConfig(_)
553 | ResolveError::InvalidPackageConfigDefault(_)
554 | ResolveError::InvalidPackageConfigDirectory(_) => FailedKind::Config,
555 ResolveError::IOError(_) => FailedKind::Io,
556 ResolveError::PathNotSupported(_)
557 | ResolveError::Specifier(_)
558 | ResolveError::InvalidModuleSpecifier(_, _) => FailedKind::InvalidSpecifier,
559 _ => FailedKind::Other,
560 };
561 failed(kind, error.to_string())
562 }
563}
564
565fn is_not_found_error(error: &ResolveError) -> bool {
566 matches!(
567 error,
568 ResolveError::NotFound(_)
569 | ResolveError::MatchedAliasNotFound(_, _)
570 | ResolveError::ExtensionAlias(_, _, _)
571 )
572}
573
574fn error_dependency_paths(error: &ResolveError) -> Vec<PathBuf> {
575 match error {
576 ResolveError::TsconfigLoadFailed { path, source } => {
577 let mut paths = vec![path.clone()];
578 paths.extend(error_dependency_paths(source));
579 paths
580 }
581 ResolveError::TsconfigCircularExtend(paths) => paths.paths().to_vec(),
582 ResolveError::Json(error) => vec![error.path.clone()],
583 ResolveError::InvalidModuleSpecifier(_, path)
584 | ResolveError::InvalidPackageTarget(_, _, path)
585 | ResolveError::InvalidPackageConfig(path)
586 | ResolveError::InvalidPackageConfigDefault(path)
587 | ResolveError::InvalidPackageConfigDirectory(path)
588 | ResolveError::PackageImportNotDefined(_, path) => vec![path.clone()],
589 ResolveError::PackagePathNotExported {
590 package_json_path, ..
591 } => vec![package_json_path.clone()],
592 _ => Vec::new(),
593 }
594}
595
596fn absolute_path(path: &Path) -> PathBuf {
597 if path.is_absolute() {
598 path.to_path_buf()
599 } else {
600 env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
601 }
602}
603
604fn path_string(path: &Path) -> CompactString {
605 CompactString::from(path.to_string_lossy().as_ref())
606}
607
608fn is_node_modules_path(path: &Path) -> bool {
609 path.components()
610 .any(|component| matches!(component, Component::Normal(name) if name == "node_modules"))
611}
612
613fn is_path_specifier(specifier: &str) -> bool {
614 specifier.starts_with("./")
615 || specifier.starts_with("../")
616 || Path::new(specifier).is_absolute()
617}
618
619fn classify_resolution(specifier: &str, path: &Path, manifest_name: Option<&str>) -> Resolved {
620 let path = absolute_path(path);
621 if is_path_specifier(specifier) || !is_node_modules_path(&path) {
622 Resolved::Path(path_string(&path))
623 } else {
624 let name = manifest_name
628 .map(CompactString::from)
629 .or_else(|| package_name_from_path(&path))
630 .unwrap_or_else(|| package_name(specifier));
631 Resolved::External(name)
632 }
633}
634
635fn package_name_from_path(path: &Path) -> Option<CompactString> {
636 let components: Vec<&str> = path
637 .components()
638 .filter_map(|component| match component {
639 Component::Normal(name) => name.to_str(),
640 _ => None,
641 })
642 .collect();
643 let base = components
644 .iter()
645 .rposition(|name| *name == "node_modules")?;
646 let first = components.get(base + 1)?;
647 if first.starts_with('@') {
648 let second = components.get(base + 2)?;
649 Some(CompactString::from(format!("{first}/{second}")))
650 } else {
651 Some(CompactString::from(*first))
652 }
653}
654
655fn package_name(specifier: &str) -> CompactString {
656 let segment_count = usize::from(specifier.starts_with('@')) + 1;
657 CompactString::from(
658 specifier
659 .split('/')
660 .take(segment_count)
661 .collect::<Vec<_>>()
662 .join("/"),
663 )
664}
665
666fn unresolved(
667 reason: UnresolvedReason,
668 dependencies: Vec<CompactString>,
669 notes: Vec<CompactString>,
670) -> ResolutionOutcome {
671 let completeness = if matches!(&reason, UnresolvedReason::Failed { .. }) {
672 ResolutionCompleteness::Partial
673 } else {
674 ResolutionCompleteness::Complete
675 };
676 ResolutionOutcome {
677 resolved: Resolved::Unresolved(reason),
678 dependencies,
679 notes,
680 completeness,
681 }
682}
683
684fn failed(kind: FailedKind, detail: impl Into<CompactString>) -> UnresolvedReason {
685 UnresolvedReason::Failed {
686 kind,
687 detail: detail.into(),
688 }
689}
690
691fn normalize_dependencies(dependencies: &mut Vec<CompactString>) {
692 dependencies.sort_unstable();
693 dependencies.dedup();
694}
695
696#[derive(Default)]
697struct TsconfigTracking {
698 dependencies: Vec<PathBuf>,
699 notes: Vec<CompactString>,
700 truncated: bool,
701}
702
703#[derive(Clone)]
704struct SharedFileSystem(Arc<dyn FileSystem>);
705
706impl SharedFileSystem {
707 fn from_file_system(file_system: impl FileSystem + 'static) -> Self {
708 Self(Arc::new(file_system))
709 }
710}
711
712impl FileSystem for SharedFileSystem {
713 fn new() -> Self {
714 Self::from_file_system(FileSystemOs::new())
715 }
716
717 fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
718 self.0.read(path)
719 }
720
721 fn read_to_string(&self, path: &Path) -> io::Result<String> {
722 self.0.read_to_string(path)
723 }
724
725 fn metadata(&self, path: &Path) -> io::Result<oxc_resolver::FileMetadata> {
726 self.0.metadata(path)
727 }
728
729 fn symlink_metadata(&self, path: &Path) -> io::Result<oxc_resolver::FileMetadata> {
730 self.0.symlink_metadata(path)
731 }
732
733 fn read_link(&self, path: &Path) -> Result<PathBuf, ResolveError> {
734 self.0.read_link(path)
735 }
736
737 fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
738 self.0.canonicalize(path)
739 }
740}
741
742#[cfg(debug_assertions)]
743struct InFlightResolve<'a> {
744 counter: &'a AtomicU32,
745}
746
747#[cfg(debug_assertions)]
748impl<'a> InFlightResolve<'a> {
749 fn enter(counter: &'a AtomicU32) -> Self {
750 let previous = counter.fetch_add(1, Ordering::AcqRel);
751 debug_assert_ne!(previous, u32::MAX, "in-flight resolve counter overflowed");
752 Self { counter }
753 }
754}
755
756#[cfg(debug_assertions)]
757impl Drop for InFlightResolve<'_> {
758 fn drop(&mut self) {
759 let previous = self.counter.fetch_sub(1, Ordering::AcqRel);
760 debug_assert!(previous > 0, "in-flight resolve counter underflowed");
761 }
762}