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