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#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum TsconfigOutputResolution {
20 Unconfigured,
22 ConfiguredButUnresolved,
25 ConfiguredWithoutSource,
29 Resolved(PathBuf),
31}
32
33#[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 #[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 #[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 #[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
306pub(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#[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
383fn 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
408fn 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 #[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}