Skip to main content

fallow_config/workspace/
tsconfig.rs

1use std::path::{Component, Path, PathBuf};
2
3use rustc_hash::FxHashSet;
4
5const MAX_EXTENDS_DEPTH: usize = 16;
6const DECLARATION_OUTPUT_SUFFIXES: &[&str] = &[".d.ts", ".d.mts", ".d.cts"];
7
8#[derive(Debug, Clone, Default)]
9struct CompilerOptions {
10    root_dir: Option<PathBuf>,
11    out_dir: Option<PathBuf>,
12    declaration_dir: Option<PathBuf>,
13    no_emit: Option<bool>,
14    emit_declaration_only: Option<bool>,
15}
16
17/// Outcome for a package entry that may point into a configured TypeScript output directory.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum TsconfigOutputResolution {
20    /// No supported config declares this entry as an output.
21    Unconfigured,
22    /// A config declares the output path, but no unique source mapping is available.
23    ConfiguredButUnresolved,
24    /// The configured output maps to one source file inside the project.
25    Resolved(PathBuf),
26}
27
28/// Static package output mappings read from a package's TypeScript configs.
29#[derive(Debug, Clone, Default)]
30pub struct TsconfigOutputMap {
31    root: Option<PathBuf>,
32    display_root: Option<PathBuf>,
33    configs: Vec<CompilerOptions>,
34}
35
36impl TsconfigOutputMap {
37    /// Read root-level tsconfig files once, resolving static relative and package `extends` paths.
38    #[must_use]
39    pub fn from_project(root: &Path) -> Self {
40        let display_root = root.to_path_buf();
41        let Some(canonical_root) = dunce::canonicalize(root).ok() else {
42            return Self::default();
43        };
44        let Some(config_paths) = tsconfig_paths(&canonical_root) else {
45            return Self::default();
46        };
47        let configs = config_paths
48            .iter()
49            .filter_map(|path| {
50                let config_dir = path.parent()?;
51                read_compiler_options(path, config_dir, &mut FxHashSet::default(), 0)
52            })
53            .collect();
54        Self {
55            root: Some(canonical_root),
56            display_root: Some(display_root),
57            configs,
58        }
59    }
60
61    /// Resolve an absolute output path, such as the target of a relative import,
62    /// to its source file.
63    ///
64    /// The path does not need to exist on disk. A path outside the project root
65    /// of this map is [`TsconfigOutputResolution::Unconfigured`].
66    #[must_use]
67    pub fn resolve_source_for_output_path(
68        &self,
69        output_path: &Path,
70        source_extensions: &[&str],
71    ) -> TsconfigOutputResolution {
72        let Some(output_path) = normalize_path(output_path) else {
73            return TsconfigOutputResolution::Unconfigured;
74        };
75        let relative = [self.display_root.as_deref(), self.root.as_deref()]
76            .into_iter()
77            .flatten()
78            .find_map(|root| output_path.strip_prefix(root).ok());
79        let Some(entry) = relative.and_then(Path::to_str) else {
80            return TsconfigOutputResolution::Unconfigured;
81        };
82        self.resolve_source_for_entry(entry, source_extensions)
83    }
84
85    /// Resolve a generated entry while preserving whether the output was configured.
86    #[must_use]
87    pub fn resolve_source_for_entry(
88        &self,
89        entry: &str,
90        source_extensions: &[&str],
91    ) -> TsconfigOutputResolution {
92        let Some(root) = self.root.as_deref() else {
93            return TsconfigOutputResolution::Unconfigured;
94        };
95        let entry_path = Path::new(entry);
96        if entry_path.is_absolute()
97            || entry_path
98                .components()
99                .any(|component| matches!(component, Component::ParentDir))
100        {
101            return TsconfigOutputResolution::Unconfigured;
102        }
103        let Some(entry_path) = normalize_path(&root.join(entry_path)) else {
104            return TsconfigOutputResolution::Unconfigured;
105        };
106        let mut candidates = FxHashSet::default();
107        let mut configured = false;
108
109        for options in &self.configs {
110            let Some(file_name) = entry_path.file_name().and_then(|name| name.to_str()) else {
111                continue;
112            };
113            let is_declaration = DECLARATION_OUTPUT_SUFFIXES
114                .iter()
115                .any(|suffix| file_name.ends_with(suffix));
116            if options.emit_declaration_only.unwrap_or(false) && !is_declaration {
117                continue;
118            }
119            let output_dir = if is_declaration {
120                options
121                    .declaration_dir
122                    .as_deref()
123                    .or(options.out_dir.as_deref())
124            } else {
125                options.out_dir.as_deref()
126            };
127            let Some(output_dir) = output_dir else {
128                continue;
129            };
130            let Some((source_stem, family_extensions)) =
131                source_suffix_for_output(&entry_path, output_dir)
132            else {
133                continue;
134            };
135            let Some(root_dir) = options.root_dir.as_deref() else {
136                continue;
137            };
138            configured = true;
139            if options.no_emit.unwrap_or(false) {
140                continue;
141            }
142            for extension in family_extensions {
143                if !source_extensions.contains(extension) {
144                    continue;
145                }
146                let Some(candidate) = append_extension(&source_stem, extension) else {
147                    continue;
148                };
149                let candidate = root_dir.join(candidate);
150                let Ok(candidate) = dunce::canonicalize(candidate) else {
151                    continue;
152                };
153                if candidate.starts_with(root) && candidate.is_file() {
154                    candidates.insert(candidate);
155                }
156            }
157        }
158
159        if candidates.len() != 1 {
160            return if configured {
161                TsconfigOutputResolution::ConfiguredButUnresolved
162            } else {
163                TsconfigOutputResolution::Unconfigured
164            };
165        }
166        let Some(source) = candidates.into_iter().next() else {
167            return TsconfigOutputResolution::ConfiguredButUnresolved;
168        };
169        let Some(relative) = source.strip_prefix(root).ok() else {
170            return TsconfigOutputResolution::ConfiguredButUnresolved;
171        };
172        let Some(display_root) = self.display_root.as_deref() else {
173            return TsconfigOutputResolution::ConfiguredButUnresolved;
174        };
175        TsconfigOutputResolution::Resolved(display_root.join(relative))
176    }
177}
178
179fn tsconfig_paths(root: &Path) -> Option<Vec<PathBuf>> {
180    let mut configs = std::fs::read_dir(root)
181        .ok()?
182        .flatten()
183        .map(|entry| entry.path())
184        .filter(|path| {
185            path.is_file()
186                && path
187                    .file_name()
188                    .and_then(|name| name.to_str())
189                    .is_some_and(|name| name.starts_with("tsconfig"))
190                && path.extension().is_some_and(|extension| {
191                    extension.eq_ignore_ascii_case("json")
192                        || extension.eq_ignore_ascii_case("jsonc")
193                })
194        })
195        .collect::<Vec<_>>();
196    configs.sort();
197    Some(configs)
198}
199
200fn read_compiler_options(
201    config_path: &Path,
202    substitution_base: &Path,
203    visited: &mut FxHashSet<PathBuf>,
204    depth: usize,
205) -> Option<CompilerOptions> {
206    if depth >= MAX_EXTENDS_DEPTH {
207        return None;
208    }
209    let config_path = dunce::canonicalize(config_path).ok()?;
210    if !visited.insert(config_path.clone()) {
211        return None;
212    }
213    let content = std::fs::read_to_string(&config_path).ok()?;
214    let value: serde_json::Value =
215        crate::jsonc::parse_to_value(content.trim_start_matches('\u{FEFF}')).ok()?;
216    let parent_options = match value.get("extends") {
217        Some(serde_json::Value::String(extends)) => {
218            let parent = resolve_extends_path(&config_path, extends)?;
219            read_compiler_options(&parent, substitution_base, visited, depth + 1)?
220        }
221        Some(_) => return None,
222        None => CompilerOptions::default(),
223    };
224    let mut options = parent_options;
225    let Some(compiler_options) = value.get("compilerOptions") else {
226        visited.remove(&config_path);
227        return Some(options);
228    };
229    let compiler_options = compiler_options.as_object()?;
230    let config_dir = config_path.parent()?;
231
232    replace_path_option(
233        compiler_options,
234        "rootDir",
235        config_dir,
236        substitution_base,
237        &mut options.root_dir,
238    )?;
239    replace_path_option(
240        compiler_options,
241        "outDir",
242        config_dir,
243        substitution_base,
244        &mut options.out_dir,
245    )?;
246    replace_path_option(
247        compiler_options,
248        "declarationDir",
249        config_dir,
250        substitution_base,
251        &mut options.declaration_dir,
252    )?;
253    replace_bool_option(compiler_options, "noEmit", &mut options.no_emit)?;
254    replace_bool_option(
255        compiler_options,
256        "emitDeclarationOnly",
257        &mut options.emit_declaration_only,
258    )?;
259    visited.remove(&config_path);
260    Some(options)
261}
262
263fn replace_path_option(
264    compiler_options: &serde_json::Map<String, serde_json::Value>,
265    name: &str,
266    config_dir: &Path,
267    substitution_base: &Path,
268    option: &mut Option<PathBuf>,
269) -> Option<()> {
270    if let Some(value) = compiler_options.get(name) {
271        let value = value.as_str()?;
272        if value.is_empty() {
273            return None;
274        }
275        let value = value.replace("${configDir}", &substitution_base.to_string_lossy());
276        let path = Path::new(&value);
277        let path = if path.is_absolute() {
278            path.to_path_buf()
279        } else {
280            config_dir.join(path)
281        };
282        *option = Some(normalize_path(&path)?);
283    }
284    Some(())
285}
286
287fn replace_bool_option(
288    compiler_options: &serde_json::Map<String, serde_json::Value>,
289    name: &str,
290    option: &mut Option<bool>,
291) -> Option<()> {
292    if let Some(value) = compiler_options.get(name) {
293        *option = Some(value.as_bool()?);
294    }
295    Some(())
296}
297
298/// Find the configs in the `extends` chain of `config_path` that exist but fail
299/// to parse. `value` is the already parsed content of `config_path`.
300///
301/// Returns `(path, parser message)` pairs in walk order. A target that does not
302/// resolve to a file is skipped, because this walk reports syntax only. The
303/// walk follows string and array `extends` with the same lookup that
304/// [`TsconfigOutputMap`] uses, and stops at [`MAX_EXTENDS_DEPTH`].
305pub(super) fn malformed_extends_parents(
306    config_path: &Path,
307    value: &serde_json::Value,
308) -> Vec<(PathBuf, String)> {
309    let mut malformed = Vec::new();
310    let mut visited = FxHashSet::default();
311    visited.insert(config_path.to_path_buf());
312    let mut frontier = extends_parents(config_path, value)
313        .into_iter()
314        .map(|parent| (parent, 1))
315        .collect::<Vec<_>>();
316    while let Some((path, depth)) = frontier.pop() {
317        if depth > MAX_EXTENDS_DEPTH || !visited.insert(path.clone()) {
318            continue;
319        }
320        let Ok(content) = std::fs::read_to_string(&path) else {
321            continue;
322        };
323        match crate::jsonc::parse_to_value::<serde_json::Value>(
324            content.trim_start_matches('\u{FEFF}'),
325        ) {
326            Ok(parent_value) => frontier.extend(
327                extends_parents(&path, &parent_value)
328                    .into_iter()
329                    .map(|parent| (parent, depth + 1)),
330            ),
331            Err(error) => malformed.push((path, error.to_string())),
332        }
333    }
334    malformed
335}
336
337/// The resolved parent configs of one tsconfig: `extends` is one string or,
338/// since TypeScript 5.0, an array of strings.
339fn extends_parents(config_path: &Path, value: &serde_json::Value) -> Vec<PathBuf> {
340    let targets: Vec<&str> = match value.get("extends") {
341        Some(serde_json::Value::String(target)) => vec![target.as_str()],
342        Some(serde_json::Value::Array(items)) => {
343            items.iter().filter_map(serde_json::Value::as_str).collect()
344        }
345        _ => Vec::new(),
346    };
347    targets
348        .into_iter()
349        .filter_map(|target| resolve_extends_path(config_path, target))
350        .collect()
351}
352
353fn resolve_extends_path(config_path: &Path, extends: &str) -> Option<PathBuf> {
354    let path = Path::new(extends);
355    let config_dir = config_path.parent()?;
356    let explicitly_local =
357        path.is_absolute() || extends.starts_with("./") || extends.starts_with("../");
358    if !explicitly_local {
359        return resolve_package_extends(config_dir, extends);
360    }
361    let path = normalize_path(&config_dir.join(path))?;
362    if path.is_file() {
363        return Some(path);
364    }
365    let directory_config = path.join("tsconfig.json");
366    if directory_config.is_file() {
367        return Some(directory_config);
368    }
369    let file_name = path.file_name()?.to_string_lossy();
370    if !file_name.ends_with(".json") && !file_name.ends_with(".jsonc") {
371        let json = append_path_suffix(&path, ".json")?;
372        if json.is_file() {
373            return Some(json);
374        }
375        let jsonc = append_path_suffix(&path, ".jsonc")?;
376        if jsonc.is_file() {
377            return Some(jsonc);
378        }
379    }
380    None
381}
382
383fn resolve_package_extends(config_dir: &Path, extends: &str) -> Option<PathBuf> {
384    let (package_name, subpath) = package_extends_parts(extends)?;
385    let mut directory = config_dir.to_path_buf();
386
387    for _ in 0..MAX_EXTENDS_DEPTH {
388        let package_root = directory.join("node_modules").join(&package_name);
389        if package_root.is_dir() {
390            if let Some(subpath) = subpath.as_deref() {
391                return package_subpath_tsconfig(&package_root, subpath);
392            }
393            return package_root_tsconfig(&package_root);
394        }
395        if !directory.pop() {
396            break;
397        }
398    }
399    None
400}
401
402fn package_subpath_tsconfig(package_root: &Path, subpath: &str) -> Option<PathBuf> {
403    let package_json_path = package_root.join("package.json");
404    if package_json_path.is_file() {
405        let package_json = std::fs::read_to_string(package_json_path).ok()?;
406        let package_json: serde_json::Value = serde_json::from_str(&package_json).ok()?;
407        if let Some(exports) = package_json.get("exports") {
408            let serde_json::Value::Object(exports) = exports else {
409                return None;
410            };
411            let key = format!("./{subpath}");
412            let target = exports.get(&key)?.as_str()?;
413            return resolve_exported_config_file(package_root, target);
414        }
415    }
416    resolve_config_file(package_root, &format!("./{subpath}"))
417}
418
419fn package_extends_parts(extends: &str) -> Option<(PathBuf, Option<String>)> {
420    let parts = extends.split('/').collect::<Vec<_>>();
421    let package_parts = if parts.first()?.starts_with('@') {
422        2
423    } else {
424        1
425    };
426    if parts.len() < package_parts || parts.iter().any(|part| part.is_empty() || *part == "..") {
427        return None;
428    }
429    let package = parts[..package_parts].join("/");
430    let subpath = (parts.len() > package_parts).then(|| parts[package_parts..].join("/"));
431    Some((PathBuf::from(package), subpath))
432}
433
434fn package_root_tsconfig(package_root: &Path) -> Option<PathBuf> {
435    let package_json_path = package_root.join("package.json");
436    if package_json_path.is_file() {
437        let package_json = std::fs::read_to_string(package_json_path).ok()?;
438        let package_json: serde_json::Value = serde_json::from_str(&package_json).ok()?;
439        if let Some(exports) = package_json.get("exports") {
440            let target = match exports {
441                serde_json::Value::String(target) => Some(target.as_str()),
442                serde_json::Value::Object(map) => map.get(".").and_then(serde_json::Value::as_str),
443                _ => None,
444            }?;
445            return resolve_exported_config_file(package_root, target);
446        }
447        if let Some(tsconfig) = package_json.get("tsconfig") {
448            return resolve_package_tsconfig_field(package_root, tsconfig.as_str()?);
449        }
450    }
451    let conventional = package_root.join("tsconfig.json");
452    conventional.is_file().then_some(conventional)
453}
454
455fn resolve_package_tsconfig_field(package_root: &Path, target: &str) -> Option<PathBuf> {
456    if target.starts_with("./") {
457        return resolve_config_file(package_root, target);
458    }
459    if target.is_empty()
460        || target.starts_with('/')
461        || target.contains('*')
462        || Path::new(target)
463            .components()
464            .any(|component| matches!(component, Component::ParentDir))
465    {
466        return None;
467    }
468    resolve_config_file(package_root, &format!("./{target}"))
469}
470
471fn resolve_exported_config_file(root: &Path, target: &str) -> Option<PathBuf> {
472    let path = resolve_config_target(root, target)?;
473    (path.extension()? == "json" && path.is_file()).then_some(path)
474}
475
476fn resolve_config_file(root: &Path, target: &str) -> Option<PathBuf> {
477    let path = resolve_config_target(root, target)?;
478    if path
479        .extension()
480        .is_some_and(|extension| extension == "json")
481        && path.is_file()
482    {
483        return Some(path);
484    }
485    let candidate = append_path_suffix(&path, ".json")?;
486    candidate.is_file().then_some(candidate)
487}
488
489fn resolve_config_target(root: &Path, target: &str) -> Option<PathBuf> {
490    if !target.starts_with("./") || target.contains('*') {
491        return None;
492    }
493    let relative = Path::new(target);
494    relative.file_name()?;
495    if relative
496        .components()
497        .any(|component| matches!(component, Component::ParentDir))
498    {
499        return None;
500    }
501    normalize_path(&root.join(relative))
502}
503
504fn source_suffix_for_output(
505    entry: &Path,
506    out_dir: &Path,
507) -> Option<(PathBuf, &'static [&'static str])> {
508    let relative = entry.strip_prefix(out_dir).ok()?;
509    let file_name = relative.file_name()?.to_str()?;
510    output_source_stem(relative, file_name)
511}
512
513fn output_source_stem(path: &Path, file_name: &str) -> Option<(PathBuf, &'static [&'static str])> {
514    if let Some(suffix) = DECLARATION_OUTPUT_SUFFIXES
515        .iter()
516        .find(|suffix| file_name.ends_with(**suffix))
517    {
518        let stem = file_name.strip_suffix(suffix)?;
519        let extensions = match *suffix {
520            ".d.ts" => &["ts", "tsx", "js", "jsx"][..],
521            ".d.mts" => &["mts", "mjs"][..],
522            ".d.cts" => &["cts", "cjs"][..],
523            _ => return None,
524        };
525        return Some((path.parent()?.join(stem), extensions));
526    }
527
528    let extension = path.extension()?.to_str()?;
529    let stem = file_name.strip_suffix(&format!(".{extension}"))?;
530    let extensions = match extension {
531        "js" => &["ts", "tsx", "js", "jsx"][..],
532        "jsx" => &["tsx", "jsx"][..],
533        "mjs" => &["mts", "mjs"][..],
534        "cjs" => &["cts", "cjs"][..],
535        _ => return None,
536    };
537    Some((path.parent()?.join(stem), extensions))
538}
539
540fn append_extension(stem: &Path, extension: &str) -> Option<PathBuf> {
541    let file_name = stem.file_name()?.to_str()?;
542    Some(stem.parent()?.join(format!("{file_name}.{extension}")))
543}
544
545fn append_path_suffix(path: &Path, suffix: &str) -> Option<PathBuf> {
546    let file_name = path.file_name()?.to_str()?;
547    Some(path.parent()?.join(format!("{file_name}{suffix}")))
548}
549
550fn normalize_path(path: &Path) -> Option<PathBuf> {
551    let mut normalized = PathBuf::new();
552    for component in path.components() {
553        match component {
554            Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()),
555            Component::CurDir => {}
556            Component::ParentDir => {
557                if !normalized.pop() {
558                    return None;
559                }
560            }
561            Component::Normal(part) => normalized.push(part),
562        }
563    }
564    Some(normalized)
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    fn write(path: &Path, content: &str) {
572        std::fs::create_dir_all(path.parent().expect("parent directory")).expect("create parent");
573        std::fs::write(path, content).expect("write file");
574    }
575
576    fn map(root: &Path, entry: &str, source_extensions: &[&str]) -> TsconfigOutputResolution {
577        TsconfigOutputMap::from_project(root).resolve_source_for_entry(entry, source_extensions)
578    }
579
580    #[test]
581    fn maps_generated_entry_with_inherited_paths_relative_to_their_config_file() {
582        let directory = tempfile::tempdir().expect("temporary directory");
583        let root = directory.path();
584        write(
585            &root.join("configs/tsconfig.base.json"),
586            r#"{
587                "compilerOptions": {
588                    "rootDir": "../source",
589                    "outDir": "../distribution"
590                }
591            }"#,
592        );
593        write(
594            &root.join("tsconfig.build.json"),
595            r#"{"extends":"./configs/tsconfig.base.json"}"#,
596        );
597        write(&root.join("source/index.ts"), "export const value = 1;\n");
598
599        assert_eq!(
600            map(root, "./distribution/index.js", &["ts", "tsx", "js"]),
601            TsconfigOutputResolution::Resolved(root.join("source/index.ts"))
602        );
603    }
604
605    #[test]
606    fn no_emit_configs_do_not_map_public_outputs() {
607        let directory = tempfile::tempdir().expect("temporary directory");
608        let root = directory.path();
609        write(
610            &root.join("configs/tsconfig.base.json"),
611            r#"{
612                "compilerOptions": {
613                    "rootDir": "../source",
614                    "outDir": "../distribution"
615                }
616            }"#,
617        );
618        write(
619            &root.join("tsconfig.test.json"),
620            r#"{
621                "extends": "./configs/tsconfig.base.json",
622                "compilerOptions": {"noEmit": true}
623            }"#,
624        );
625        write(&root.join("source/index.ts"), "export const value = 1;\n");
626
627        assert_eq!(
628            map(root, "./distribution/index.js", &["ts"]),
629            TsconfigOutputResolution::ConfiguredButUnresolved
630        );
631        write(
632            &root.join("tsconfig.test.json"),
633            r#"{"extends":"./configs/tsconfig.base.json","compilerOptions":{"noEmit":false}}"#,
634        );
635        assert_eq!(
636            map(root, "./distribution/index.js", &["ts"]),
637            TsconfigOutputResolution::Resolved(root.join("source/index.ts"))
638        );
639    }
640
641    #[test]
642    fn ambiguous_source_candidates_fail_closed() {
643        let directory = tempfile::tempdir().expect("temporary directory");
644        let root = directory.path();
645        write(
646            &root.join("tsconfig.build.json"),
647            r#"{
648                "compilerOptions": {
649                    "rootDir": "./source",
650                    "outDir": "./distribution"
651                }
652            }"#,
653        );
654        write(&root.join("source/index.ts"), "export const value = 1;\n");
655        write(&root.join("source/index.js"), "export const value = 1;\n");
656
657        assert_eq!(
658            map(root, "./distribution/index.js", &["ts", "js"]),
659            TsconfigOutputResolution::ConfiguredButUnresolved
660        );
661        assert_eq!(
662            map(root, "./distribution/index.js", &["ts"]),
663            TsconfigOutputResolution::Resolved(root.join("source/index.ts"))
664        );
665    }
666
667    #[test]
668    fn maps_dotted_basenames_and_keeps_module_output_families_separate() {
669        let directory = tempfile::tempdir().expect("temporary directory");
670        let root = directory.path();
671        write(
672            &root.join("tsconfig.build.json"),
673            r#"{
674                "compilerOptions": {
675                    "rootDir": "./source",
676                    "outDir": "./distribution"
677                }
678            }"#,
679        );
680        write(
681            &root.join("source/feature.test.ts"),
682            "export const value = 1;\n",
683        );
684        write(
685            &root.join("source/feature.native.mts"),
686            "export const native = 1;\n",
687        );
688
689        assert_eq!(
690            map(root, "./distribution/feature.test.js", &["ts"]),
691            TsconfigOutputResolution::Resolved(root.join("source/feature.test.ts"))
692        );
693        assert_eq!(
694            map(root, "./distribution/feature.test.mjs", &["ts"]),
695            TsconfigOutputResolution::ConfiguredButUnresolved,
696            "an MJS output must not resolve to an unrelated TS source with the same basename"
697        );
698        assert_eq!(
699            map(root, "./distribution/feature.native.mjs", &["mts"]),
700            TsconfigOutputResolution::Resolved(root.join("source/feature.native.mts"))
701        );
702    }
703
704    #[test]
705    fn resolves_installed_package_extends_and_config_dir_options_statically() {
706        let directory = tempfile::tempdir().expect("temporary directory");
707        let root = directory.path();
708        write(
709            &root.join("node_modules/@scope/config/package.json"),
710            r#"{"exports":"./shared.json"}"#,
711        );
712        write(
713            &root.join("node_modules/@scope/config/shared.json"),
714            r#"{
715                "compilerOptions": {
716                    "outDir": "${configDir}/distribution"
717                }
718            }"#,
719        );
720        write(
721            &root.join("tsconfig.json"),
722            r#"{"extends":"@scope/config"}"#,
723        );
724        write(
725            &root.join("tsconfig.build.json"),
726            r#"{
727                "extends":"./tsconfig.json",
728                "compilerOptions":{"rootDir":"./source"}
729            }"#,
730        );
731        write(&root.join("source/index.ts"), "export const value = 1;\n");
732
733        assert_eq!(
734            map(root, "./distribution/index.js", &["ts"]),
735            TsconfigOutputResolution::Resolved(root.join("source/index.ts"))
736        );
737    }
738
739    #[test]
740    fn bare_extends_uses_package_lookup_and_dotted_local_stems_append_json() {
741        let directory = tempfile::tempdir().expect("temporary directory");
742        let root = directory.path();
743        write(
744            &root.join("configs/tsconfig.base.json"),
745            r#"{
746                "compilerOptions": {
747                    "rootDir": "../source",
748                    "outDir": "../local-output"
749                }
750            }"#,
751        );
752        write(
753            &root.join("node_modules/shared-config/package.json"),
754            r#"{"exports":"./shared.json"}"#,
755        );
756        write(
757            &root.join("node_modules/shared-config/shared.json"),
758            r#"{"compilerOptions":{"outDir":"../../distribution"}}"#,
759        );
760        write(
761            &root.join("shared-config"),
762            r#"{"compilerOptions":{"outDir":"./wrong-output"}}"#,
763        );
764        write(
765            &root.join("tsconfig.local.json"),
766            r#"{"extends":"./configs/tsconfig.base"}"#,
767        );
768        write(
769            &root.join("tsconfig.package.json"),
770            r#"{
771                "extends":"shared-config",
772                "compilerOptions":{"rootDir":"./source"}
773            }"#,
774        );
775        write(&root.join("source/index.ts"), "export const value = 1;\n");
776
777        let map = TsconfigOutputMap::from_project(root);
778        assert_eq!(
779            map.resolve_source_for_entry("./distribution/index.js", &["ts"]),
780            TsconfigOutputResolution::Resolved(root.join("source/index.ts")),
781            "bare package names must resolve through node_modules even with a same-named local file"
782        );
783        assert_eq!(
784            map.resolve_source_for_entry("./local-output/index.js", &["ts"]),
785            TsconfigOutputResolution::Resolved(root.join("source/index.ts")),
786            "dotted local config stems should resolve by appending .json"
787        );
788    }
789
790    #[test]
791    fn package_config_fields_and_unexported_subpaths_resolve_json_files() {
792        let directory = tempfile::tempdir().expect("temporary directory");
793        let root = directory.path();
794        let package_root = root.join("node_modules/shared-config");
795        for file in [
796            "valid.json",
797            "dotted.base.json",
798            "double.json.json",
799            "extensionless",
800            "invalid.jsonc",
801            "invalid.ts",
802        ] {
803            write(
804                &package_root.join(file),
805                r#"{"compilerOptions":{"outDir":"../../distribution"}}"#,
806            );
807        }
808        write(&root.join("source/index.ts"), "export const value = 1;\n");
809
810        for use_field in [true, false] {
811            for (target, resolves) in [
812                ("valid.json", true),
813                ("valid", true),
814                ("dotted.base", true),
815                ("double.json", true),
816                ("extensionless", false),
817                ("invalid.jsonc", false),
818                ("invalid.ts", false),
819            ] {
820                let (manifest, extends) = if use_field {
821                    (
822                        serde_json::json!({"tsconfig": target}),
823                        "shared-config".to_string(),
824                    )
825                } else {
826                    (serde_json::json!({}), format!("shared-config/{target}"))
827                };
828                write(&package_root.join("package.json"), &manifest.to_string());
829                write(
830                    &root.join("tsconfig.json"),
831                    &serde_json::json!({
832                        "extends": extends,
833                        "compilerOptions": {"rootDir": "./source"}
834                    })
835                    .to_string(),
836                );
837                let expected = if resolves {
838                    TsconfigOutputResolution::Resolved(root.join("source/index.ts"))
839                } else {
840                    TsconfigOutputResolution::Unconfigured
841                };
842                assert_eq!(
843                    map(root, "./distribution/index.js", &["ts"]),
844                    expected,
845                    "package config target {target}, tsconfig field present={use_field}"
846                );
847            }
848        }
849    }
850
851    #[test]
852    fn package_exports_precede_conventional_tsconfig_and_conditions_fail_closed() {
853        let directory = tempfile::tempdir().expect("temporary directory");
854        let root = directory.path();
855        write(
856            &root.join("node_modules/shared-config/package.json"),
857            r#"{"exports":{".":"./build.json"}}"#,
858        );
859        write(
860            &root.join("node_modules/shared-config/build.json"),
861            r#"{"compilerOptions":{"outDir":"../../distribution"}}"#,
862        );
863        write(
864            &root.join("node_modules/shared-config/tsconfig.json"),
865            r#"{"compilerOptions":{"outDir":"../../other"}}"#,
866        );
867        write(
868            &root.join("node_modules/conditional-config/package.json"),
869            r#"{"exports":{".":{"types":"./types.json"}},"tsconfig":"./fallback.json"}"#,
870        );
871        write(
872            &root.join("node_modules/conditional-config/types.json"),
873            r#"{"compilerOptions":{"outDir":"../../conditional-output"}}"#,
874        );
875        write(
876            &root.join("node_modules/conditional-config/fallback.json"),
877            r#"{"compilerOptions":{"outDir":"../../conditional-output"}}"#,
878        );
879        write(
880            &root.join("tsconfig.build.json"),
881            r#"{
882                "extends":"shared-config",
883                "compilerOptions":{"rootDir":"./source"}
884            }"#,
885        );
886        write(
887            &root.join("tsconfig.conditional.json"),
888            r#"{
889                "extends":"conditional-config",
890                "compilerOptions":{"rootDir":"./source"}
891            }"#,
892        );
893        write(&root.join("source/index.ts"), "export const value = 1;\n");
894
895        let map = TsconfigOutputMap::from_project(root);
896        assert_eq!(
897            map.resolve_source_for_entry("./distribution/index.js", &["ts"]),
898            TsconfigOutputResolution::Resolved(root.join("source/index.ts")),
899            "the simple root export should be selected before conventional tsconfig.json"
900        );
901        assert_eq!(
902            map.resolve_source_for_entry("./other/index.js", &["ts"]),
903            TsconfigOutputResolution::Unconfigured,
904            "a simple root export must take precedence over the conventional config"
905        );
906        assert_eq!(
907            map.resolve_source_for_entry("./conditional-output/index.js", &["ts"]),
908            TsconfigOutputResolution::Unconfigured,
909            "unsupported conditional package exports must not fall through to package tsconfig"
910        );
911    }
912
913    #[test]
914    fn package_export_targets_require_explicit_config_extensions() {
915        for (extends, export_key) in [
916            ("shared-config", None),
917            ("shared-config", Some(".")),
918            ("shared-config/build", Some("./build")),
919        ] {
920            let directory = tempfile::tempdir().expect("temporary directory");
921            let root = directory.path();
922            let package_root = root.join("node_modules/shared-config");
923            for file in [
924                "build.json",
925                "build",
926                "build.jsonc",
927                "build.ts",
928                "inferred.json",
929                "double.json.json",
930            ] {
931                write(
932                    &package_root.join(file),
933                    r#"{"compilerOptions":{"outDir":"../../distribution"}}"#,
934                );
935            }
936            write(
937                &root.join("tsconfig.json"),
938                &format!(r#"{{"extends":"{extends}","compilerOptions":{{"rootDir":"./source"}}}}"#),
939            );
940            write(&root.join("source/index.ts"), "export const value = 1;\n");
941
942            for (target, expected) in [
943                ("./build", TsconfigOutputResolution::Unconfigured),
944                ("./build.jsonc", TsconfigOutputResolution::Unconfigured),
945                ("./build.ts", TsconfigOutputResolution::Unconfigured),
946                ("./inferred", TsconfigOutputResolution::Unconfigured),
947                ("./double.json", TsconfigOutputResolution::Unconfigured),
948                (
949                    "./build.json",
950                    TsconfigOutputResolution::Resolved(root.join("source/index.ts")),
951                ),
952            ] {
953                let exports = export_key.map_or_else(
954                    || serde_json::json!(target),
955                    |key| serde_json::json!({key: target}),
956                );
957                write(
958                    &package_root.join("package.json"),
959                    &serde_json::json!({"exports": exports}).to_string(),
960                );
961                assert_eq!(
962                    map(root, "./distribution/index.js", &["ts"]),
963                    expected,
964                    "package exports must resolve exactly: {extends} -> {target}"
965                );
966            }
967        }
968    }
969
970    #[test]
971    fn package_subpaths_resolve_through_simple_exports_without_bypassing_restrictions() {
972        let directory = tempfile::tempdir().expect("temporary directory");
973        let root = directory.path();
974        write(
975            &root.join("node_modules/shared-config/package.json"),
976            r#"{"exports":{".":"./root.json","./build":"./build.json"}}"#,
977        );
978        write(
979            &root.join("node_modules/shared-config/root.json"),
980            r#"{"compilerOptions":{"outDir":"../../wrong-output"}}"#,
981        );
982        write(
983            &root.join("node_modules/shared-config/build.json"),
984            r#"{"compilerOptions":{"outDir":"../../distribution"}}"#,
985        );
986        write(
987            &root.join("node_modules/shared-config/private.json"),
988            r#"{"compilerOptions":{"outDir":"../../private-output"}}"#,
989        );
990        write(
991            &root.join("node_modules/restricted-config/package.json"),
992            r#"{"exports":"./root.json"}"#,
993        );
994        write(
995            &root.join("node_modules/restricted-config/root.json"),
996            r#"{"compilerOptions":{"outDir":"../../wrong-output"}}"#,
997        );
998        write(
999            &root.join("node_modules/restricted-config/private.json"),
1000            r#"{"compilerOptions":{"outDir":"../../private-output"}}"#,
1001        );
1002        write(
1003            &root.join("tsconfig.build.json"),
1004            r#"{
1005                "extends":"shared-config/build",
1006                "compilerOptions":{"rootDir":"./source"}
1007            }"#,
1008        );
1009        write(
1010            &root.join("tsconfig.restricted.json"),
1011            r#"{
1012                "extends":"restricted-config/private",
1013                "compilerOptions":{"rootDir":"./source"}
1014            }"#,
1015        );
1016        write(
1017            &root.join("tsconfig.private.json"),
1018            r#"{"extends":"shared-config/private","compilerOptions":{"rootDir":"./source"}}"#,
1019        );
1020        write(&root.join("source/index.ts"), "export const value = 1;\n");
1021
1022        let map = TsconfigOutputMap::from_project(root);
1023        assert_eq!(
1024            map.resolve_source_for_entry("./distribution/index.js", &["ts"]),
1025            TsconfigOutputResolution::Resolved(root.join("source/index.ts")),
1026            "the exact exported subpath should map through the configured build output"
1027        );
1028        assert_eq!(
1029            map.resolve_source_for_entry("./wrong-output/index.js", &["ts"]),
1030            TsconfigOutputResolution::Unconfigured,
1031            "a subpath must not use the package root config"
1032        );
1033        assert_eq!(
1034            map.resolve_source_for_entry("./private-output/index.js", &["ts"]),
1035            TsconfigOutputResolution::Unconfigured,
1036            "an unexported package subpath must not be loaded from disk"
1037        );
1038    }
1039
1040    #[test]
1041    fn missing_local_extends_does_not_use_child_options() {
1042        let directory = tempfile::tempdir().expect("temporary directory");
1043        let root = directory.path();
1044        write(
1045            &root.join("tsconfig.build.json"),
1046            r#"{
1047                "extends":"./missing-config.json",
1048                "compilerOptions": {
1049                    "rootDir":"./source",
1050                    "outDir":"./distribution"
1051                }
1052            }"#,
1053        );
1054        write(&root.join("source/index.ts"), "export const value = 1;\n");
1055
1056        assert_eq!(
1057            map(root, "./distribution/index.js", &["ts"]),
1058            TsconfigOutputResolution::Unconfigured
1059        );
1060        write(&root.join("missing-config.json"), "{}");
1061        assert_eq!(
1062            map(root, "./distribution/index.js", &["ts"]),
1063            TsconfigOutputResolution::Resolved(root.join("source/index.ts"))
1064        );
1065    }
1066
1067    #[test]
1068    fn invalid_configs_fail_closed() {
1069        let directory = tempfile::tempdir().expect("temporary directory");
1070        let root = directory.path();
1071        write(&root.join("source/index.ts"), "export const value = 1;\n");
1072        write(
1073            &root.join("tsconfig.build.json"),
1074            "{ this is not valid JSONC",
1075        );
1076
1077        assert_eq!(
1078            map(root, "./distribution/index.js", &["ts"]),
1079            TsconfigOutputResolution::Unconfigured
1080        );
1081        write(
1082            &root.join("tsconfig.build.json"),
1083            r#"{"compilerOptions":{"rootDir":"./source","outDir":"./distribution"}}"#,
1084        );
1085        assert_eq!(
1086            map(root, "./distribution/index.js", &["ts"]),
1087            TsconfigOutputResolution::Resolved(root.join("source/index.ts"))
1088        );
1089
1090        let missing = tempfile::tempdir().expect("empty temporary project");
1091        assert_eq!(
1092            map(missing.path(), "./distribution/index.js", &["ts"]),
1093            TsconfigOutputResolution::Unconfigured
1094        );
1095    }
1096}