Skip to main content

hearth_graph/resolve/
js.rs

1//! JavaScript and TypeScript resolution through `oxc_resolver`.
2
3#[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/// Configuration for JavaScript and TypeScript module resolution.
30#[derive(Debug, Clone)]
31pub struct JsResolveOptions {
32    /// An optional manually selected tsconfig-format file, such as
33    /// `tsconfig.json` or `jsconfig.json`.
34    pub tsconfig: Option<PathBuf>,
35    /// Conditions accepted while resolving package `exports`.
36    pub condition_names: Vec<String>,
37    /// File extensions probed in priority order.
38    pub extensions: Vec<String>,
39}
40
41impl Default for JsResolveOptions {
42    fn default() -> Self {
43        Self {
44            tsconfig: None,
45            // These family conditions are split between the import and require
46            // resolvers by `resolver_options`.
47            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
62/// Build a JavaScript resolver backed by the operating system filesystem.
63pub fn js_resolver(options: JsResolveOptions) -> Box<dyn Resolve> {
64    build_js_resolver(FileSystemOs::new(), options)
65}
66
67/// Build a JavaScript resolver backed by an injected filesystem.
68pub 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        // Aliased specifiers (`#dep`) say nothing about the installed package,
625        // so a missing manifest name falls back to the directory name under
626        // the last node_modules component before the specifier text.
627        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}