Skip to main content

oxc_resolver/
tsconfig.rs

1use std::{
2    borrow::Cow,
3    fmt::Debug,
4    hash::BuildHasherDefault,
5    path::{Path, PathBuf},
6    sync::Arc,
7};
8
9use compact_str::CompactString;
10use indexmap::IndexMap;
11use rustc_hash::FxHasher;
12use serde::Deserialize;
13
14use crate::{TsconfigReferences, path::PathUtil, replace_bom_with_whitespace};
15
16/// Template variable `${configDir}` for substitution of config files
17/// directory path.
18///
19/// NOTE: All tests cases are just a head replacement of `${configDir}`, so
20///       we are constrained as such.
21///
22/// See <https://github.com/microsoft/TypeScript/pull/58042>.
23/// Allow list: <https://github.com/microsoft/TypeScript/issues/57485#issuecomment-2027787456>
24const TEMPLATE_VARIABLE: &str = "${configDir}";
25
26pub type CompilerOptionsPathsMap = IndexMap<String, Vec<PathBuf>, BuildHasherDefault<FxHasher>>;
27
28/// Project Reference
29///
30/// <https://www.typescriptlang.org/docs/handbook/project-references.html>
31#[derive(Clone, Debug, Deserialize)]
32pub struct ProjectReference {
33    pub path: PathBuf,
34}
35
36#[derive(Clone, Debug, Default, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct TsConfig {
39    /// Whether this is the caller tsconfig.
40    /// `false` for configs loaded through `extends`.
41    #[serde(skip)]
42    pub root: bool,
43
44    /// Whether `build()` should normalize paths.
45    /// Set to true when caching to ensure paths are always normalized regardless of `root`.
46    #[serde(skip)]
47    should_build: bool,
48
49    /// Path to `tsconfig.json`. Contains the `tsconfig.json` filename.
50    #[serde(skip)]
51    pub path: PathBuf,
52
53    #[serde(default)]
54    pub files: Option<Vec<PathBuf>>,
55
56    #[serde(default)]
57    pub include: Option<Vec<PathBuf>>,
58
59    #[serde(default)]
60    pub exclude: Option<Vec<PathBuf>>,
61
62    #[serde(default)]
63    pub extends: Option<ExtendsField>,
64
65    #[serde(default)]
66    pub compiler_options: CompilerOptions,
67
68    #[serde(default)]
69    pub references: Vec<ProjectReference>,
70
71    /// Resolved project references.
72    ///
73    /// Corresponds to each item in [TsConfig::references].
74    #[serde(skip)]
75    pub references_resolved: Vec<Arc<Self>>,
76}
77
78impl TsConfig {
79    /// Parses the tsconfig from a JSON string.
80    ///
81    /// `path` is the requested path (used as identity for cache lookup and error
82    /// reporting). `canonical_path` anchors `baseUrl` / `paths` at the real
83    /// tsconfig directory when `extends` traverses a symlink. Callers that don't
84    /// care about symlinks may pass the same value for both.
85    ///
86    /// # Errors
87    ///
88    /// * Any error that can be returned by `serde_json::from_str()`.
89    ///
90    /// # Panics
91    ///
92    /// * When `canonical_path` has no parent directory.
93    pub fn parse(
94        root: bool,
95        path: &Path,
96        canonical_path: &Path,
97        json: String,
98    ) -> Result<Self, serde_json::Error> {
99        let mut json = json.into_bytes();
100        replace_bom_with_whitespace(&mut json);
101        _ = json_strip_comments::strip_slice(&mut json);
102        let mut tsconfig: Self = if json.iter().all(u8::is_ascii_whitespace) {
103            Self::default()
104        } else {
105            serde_json::from_slice(&json)?
106        };
107        tsconfig.root = root;
108        tsconfig.path = path.to_path_buf();
109        let canonical_directory = canonical_path.parent().unwrap();
110        tsconfig.compiler_options.paths_base =
111            tsconfig.compiler_options.base_url.as_ref().map_or_else(
112                || canonical_directory.to_path_buf(),
113                |base_url| {
114                    if base_url.to_string_lossy().starts_with(TEMPLATE_VARIABLE) {
115                        base_url.clone()
116                    } else {
117                        canonical_directory.normalize_with(base_url)
118                    }
119                },
120            );
121        if let Some(root_dirs) = &mut tsconfig.compiler_options.root_dirs {
122            for root_dir in root_dirs.iter_mut() {
123                if !root_dir.to_string_lossy().starts_with(TEMPLATE_VARIABLE) {
124                    *root_dir = canonical_directory.normalize_with(&root_dir);
125                }
126            }
127        }
128        if let Some(out_dir) = &mut tsconfig.compiler_options.out_dir
129            && !out_dir.to_string_lossy().starts_with(TEMPLATE_VARIABLE)
130        {
131            *out_dir = canonical_directory.normalize_with(&out_dir);
132        }
133        if let Some(declaration_dir) = &mut tsconfig.compiler_options.declaration_dir
134            && !declaration_dir.to_string_lossy().starts_with(TEMPLATE_VARIABLE)
135        {
136            *declaration_dir = canonical_directory.normalize_with(&declaration_dir);
137        }
138        Ok(tsconfig)
139    }
140
141    /// Whether this is the caller tsconfig.
142    /// `false` for configs loaded through `extends`.
143    #[must_use]
144    pub fn root(&self) -> bool {
145        self.root
146    }
147
148    /// Whether `build()` should normalize paths.
149    #[must_use]
150    pub fn should_build(&self) -> bool {
151        self.should_build
152    }
153
154    /// Set whether `build()` should normalize paths.
155    pub fn set_should_build(&mut self, should_build: bool) {
156        self.should_build = should_build;
157    }
158
159    /// Returns the path where the `tsconfig.json` was found.
160    ///
161    /// Contains the `tsconfig.json` filename.
162    #[must_use]
163    pub fn path(&self) -> &Path {
164        &self.path
165    }
166
167    /// Directory to `tsconfig.json`.
168    ///
169    /// # Panics
170    ///
171    /// * When the `tsconfig.json` path is misconfigured.
172    #[must_use]
173    pub fn directory(&self) -> &Path {
174        debug_assert!(self.path.file_name().is_some());
175        self.path.parent().unwrap()
176    }
177
178    /// Returns any paths to tsconfigs that should be extended by this tsconfig.
179    pub(crate) fn extends(&self) -> impl Iterator<Item = &str> {
180        let specifiers = match &self.extends {
181            Some(ExtendsField::Single(specifier)) => {
182                vec![specifier.as_str()]
183            }
184            Some(ExtendsField::Multiple(specifiers)) => {
185                specifiers.iter().map(String::as_str).collect()
186            }
187            None => Vec::new(),
188        };
189        specifiers.into_iter()
190    }
191
192    /// Loads the given references into this tsconfig.
193    ///
194    /// Returns whether any references are defined in the tsconfig.
195    pub(crate) fn load_references(&mut self, references: TsconfigReferences) -> bool {
196        match references {
197            TsconfigReferences::Disabled => {
198                self.references.clear();
199            }
200            TsconfigReferences::Auto => {}
201        }
202        !self.references.is_empty()
203    }
204
205    /// Inherits settings from the given tsconfig into `self`.
206    #[expect(clippy::too_many_lines, reason = "field-by-field merge of every inherited setting")]
207    pub(crate) fn extend_tsconfig(&mut self, tsconfig: &Self) {
208        if self.files.is_none()
209            && let Some(files) = &tsconfig.files
210        {
211            self.files = Some(files.clone());
212        }
213
214        if self.include.is_none()
215            && let Some(include) = &tsconfig.include
216        {
217            self.include = Some(include.clone());
218        }
219
220        if self.exclude.is_none()
221            && let Some(exclude) = &tsconfig.exclude
222        {
223            self.exclude = Some(exclude.clone());
224        }
225
226        let compiler_options = &mut self.compiler_options;
227
228        if compiler_options.base_url.is_none() {
229            compiler_options.base_url.clone_from(&tsconfig.compiler_options.base_url);
230            if tsconfig.compiler_options.base_url.is_some() {
231                compiler_options.paths_base.clone_from(&tsconfig.compiler_options.paths_base);
232            }
233        }
234        if compiler_options.paths.is_none() {
235            if compiler_options.base_url.is_none() && tsconfig.compiler_options.base_url.is_none() {
236                compiler_options.paths_base.clone_from(&tsconfig.compiler_options.paths_base);
237            }
238            compiler_options.paths.clone_from(&tsconfig.compiler_options.paths);
239        }
240
241        if compiler_options.experimental_decorators.is_none()
242            && let Some(experimental_decorators) =
243                &tsconfig.compiler_options.experimental_decorators
244        {
245            compiler_options.experimental_decorators = Some(*experimental_decorators);
246        }
247
248        if compiler_options.emit_decorator_metadata.is_none()
249            && let Some(emit_decorator_metadata) =
250                &tsconfig.compiler_options.emit_decorator_metadata
251        {
252            compiler_options.emit_decorator_metadata = Some(*emit_decorator_metadata);
253        }
254
255        if compiler_options.strict.is_none()
256            && let Some(strict) = &tsconfig.compiler_options.strict
257        {
258            compiler_options.strict = Some(*strict);
259        }
260
261        if compiler_options.strict_null_checks.is_none()
262            && let Some(strict_null_checks) = &tsconfig.compiler_options.strict_null_checks
263        {
264            compiler_options.strict_null_checks = Some(*strict_null_checks);
265        }
266
267        if compiler_options.use_define_for_class_fields.is_none()
268            && let Some(use_define_for_class_fields) =
269                &tsconfig.compiler_options.use_define_for_class_fields
270        {
271            compiler_options.use_define_for_class_fields = Some(*use_define_for_class_fields);
272        }
273
274        if compiler_options.rewrite_relative_import_extensions.is_none()
275            && let Some(rewrite_relative_import_extensions) =
276                &tsconfig.compiler_options.rewrite_relative_import_extensions
277        {
278            compiler_options.rewrite_relative_import_extensions =
279                Some(*rewrite_relative_import_extensions);
280        }
281
282        if compiler_options.jsx.is_none()
283            && let Some(jsx) = &tsconfig.compiler_options.jsx
284        {
285            compiler_options.jsx = Some(jsx.clone());
286        }
287
288        if compiler_options.jsx_factory.is_none()
289            && let Some(jsx_factory) = &tsconfig.compiler_options.jsx_factory
290        {
291            compiler_options.jsx_factory = Some(jsx_factory.clone());
292        }
293
294        if compiler_options.jsx_fragment_factory.is_none()
295            && let Some(jsx_fragment_factory) = &tsconfig.compiler_options.jsx_fragment_factory
296        {
297            compiler_options.jsx_fragment_factory = Some(jsx_fragment_factory.clone());
298        }
299
300        if compiler_options.jsx_import_source.is_none()
301            && let Some(jsx_import_source) = &tsconfig.compiler_options.jsx_import_source
302        {
303            compiler_options.jsx_import_source = Some(jsx_import_source.clone());
304        }
305
306        if compiler_options.verbatim_module_syntax.is_none()
307            && let Some(verbatim_module_syntax) = &tsconfig.compiler_options.verbatim_module_syntax
308        {
309            compiler_options.verbatim_module_syntax = Some(*verbatim_module_syntax);
310        }
311
312        if compiler_options.preserve_value_imports.is_none()
313            && let Some(preserve_value_imports) = &tsconfig.compiler_options.preserve_value_imports
314        {
315            compiler_options.preserve_value_imports = Some(*preserve_value_imports);
316        }
317
318        if compiler_options.imports_not_used_as_values.is_none()
319            && let Some(imports_not_used_as_values) =
320                &tsconfig.compiler_options.imports_not_used_as_values
321        {
322            compiler_options.imports_not_used_as_values = Some(imports_not_used_as_values.clone());
323        }
324
325        if compiler_options.target.is_none()
326            && let Some(target) = &tsconfig.compiler_options.target
327        {
328            compiler_options.target = Some(target.clone());
329        }
330
331        if compiler_options.module.is_none()
332            && let Some(module) = &tsconfig.compiler_options.module
333        {
334            compiler_options.module = Some(module.clone());
335        }
336
337        if compiler_options.allow_js.is_none()
338            && let Some(allow_js) = &tsconfig.compiler_options.allow_js
339        {
340            compiler_options.allow_js = Some(*allow_js);
341        }
342
343        if compiler_options.root_dirs.is_none()
344            && let Some(root_dirs) = &tsconfig.compiler_options.root_dirs
345        {
346            compiler_options.root_dirs = Some(root_dirs.clone());
347        }
348
349        if compiler_options.out_dir.is_none()
350            && let Some(out_dir) = &tsconfig.compiler_options.out_dir
351        {
352            compiler_options.out_dir = Some(out_dir.clone());
353        }
354
355        if compiler_options.declaration_dir.is_none()
356            && let Some(declaration_dir) = &tsconfig.compiler_options.declaration_dir
357        {
358            compiler_options.declaration_dir = Some(declaration_dir.clone());
359        }
360
361        if compiler_options.resolve_json_module.is_none()
362            && let Some(resolve_json_module) = &tsconfig.compiler_options.resolve_json_module
363        {
364            compiler_options.resolve_json_module = Some(*resolve_json_module);
365        }
366
367        if compiler_options.check_js.is_none()
368            && let Some(check_js) = &tsconfig.compiler_options.check_js
369        {
370            compiler_options.check_js = Some(*check_js);
371        }
372    }
373
374    /// "Build" the root tsconfig, resolve:
375    ///
376    /// * `{configDir}` template variable
377    /// * `paths_base` for resolving paths alias
378    /// * `baseUrl` to absolute path
379    #[must_use]
380    pub(crate) fn build(mut self) -> Self {
381        // Only build if should_build is true.
382        // This is controlled separately from `root` to avoid cache pollution.
383        if !self.should_build {
384            return self;
385        }
386
387        let config_dir = self.directory().to_path_buf();
388
389        // Substitute template variable in `tsconfig.files`.
390        if let Some(files) = self.files.take() {
391            self.files = Some(files.into_iter().map(|p| self.adjust_path(p)).collect());
392        }
393
394        // Substitute template variable in `tsconfig.include`.
395        if let Some(includes) = self.include.take() {
396            self.include = Some(includes.into_iter().map(|p| self.adjust_path(p)).collect());
397        }
398
399        // Substitute template variable in `tsconfig.exclude`.
400        if let Some(excludes) = self.exclude.take() {
401            self.exclude = Some(excludes.into_iter().map(|p| self.adjust_path(p)).collect());
402        }
403
404        if let Some(base_url) = &self.compiler_options.base_url {
405            self.compiler_options.base_url = Some(self.adjust_path(base_url.clone()));
406        }
407
408        if let Some(stripped_path) =
409            self.compiler_options.paths_base.to_string_lossy().strip_prefix(TEMPLATE_VARIABLE)
410        {
411            self.compiler_options.paths_base =
412                config_dir.join(stripped_path.trim_start_matches('/'));
413        }
414
415        if let Some(root_dirs) = &mut self.compiler_options.root_dirs {
416            for root_dir in root_dirs.iter_mut() {
417                if let Some(stripped_path) =
418                    root_dir.to_string_lossy().strip_prefix(TEMPLATE_VARIABLE)
419                {
420                    *root_dir = config_dir.join(stripped_path.trim_start_matches('/'));
421                }
422            }
423        }
424
425        if let Some(out_dir) = &mut self.compiler_options.out_dir
426            && let Some(stripped_path) = out_dir.to_string_lossy().strip_prefix(TEMPLATE_VARIABLE)
427        {
428            *out_dir = config_dir.join(stripped_path.trim_start_matches('/'));
429        }
430        if let Some(declaration_dir) = &mut self.compiler_options.declaration_dir
431            && let Some(stripped_path) =
432                declaration_dir.to_string_lossy().strip_prefix(TEMPLATE_VARIABLE)
433        {
434            *declaration_dir = config_dir.join(stripped_path.trim_start_matches('/'));
435        }
436
437        if let Some(paths_map) = &mut self.compiler_options.paths {
438            // Substitute template variable in `tsconfig.compilerOptions.paths`.
439            for paths in paths_map.values_mut() {
440                for path in paths {
441                    *path = if let Some(stripped_path) =
442                        path.to_string_lossy().strip_prefix(TEMPLATE_VARIABLE)
443                    {
444                        config_dir.join(stripped_path.trim_start_matches('/'))
445                    } else {
446                        self.compiler_options.paths_base.normalize_with(&path)
447                    };
448                }
449            }
450            self.compiler_options.compiled_paths =
451                Some(Arc::new(CompiledTsconfigPaths::new(paths_map)));
452        } else {
453            self.compiler_options.compiled_paths = None;
454        }
455
456        self
457    }
458
459    #[expect(clippy::option_if_let_else, reason = "the if/else reads clearer than `map_or_else`")]
460    fn adjust_path(&self, path: PathBuf) -> PathBuf {
461        if let Some(stripped) = path.to_string_lossy().strip_prefix(TEMPLATE_VARIABLE) {
462            self.directory().join(stripped.trim_start_matches('/'))
463        } else {
464            self.directory().normalize_with(path)
465        }
466    }
467
468    /// Resolves the given `specifier` within project references and then [CompilerOptions::paths].
469    ///
470    /// `specifier` can be either a real path or an alias.
471    #[must_use]
472    pub(crate) fn resolve_references_then_self_paths(
473        &self,
474        path: &Path,
475        specifier: &str,
476    ) -> Vec<PathBuf> {
477        for tsconfig in &self.references_resolved {
478            if path.starts_with(&tsconfig.compiler_options.paths_base) {
479                return tsconfig.resolve_path_alias(specifier);
480            }
481        }
482        self.resolve_path_alias(specifier)
483    }
484
485    /// Resolves the given `specifier` within the project configured by this
486    /// tsconfig.
487    ///
488    /// `specifier` is expected to be a path alias.
489    // Copied from parcel
490    // <https://github.com/parcel-bundler/parcel/blob/b6224fd519f95e68d8b93ba90376fd94c8b76e69/packages/utils/node-resolver-rs/src/tsconfig.rs#L93>
491    #[must_use]
492    pub(crate) fn resolve_path_alias(&self, specifier: &str) -> Vec<PathBuf> {
493        if specifier.starts_with('.') {
494            return Vec::new();
495        }
496
497        let compiler_options = &self.compiler_options;
498
499        let Some(paths_map) = &compiler_options.paths else {
500            return vec![];
501        };
502
503        if let Some(paths) = paths_map.get(specifier) {
504            return paths.clone();
505        }
506
507        if let Some(compiled_paths) = &compiler_options.compiled_paths
508            && let Some(paths) = compiled_paths.resolve(specifier)
509        {
510            return paths;
511        }
512
513        Vec::new()
514    }
515
516    pub(crate) fn resolve_base_url(&self, specifier: &str) -> Option<PathBuf> {
517        self.compiler_options
518            .base_url
519            .is_some()
520            .then(|| self.compiler_options.paths_base.normalize_with(specifier))
521    }
522
523    /// Maps a non-relative module `specifier` through this tsconfig's `compilerOptions.paths`
524    /// and `baseUrl` configuration, returning the mapped absolute path candidates without
525    /// checking whether they exist on disk.
526    ///
527    /// Unlike normal resolution (e.g. [`crate::ResolverImpl::resolve_file`]), which discards
528    /// a `paths`/`baseUrl` mapping when the mapped path does not point at a real file, this
529    /// keeps the mapping. It is intended for callers that need the alias mapping for a specifier
530    /// that is not expected to resolve to a real file, such as glob patterns used by
531    /// `import.meta.glob` (e.g. `@/foo/**/*`).
532    ///
533    /// Discover the tsconfig for an importing file with [`crate::ResolverImpl::find_tsconfig`],
534    /// which resolves project references so the returned config is the one that owns the file.
535    #[must_use]
536    pub fn resolve_path_alias_or_base_url(&self, specifier: &str) -> Vec<PathBuf> {
537        let mut paths = self.resolve_path_alias(specifier);
538        if paths.is_empty()
539            && let Some(base_url_path) = self.resolve_base_url(specifier)
540        {
541            paths.push(base_url_path);
542        }
543        paths
544    }
545}
546
547/// Compiler Options
548///
549/// <https://www.typescriptlang.org/tsconfig#compilerOptions>
550#[derive(Clone, Debug, Default, Deserialize)]
551#[serde(rename_all = "camelCase")]
552pub struct CompilerOptions {
553    pub base_url: Option<PathBuf>,
554
555    /// Path aliases.
556    pub paths: Option<CompilerOptionsPathsMap>,
557
558    /// Pre-compiled wildcard path aliases for faster runtime matching.
559    #[serde(skip)]
560    compiled_paths: Option<Arc<CompiledTsconfigPaths>>,
561
562    /// The "base_url" at which this tsconfig is defined.
563    #[serde(skip)]
564    pub(crate) paths_base: PathBuf,
565
566    /// <https://www.typescriptlang.org/tsconfig/#experimentalDecorators>
567    pub experimental_decorators: Option<bool>,
568
569    /// <https://www.typescriptlang.org/tsconfig/#emitDecoratorMetadata>
570    pub emit_decorator_metadata: Option<bool>,
571
572    /// <https://www.typescriptlang.org/tsconfig/#strict>
573    pub strict: Option<bool>,
574
575    /// <https://www.typescriptlang.org/tsconfig/#strictNullChecks>
576    pub strict_null_checks: Option<bool>,
577
578    /// <https://www.typescriptlang.org/tsconfig/#useDefineForClassFields>
579    pub use_define_for_class_fields: Option<bool>,
580
581    /// <https://www.typescriptlang.org/tsconfig/#rewriteRelativeImportExtensions>
582    pub rewrite_relative_import_extensions: Option<bool>,
583
584    /// <https://www.typescriptlang.org/tsconfig/#jsx>
585    pub jsx: Option<String>,
586
587    /// <https://www.typescriptlang.org/tsconfig/#jsxFactory>
588    pub jsx_factory: Option<String>,
589
590    /// <https://www.typescriptlang.org/tsconfig/#jsxFragmentFactory>
591    pub jsx_fragment_factory: Option<String>,
592
593    /// <https://www.typescriptlang.org/tsconfig/#jsxImportSource>
594    pub jsx_import_source: Option<String>,
595
596    /// <https://www.typescriptlang.org/tsconfig/#verbatimModuleSyntax>
597    pub verbatim_module_syntax: Option<bool>,
598
599    /// <https://www.typescriptlang.org/tsconfig/#preserveValueImports>
600    pub preserve_value_imports: Option<bool>,
601
602    /// <https://www.typescriptlang.org/tsconfig/#importsNotUsedAsValues>
603    pub imports_not_used_as_values: Option<String>,
604
605    /// <https://www.typescriptlang.org/tsconfig/#target>
606    pub target: Option<String>,
607
608    /// <https://www.typescriptlang.org/tsconfig/#module>
609    pub module: Option<String>,
610
611    /// <https://www.typescriptlang.org/tsconfig/#allowJs>
612    pub allow_js: Option<bool>,
613
614    /// <https://www.typescriptlang.org/tsconfig/#rootDirs>
615    pub root_dirs: Option<Vec<PathBuf>>,
616
617    /// <https://www.typescriptlang.org/tsconfig/#outDir>
618    pub out_dir: Option<PathBuf>,
619
620    /// <https://www.typescriptlang.org/tsconfig/#declarationDir>
621    pub declaration_dir: Option<PathBuf>,
622
623    /// <https://www.typescriptlang.org/tsconfig/#resolveJsonModule>
624    pub resolve_json_module: Option<bool>,
625
626    /// <https://www.typescriptlang.org/tsconfig/#checkJs>
627    pub check_js: Option<bool>,
628}
629
630/// Value for the "extends" field.
631///
632/// <https://www.typescriptlang.org/tsconfig/#extends>
633#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
634#[serde(untagged)]
635pub enum ExtendsField {
636    Single(String),
637    Multiple(Vec<String>),
638}
639
640#[derive(Clone, Copy)]
641enum GlobPattern<'a> {
642    Pattern(&'a [PathBuf]),
643    All,
644}
645
646/// Normalize Windows `\` to `/` for glob matching, without allocating when there's nothing to replace.
647fn to_forward_slashes(s: Cow<'_, str>) -> Cow<'_, str> {
648    if s.contains('\\') { Cow::Owned(s.replace('\\', "/")) } else { s }
649}
650
651/// Tsconfig resolver
652impl TsConfig {
653    pub(crate) fn resolve_tsconfig_solution(tsconfig: Arc<Self>, path: &Path) -> Arc<Self> {
654        if !tsconfig.references_resolved.is_empty()
655            && let Some(solution_tsconfig) = tsconfig
656                .references_resolved
657                .iter()
658                .find(|referenced| referenced.is_file_included_in_tsconfig(path))
659                .map(Arc::clone)
660        {
661            return solution_tsconfig;
662        }
663        tsconfig
664    }
665
666    /// Whether this tsconfig (directly or via a referenced sub-project) claims
667    /// ownership of `path`. Used by tsconfig auto-discovery to decide whether
668    /// to keep walking up to an ancestor `tsconfig.json` when the nearest one
669    /// doesn't actually cover the file via its `files` / `include` / `exclude`
670    /// or via a matching reference.
671    pub(crate) fn claims_ownership_of(&self, path: &Path) -> bool {
672        // Any matching reference claims ownership (consistent with
673        // resolve_tsconfig_solution).
674        if self.references_resolved.iter().any(|r| r.is_file_included_in_tsconfig(path)) {
675            return true;
676        }
677        // Solution-style configs (have `references` and explicit empty
678        // `files` / `include`) never claim files themselves. Per the
679        // TypeScript spec, an *omitted* `include` defaults to `**/*`, so it
680        // must fall through to `is_file_included_in_tsconfig`; only an
681        // explicit empty array means "own no files".
682        let is_solution_style = !self.references_resolved.is_empty()
683            && matches!(self.files.as_deref(), Some([]))
684            && matches!(self.include.as_deref(), Some([]));
685        if is_solution_style {
686            return false;
687        }
688        self.is_file_included_in_tsconfig(path)
689    }
690
691    fn is_file_included_in_tsconfig(&self, path: &Path) -> bool {
692        // 1. Check files array (highest priority - overrides exclude)
693        if self.files.as_ref().is_some_and(|files| files.iter().any(|file| Path::new(file) == path))
694        {
695            return true;
696        }
697        // 2. Reject non-program inputs: a `.js`-family file with `allowJs` off
698        //    (even if a glob names `.js`), or an extensionless path
699        if self.is_extensionless_or_uncompiled_js(path) {
700            return false;
701        }
702        // 3. Check include patterns
703        let is_included = self.include.as_ref().map_or_else(
704            || {
705                if self.files.is_some() {
706                    false
707                } else {
708                    self.is_glob_matches(path, GlobPattern::All)
709                }
710            },
711            |include_patterns| self.is_glob_matches(path, GlobPattern::Pattern(include_patterns)),
712        );
713        // 4. Check exclude patterns
714        if is_included {
715            return self.exclude.as_ref().is_none_or(|exclude_patterns| {
716                !self.is_glob_matches(path, GlobPattern::Pattern(exclude_patterns))
717            });
718        }
719        false
720    }
721
722    fn is_glob_matches(&self, path: &Path, pattern: GlobPattern) -> bool {
723        match pattern {
724            // The default `**/*` is scoped to the tsconfig directory; as a
725            // wildcard pattern it is restricted to the configured TS/JS
726            // extensions, same as the gate in `is_glob_match`.
727            GlobPattern::All => {
728                path.starts_with(self.directory())
729                    && self.is_file_extension_allowed_in_tsconfig(path)
730            }
731            GlobPattern::Pattern(patterns) => {
732                let path_str = to_forward_slashes(path.to_string_lossy());
733                patterns.iter().any(|pattern| {
734                    let pattern = to_forward_slashes(pattern.to_string_lossy());
735                    self.is_glob_match(pattern.as_ref(), path, path_str.as_ref())
736                })
737            }
738        }
739    }
740
741    fn is_glob_match(&self, pattern: &str, path: &Path, path_str: &str) -> bool {
742        if pattern == path_str {
743            return true;
744        }
745        // Normalize pattern: add implicit /**/* for directory patterns
746        // Find the part after the last '/' to check if it looks like a directory
747        let after_last_slash = pattern.rsplit('/').next().unwrap_or(pattern);
748        let needs_implicit_glob = !after_last_slash.contains(['.', '*', '?']);
749        let pattern = if needs_implicit_glob {
750            Cow::Owned(format!(
751                "{pattern}{}",
752                if pattern.ends_with('/') { "**/*" } else { "/**/*" }
753            ))
754        } else {
755            Cow::Borrowed(pattern)
756        };
757        // Extension gate, applied only to wildcard-terminated patterns: a pattern
758        // ending in `*` (e.g. the default `**/*`, or a bare directory like `src`
759        // that expands to `src/**/*`) matches "any file", so — mirroring
760        // TypeScript — it is restricted to the configured TS/JS extensions.
761        // A pattern naming an explicit extension (e.g. `src/**/*.vue`) does not
762        // end in `*` and is matched verbatim below; this is what lets
763        // solution-style tsconfigs claim explicitly-included non-TS files such
764        // as `.vue` / `.svelte` (e.g. the Vite Vue scaffold).
765        if pattern.ends_with('*') && !self.is_file_extension_allowed_in_tsconfig(path) {
766            return false;
767        }
768        fast_glob::glob_match(pattern.as_ref(), path_str)
769    }
770
771    fn is_file_extension_allowed_in_tsconfig(&self, path: &Path) -> bool {
772        const TS_EXTENSIONS: [&str; 4] = ["ts", "tsx", "mts", "cts"];
773        const JS_EXTENSIONS: [&str; 4] = ["js", "jsx", "mjs", "cjs"];
774        let allow_js = self.compiler_options.allow_js.is_some_and(|b| b);
775        path.extension().and_then(|ext| ext.to_str()).is_some_and(|ext| {
776            TS_EXTENSIONS.contains(&ext)
777                || if allow_js { JS_EXTENSIONS.contains(&ext) } else { false }
778        })
779    }
780
781    /// Whether `path` has no extension (typically a directory) or a `.js`-family
782    /// extension this tsconfig does not compile (`allowJs` off). Unlike
783    /// `is_file_extension_allowed_in_tsconfig`, it excludes genuine non-TS files
784    /// (e.g. `.vue`), which must instead be claimed through `include`.
785    fn is_extensionless_or_uncompiled_js(&self, path: &Path) -> bool {
786        let allow_js = self.compiler_options.allow_js.is_some_and(|b| b);
787        path.extension()
788            .and_then(|ext| ext.to_str())
789            .is_none_or(|ext| !allow_js && matches!(ext, "js" | "jsx" | "mjs" | "cjs"))
790    }
791}
792
793#[derive(Clone, Debug, Default)]
794struct CompiledTsconfigPaths {
795    wildcard_patterns: Vec<CompiledTsconfigPathPattern>,
796}
797
798#[derive(Clone, Debug)]
799struct CompiledTsconfigPathPattern {
800    prefix: CompactString,
801    suffix: CompactString,
802    prefix_len: usize,
803    suffix_len: usize,
804    targets: Vec<CompiledTsconfigPathTarget>,
805}
806
807#[derive(Clone, Debug)]
808enum CompiledTsconfigPathTarget {
809    Static(PathBuf),
810    Wildcard { prefix: CompactString, suffix: CompactString },
811}
812
813impl CompiledTsconfigPaths {
814    fn new(paths_map: &CompilerOptionsPathsMap) -> Self {
815        let mut wildcard_patterns =
816            Vec::<CompiledTsconfigPathPattern>::with_capacity(paths_map.len());
817        for (key, paths) in paths_map {
818            let Some((prefix, suffix)) = key.split_once('*') else {
819                continue;
820            };
821            let targets = paths
822                .iter()
823                .map(|path| {
824                    let path_str = path.to_string_lossy();
825                    path_str.split_once('*').map_or_else(
826                        || CompiledTsconfigPathTarget::Static(path.clone()),
827                        |(target_prefix, target_suffix)| CompiledTsconfigPathTarget::Wildcard {
828                            prefix: CompactString::new(target_prefix),
829                            suffix: CompactString::new(target_suffix),
830                        },
831                    )
832                })
833                .collect::<Vec<_>>();
834            let pattern = CompiledTsconfigPathPattern {
835                prefix: CompactString::new(prefix),
836                suffix: CompactString::new(suffix),
837                prefix_len: prefix.len(),
838                suffix_len: suffix.len(),
839                targets,
840            };
841
842            // Match longer prefixes first. Equal-length prefixes keep insertion order.
843            let index = wildcard_patterns
844                .iter()
845                .position(|existing| existing.prefix_len < pattern.prefix_len)
846                .unwrap_or(wildcard_patterns.len());
847            wildcard_patterns.insert(index, pattern);
848        }
849
850        Self { wildcard_patterns }
851    }
852
853    fn resolve(&self, specifier: &str) -> Option<Vec<PathBuf>> {
854        self.wildcard_patterns.iter().find_map(|pattern| {
855            if !specifier.starts_with(pattern.prefix.as_str())
856                || !specifier.ends_with(pattern.suffix.as_str())
857                || specifier.len() < pattern.prefix_len + pattern.suffix_len
858            {
859                return None;
860            }
861            let wildcard = &specifier[pattern.prefix_len..specifier.len() - pattern.suffix_len];
862            Some(pattern.targets.iter().map(|target| target.resolve(wildcard)).collect())
863        })
864    }
865}
866
867impl CompiledTsconfigPathTarget {
868    fn resolve(&self, wildcard: &str) -> PathBuf {
869        match self {
870            Self::Static(path) => path.clone(),
871            Self::Wildcard { prefix, suffix } => {
872                let mut resolved =
873                    String::with_capacity(prefix.len() + wildcard.len() + suffix.len());
874                resolved.push_str(prefix.as_str());
875                resolved.push_str(wildcard);
876                resolved.push_str(suffix.as_str());
877                PathBuf::from(resolved)
878            }
879        }
880    }
881}