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,
24 Resolved(PathBuf),
26}
27
28#[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 #[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 #[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 #[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
298pub(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
337fn 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}