Skip to main content

code_moniker_workspace/
tsconfig.rs

1use std::path::{Path, PathBuf};
2
3use code_moniker_core::lang::ts::{PathAlias, TsSdkProfile};
4use regex::Regex;
5use serde::Deserialize;
6
7#[derive(Debug, Clone)]
8pub struct TsResolution {
9	pub aliases: Vec<PathAlias>,
10	root: PathBuf,
11	default_sdk_profile: TsSdkProfile,
12	sdk_profiles: Vec<TsSdkScope>,
13}
14
15#[derive(Debug, Clone)]
16struct TsSdkScope {
17	root: PathBuf,
18	config_path: PathBuf,
19	profile: TsSdkProfile,
20	selector: TsFileSelector,
21}
22
23#[derive(Debug, Clone, Default)]
24struct TsFileSelector {
25	files: Option<Vec<PathBuf>>,
26	include: Option<Vec<Regex>>,
27	exclude: Vec<Regex>,
28}
29
30impl Default for TsResolution {
31	fn default() -> Self {
32		Self {
33			aliases: Vec::new(),
34			root: PathBuf::new(),
35			default_sdk_profile: TsSdkProfile::default(),
36			sdk_profiles: Vec::new(),
37		}
38	}
39}
40
41impl TsResolution {
42	pub fn sdk_profile_for(&self, path: &Path) -> &TsSdkProfile {
43		let unresolved = if path.is_absolute() || self.root.as_os_str().is_empty() {
44			path.to_path_buf()
45		} else {
46			self.root.join(path)
47		};
48		let absolute = normalize_scope_path(&unresolved);
49		self.sdk_profiles
50			.iter()
51			.filter_map(|scope| {
52				scope
53					.selector
54					.match_score(&scope.root, &absolute)
55					.map(|score| (scope, score))
56			})
57			.max_by(|(left, left_score), (right, right_score)| {
58				left.root
59					.components()
60					.count()
61					.cmp(&right.root.components().count())
62					.then_with(|| left_score.cmp(right_score))
63					.then_with(|| left.config_path.cmp(&right.config_path))
64			})
65			.map(|(scope, _)| &scope.profile)
66			.unwrap_or(&self.default_sdk_profile)
67	}
68}
69
70impl TsFileSelector {
71	fn from_options(options: &EffectiveSdkOptions) -> Self {
72		Self {
73			files: options.files.clone(),
74			include: options.include.clone(),
75			exclude: options.exclude.clone().unwrap_or_default(),
76		}
77	}
78
79	fn match_score(&self, root: &Path, absolute: &Path) -> Option<usize> {
80		let absolute_text = normalize_config_path(&absolute.to_string_lossy());
81		if self
82			.files
83			.as_ref()
84			.is_some_and(|files| files.iter().any(|file| file == absolute))
85		{
86			return Some(2_000_000 + absolute_text.len());
87		}
88		let excluded = self
89			.exclude
90			.iter()
91			.any(|pattern| pattern.is_match(&absolute_text));
92		if !excluded
93			&& let Some(score) = self.include.as_ref().and_then(|patterns| {
94				patterns
95					.iter()
96					.filter(|pattern| pattern.is_match(&absolute_text))
97					.map(|pattern| pattern.as_str().len())
98					.max()
99			}) {
100			return Some(1_000_000 + score);
101		}
102		if self.files.is_none() && self.include.is_none() && !excluded && absolute.starts_with(root)
103		{
104			return Some(1);
105		}
106		None
107	}
108}
109
110fn normalize_scope_path(path: &Path) -> PathBuf {
111	path.canonicalize().unwrap_or_else(|_| {
112		path.parent()
113			.and_then(|parent| parent.canonicalize().ok())
114			.and_then(|parent| path.file_name().map(|name| parent.join(name)))
115			.unwrap_or_else(|| path.to_path_buf())
116	})
117}
118
119#[derive(Deserialize)]
120struct RawTsConfig {
121	#[serde(rename = "compilerOptions", default)]
122	compiler_options: Option<RawCompilerOptions>,
123	#[serde(default)]
124	extends: Option<RawExtends>,
125	#[serde(default)]
126	files: Option<Vec<String>>,
127	#[serde(default)]
128	include: Option<Vec<String>>,
129	#[serde(default)]
130	exclude: Option<Vec<String>>,
131	#[serde(default)]
132	references: Vec<RawReference>,
133}
134
135#[derive(Deserialize)]
136#[serde(untagged)]
137enum RawExtends {
138	One(String),
139	Many(Vec<String>),
140}
141
142impl RawExtends {
143	fn specifiers(&self) -> Box<dyn Iterator<Item = &str> + '_> {
144		match self {
145			Self::One(specifier) => Box::new(std::iter::once(specifier.as_str())),
146			Self::Many(specifiers) => Box::new(specifiers.iter().map(String::as_str)),
147		}
148	}
149}
150
151#[derive(Deserialize)]
152struct RawCompilerOptions {
153	#[serde(rename = "baseUrl", default)]
154	base_url: Option<String>,
155	#[serde(default)]
156	paths: std::collections::BTreeMap<String, Vec<String>>,
157	#[serde(default)]
158	lib: Option<Vec<String>>,
159	#[serde(default)]
160	target: Option<String>,
161	#[serde(default)]
162	types: Option<Vec<String>>,
163}
164
165#[derive(Deserialize)]
166struct RawReference {
167	path: String,
168}
169
170const SKIP_DIR_NAMES: &[&str] = &["node_modules", "target", "dist", "build", "out"];
171
172const MAX_REFERENCES_DEPTH: usize = 3;
173const MAX_SDK_EXTENDS_DEPTH: usize = 32;
174
175pub fn load(root: &Path) -> TsResolution {
176	let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
177	let mut aliases: Vec<PathAlias> = Vec::new();
178	let entries = discover_tsconfigs(root);
179	for entry in &entries {
180		merge_from_file(entry, &canonical_root, &mut aliases, 0);
181	}
182	let sdk_profiles = entries
183		.iter()
184		.filter_map(|entry| sdk_scope_from_file(entry))
185		.collect();
186	TsResolution {
187		aliases,
188		root: canonical_root,
189		default_sdk_profile: TsSdkProfile::default(),
190		sdk_profiles,
191	}
192}
193
194fn discover_tsconfigs(root: &Path) -> Vec<PathBuf> {
195	let mut out = Vec::new();
196	let mut pending = vec![root.to_path_buf()];
197	while let Some(directory) = pending.pop() {
198		let Ok(entries) = std::fs::read_dir(&directory) else {
199			continue;
200		};
201		for entry in entries.flatten() {
202			let path = entry.path();
203			let Ok(file_type) = entry.file_type() else {
204				continue;
205			};
206			if file_type.is_dir() {
207				if !is_ignored_dir(&path) {
208					pending.push(path);
209				}
210			} else if file_type.is_file() && is_tsconfig_path(&path) {
211				out.push(path);
212			}
213		}
214	}
215	out.sort();
216	out
217}
218
219pub(crate) fn is_tsconfig_path(path: &Path) -> bool {
220	path.file_name()
221		.and_then(|name| name.to_str())
222		.is_some_and(|name| {
223			name == "tsconfig.json" || name.starts_with("tsconfig.") && name.ends_with(".json")
224		})
225}
226
227fn is_ignored_dir(path: &Path) -> bool {
228	let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
229		return false;
230	};
231	name.starts_with('.') || SKIP_DIR_NAMES.contains(&name)
232}
233
234fn merge_from_file(file: &Path, root: &Path, aliases: &mut Vec<PathAlias>, depth: usize) {
235	if depth > MAX_REFERENCES_DEPTH {
236		return;
237	}
238	let Ok(raw) = std::fs::read_to_string(file) else {
239		return;
240	};
241	let stripped = strip_jsonc(&raw);
242	let Ok(parsed) = serde_json::from_str::<RawTsConfig>(&stripped) else {
243		return;
244	};
245	let file_dir = file.parent().unwrap_or(root);
246
247	if let Some(opts) = parsed.compiler_options.as_ref() {
248		let base_dir = match opts.base_url.as_deref() {
249			Some(s) => file_dir.join(s),
250			None => file_dir.to_path_buf(),
251		};
252		for (pattern, substitutions) in &opts.paths {
253			let Some(first) = substitutions.first() else {
254				continue;
255			};
256			let Some(substitution) = rebase_substitution(&base_dir, first, root) else {
257				continue;
258			};
259			if !aliases.iter().any(|a| a.pattern == *pattern) {
260				aliases.push(PathAlias {
261					pattern: pattern.clone(),
262					substitution,
263				});
264			}
265		}
266	}
267
268	for r in parsed.references {
269		let p = file_dir.join(&r.path);
270		let resolved = if p.is_file() {
271			p
272		} else if p.is_dir() {
273			p.join("tsconfig.json")
274		} else if p.extension().is_none() {
275			let with_ext = p.with_extension("json");
276			if with_ext.is_file() {
277				with_ext
278			} else {
279				continue;
280			}
281		} else {
282			continue;
283		};
284		merge_from_file(&resolved, root, aliases, depth + 1);
285	}
286}
287
288#[derive(Default)]
289struct EffectiveSdkOptions {
290	libraries: Option<Vec<String>>,
291	target: Option<String>,
292	files: Option<Vec<PathBuf>>,
293	include: Option<Vec<Regex>>,
294	exclude: Option<Vec<Regex>>,
295}
296
297impl EffectiveSdkOptions {
298	fn merge(&mut self, next: Self) {
299		if next.libraries.is_some() {
300			self.libraries = next.libraries;
301		}
302		if next.target.is_some() {
303			self.target = next.target;
304		}
305		if next.files.is_some() {
306			self.files = next.files;
307		}
308		if next.include.is_some() {
309			self.include = next.include;
310		}
311		if next.exclude.is_some() {
312			self.exclude = next.exclude;
313		}
314	}
315}
316
317fn sdk_scope_from_file(file: &Path) -> Option<TsSdkScope> {
318	let mut visited = std::collections::BTreeSet::new();
319	let options = load_sdk_options(file, 0, &mut visited)?;
320	let selector = TsFileSelector::from_options(&options);
321	let profile = match options.libraries {
322		Some(libraries) => TsSdkProfile::from_libraries(libraries),
323		None => options
324			.target
325			.as_deref()
326			.map(default_libraries_for_target)
327			.map(TsSdkProfile::from_libraries)
328			.unwrap_or_default(),
329	};
330	let root = file
331		.parent()?
332		.canonicalize()
333		.unwrap_or_else(|_| file.parent().unwrap_or(Path::new(".")).to_path_buf());
334	Some(TsSdkScope {
335		root,
336		config_path: normalize_scope_path(file),
337		profile,
338		selector,
339	})
340}
341
342fn load_sdk_options(
343	file: &Path,
344	depth: usize,
345	visited: &mut std::collections::BTreeSet<PathBuf>,
346) -> Option<EffectiveSdkOptions> {
347	if depth > MAX_SDK_EXTENDS_DEPTH {
348		eprintln!(
349			"code-moniker: TypeScript extends chain exceeds {MAX_SDK_EXTENDS_DEPTH} at {}",
350			file.display(),
351		);
352		return None;
353	}
354	let normalized = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
355	if !visited.insert(normalized.clone()) {
356		eprintln!(
357			"code-moniker: cyclic TypeScript extends chain at {}",
358			file.display(),
359		);
360		return None;
361	}
362	let raw = std::fs::read_to_string(file).ok()?;
363	let parsed = serde_json::from_str::<RawTsConfig>(&strip_jsonc(&raw)).ok()?;
364	let mut effective = EffectiveSdkOptions::default();
365	if let Some(extends) = parsed.extends.as_ref() {
366		for specifier in extends.specifiers() {
367			if let Some(parent) = resolve_extends(file, specifier)
368				&& let Some(parent_options) = load_sdk_options(&parent, depth + 1, visited)
369			{
370				effective.merge(parent_options);
371			}
372		}
373	}
374	let config_dir = file
375		.parent()
376		.map(normalize_scope_path)
377		.unwrap_or_else(|| PathBuf::from("."));
378	let own_options = EffectiveSdkOptions {
379		libraries: parsed
380			.compiler_options
381			.as_ref()
382			.and_then(|options| options.lib.clone()),
383		target: parsed
384			.compiler_options
385			.as_ref()
386			.and_then(|options| options.target.clone()),
387		files: parsed.files.map(|files| {
388			files
389				.into_iter()
390				.map(|path| crate::path_util::lexical_path(&config_dir.join(path)))
391				.collect()
392		}),
393		include: parsed
394			.include
395			.map(|patterns| compile_absolute_ts_globs(&config_dir, patterns)),
396		exclude: parsed
397			.exclude
398			.map(|patterns| compile_absolute_ts_globs(&config_dir, patterns)),
399	};
400	effective.merge(own_options);
401	if let Some(options) = parsed.compiler_options {
402		let _ = options.types;
403	}
404	visited.remove(&normalized);
405	Some(effective)
406}
407
408fn resolve_extends(file: &Path, specifier: &str) -> Option<PathBuf> {
409	if specifier.starts_with('.') || Path::new(specifier).is_absolute() {
410		let base = file.parent().unwrap_or(Path::new(".")).join(specifier);
411		return resolve_config_candidate(&base);
412	}
413	resolve_package_extends(file, specifier)
414}
415
416fn resolve_package_extends(file: &Path, specifier: &str) -> Option<PathBuf> {
417	let segments = specifier
418		.split('/')
419		.filter(|segment| !segment.is_empty())
420		.collect::<Vec<_>>();
421	let package_len = if segments.first()?.starts_with('@') {
422		2
423	} else {
424		1
425	};
426	if segments.len() < package_len {
427		return None;
428	}
429	let package_name = segments[..package_len].join("/");
430	let subpath = segments[package_len..].join("/");
431	let mut directory = file.parent();
432	while let Some(current) = directory {
433		let package_root = current.join("node_modules").join(&package_name);
434		if package_root.is_dir() {
435			if !subpath.is_empty() {
436				return resolve_config_candidate(&package_root.join(subpath));
437			}
438			if let Some(target) = package_tsconfig_target(&package_root)
439				&& let Some(resolved) = resolve_config_candidate(&package_root.join(target))
440			{
441				return Some(resolved);
442			}
443			return resolve_config_candidate(&package_root.join("tsconfig.json"));
444		}
445		directory = current.parent();
446	}
447	None
448}
449
450fn package_tsconfig_target(package_root: &Path) -> Option<String> {
451	let raw = std::fs::read_to_string(package_root.join("package.json")).ok()?;
452	let parsed = serde_json::from_str::<serde_json::Value>(&raw).ok()?;
453	parsed.get("tsconfig")?.as_str().map(str::to_owned)
454}
455
456fn resolve_config_candidate(base: &Path) -> Option<PathBuf> {
457	if base.is_file() {
458		return Some(base.to_path_buf());
459	}
460	if base.is_dir() {
461		let candidate = base.join("tsconfig.json");
462		return candidate.is_file().then_some(candidate);
463	}
464	if base.extension().is_none() {
465		let candidate = base.with_extension("json");
466		return candidate.is_file().then_some(candidate);
467	}
468	None
469}
470
471fn normalize_config_path(path: &str) -> String {
472	path.trim_start_matches("./").replace('\\', "/")
473}
474
475fn compile_absolute_ts_globs(base: &Path, patterns: Vec<String>) -> Vec<Regex> {
476	patterns
477		.into_iter()
478		.filter_map(|pattern| {
479			let absolute = crate::path_util::lexical_path(&base.join(pattern));
480			compile_ts_glob(&absolute.to_string_lossy())
481		})
482		.collect()
483}
484
485fn compile_ts_glob(pattern: &str) -> Option<Regex> {
486	let normalized = normalize_config_path(pattern);
487	if normalized.is_empty() {
488		return None;
489	}
490	if !normalized.contains('*') && !normalized.contains('?') {
491		let exact = regex::escape(normalized.trim_end_matches('/'));
492		let suffix = if Path::new(&normalized).extension().is_none() {
493			"(?:/.*)?"
494		} else {
495			""
496		};
497		return Regex::new(&format!("^{exact}{suffix}$")).ok();
498	}
499	let chars = normalized.chars().collect::<Vec<_>>();
500	let mut regex = String::from("^");
501	let mut index = 0;
502	while index < chars.len() {
503		match chars[index] {
504			'*' if chars.get(index + 1) == Some(&'*') => {
505				index += 2;
506				if chars.get(index) == Some(&'/') {
507					regex.push_str("(?:.*/)?");
508					index += 1;
509				} else {
510					regex.push_str(".*");
511				}
512			}
513			'*' => {
514				regex.push_str("[^/]*");
515				index += 1;
516			}
517			'?' => {
518				regex.push_str("[^/]");
519				index += 1;
520			}
521			character => {
522				regex.push_str(&regex::escape(&character.to_string()));
523				index += 1;
524			}
525		}
526	}
527	regex.push('$');
528	Regex::new(&regex).ok()
529}
530
531fn default_libraries_for_target(target: &str) -> Vec<String> {
532	let normalized = target.trim().to_ascii_lowercase();
533	match normalized.as_str() {
534		"es3" | "es5" => vec![
535			"es5".into(),
536			"dom".into(),
537			"dom.iterable".into(),
538			"scripthost".into(),
539		],
540		"es6" => vec!["es2015.full".into()],
541		"latest" => vec!["esnext.full".into()],
542		target => vec![format!("{target}.full")],
543	}
544}
545
546fn rebase_substitution(base_dir: &Path, sub: &str, root: &Path) -> Option<String> {
547	let (prefix, star, suffix) = match sub.find('*') {
548		Some(i) => (&sub[..i], true, &sub[i + 1..]),
549		None => (sub, false, ""),
550	};
551	let abs_prefix = base_dir.join(prefix);
552	let canonical = abs_prefix.canonicalize().unwrap_or_else(|_| {
553		base_dir
554			.canonicalize()
555			.unwrap_or_else(|_| base_dir.to_path_buf())
556			.join(prefix)
557	});
558	let rel = canonical.strip_prefix(root).ok()?;
559	let rel_str = rel.to_string_lossy();
560	let mut out = String::from("./");
561	out.push_str(&rel_str);
562	if star {
563		if !out.ends_with('/') && !rel_str.is_empty() {
564			out.push('/');
565		}
566		out.push('*');
567		out.push_str(suffix);
568	}
569	Some(out)
570}
571
572fn strip_jsonc(src: &str) -> String {
573	let bytes = src.as_bytes();
574	let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
575	let mut i = 0;
576	while i < bytes.len() {
577		let b = bytes[i];
578		if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
579			while i < bytes.len() && bytes[i] != b'\n' {
580				i += 1;
581			}
582		} else if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
583			i += 2;
584			while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
585				i += 1;
586			}
587			i = (i + 2).min(bytes.len());
588		} else if b == b'"' {
589			out.push(b);
590			i += 1;
591			while i < bytes.len() && bytes[i] != b'"' {
592				if bytes[i] == b'\\' && i + 1 < bytes.len() {
593					out.push(bytes[i]);
594					out.push(bytes[i + 1]);
595					i += 2;
596				} else {
597					out.push(bytes[i]);
598					i += 1;
599				}
600			}
601			if i < bytes.len() {
602				out.push(bytes[i]);
603				i += 1;
604			}
605		} else {
606			out.push(b);
607			i += 1;
608		}
609	}
610	String::from_utf8(out).unwrap_or_else(|_| src.to_string())
611}
612
613#[cfg(test)]
614mod tests {
615	use super::*;
616	use std::fs;
617	use tempfile::tempdir;
618
619	#[test]
620	fn load_picks_aliases_from_root_tsconfig() {
621		let tmp = tempdir().unwrap();
622		fs::write(
623			tmp.path().join("tsconfig.json"),
624			r#"{"compilerOptions": {"paths": {"@/*": ["./src/*"]}}}"#,
625		)
626		.unwrap();
627		let r = load(tmp.path());
628		assert_eq!(r.aliases.len(), 1);
629		assert_eq!(r.aliases[0].pattern, "@/*");
630		assert_eq!(r.aliases[0].substitution, "./src/*");
631	}
632
633	#[test]
634	fn load_picks_aliases_from_nested_tsconfig() {
635		let tmp = tempdir().unwrap();
636		fs::create_dir_all(tmp.path().join("web/src")).unwrap();
637		fs::write(
638			tmp.path().join("web/tsconfig.app.json"),
639			r#"{"compilerOptions": {"paths": {"@/*": ["./src/*"]}}}"#,
640		)
641		.unwrap();
642		let r = load(tmp.path());
643		let pattern_hit = r
644			.aliases
645			.iter()
646			.any(|a| a.pattern == "@/*" && a.substitution.ends_with("web/src/*"));
647		assert!(
648			pattern_hit,
649			"alias from nested tsconfig must be rebased to project root: {:?}",
650			r.aliases
651		);
652	}
653
654	#[test]
655	fn load_strips_jsonc_comments() {
656		let tmp = tempdir().unwrap();
657		fs::write(
658			tmp.path().join("tsconfig.json"),
659			"{\n  // a comment\n  \"compilerOptions\": { \"paths\": { \"@/*\": [\"./src/*\"] } } /* trailing */\n}",
660		)
661		.unwrap();
662		let r = load(tmp.path());
663		assert_eq!(r.aliases.len(), 1);
664	}
665
666	#[test]
667	fn load_empty_when_no_tsconfig() {
668		let tmp = tempdir().unwrap();
669		let r = load(tmp.path());
670		assert!(r.aliases.is_empty());
671	}
672
673	#[test]
674	fn load_selects_nearest_sdk_profile_without_cross_runtime_pollution() {
675		let tmp = tempdir().unwrap();
676		fs::create_dir_all(tmp.path().join("server")).unwrap();
677		fs::create_dir_all(tmp.path().join("worker")).unwrap();
678		fs::write(
679			tmp.path().join("tsconfig.json"),
680			r#"{"compilerOptions":{"target":"ES2022","lib":["ES2022","DOM"]}}"#,
681		)
682		.unwrap();
683		fs::write(
684			tmp.path().join("server/tsconfig.json"),
685			r#"{"compilerOptions":{"target":"ES2022","lib":["ES2022"]}}"#,
686		)
687		.unwrap();
688		fs::write(
689			tmp.path().join("worker/tsconfig.json"),
690			r#"{"compilerOptions":{"target":"ES2022","lib":["ES2022","WebWorker"]}}"#,
691		)
692		.unwrap();
693
694		let resolution = load(tmp.path());
695		let dom = resolution.sdk_profile_for(&tmp.path().join("src/app.ts"));
696		let server = resolution.sdk_profile_for(&tmp.path().join("server/main.ts"));
697		let worker = resolution.sdk_profile_for(&tmp.path().join("worker/main.ts"));
698
699		assert!(dom.is_global_value(b"document"));
700		assert!(!server.is_global_value(b"document"));
701		assert!(server.is_global_type(b"Promise"));
702		assert!(worker.is_global_value(b"self"));
703		assert!(!worker.is_global_value(b"document"));
704	}
705
706	#[test]
707	fn load_inherits_and_overrides_sdk_libraries_from_local_extends() {
708		let tmp = tempdir().unwrap();
709		fs::create_dir_all(tmp.path().join("server")).unwrap();
710		fs::create_dir_all(tmp.path().join("web")).unwrap();
711		fs::write(
712			tmp.path().join("tsconfig.base.json"),
713			r#"{"compilerOptions":{"target":"ES2022","lib":["ES2022"],"types":["node"]}}"#,
714		)
715		.unwrap();
716		fs::write(
717			tmp.path().join("server/tsconfig.json"),
718			r#"{"extends":"../tsconfig.base.json"}"#,
719		)
720		.unwrap();
721		fs::write(
722			tmp.path().join("web/tsconfig.app.json"),
723			r#"{"extends":"../tsconfig.base.json","compilerOptions":{"lib":["ES2022","DOM"]}}"#,
724		)
725		.unwrap();
726
727		let resolution = load(tmp.path());
728		let server = resolution.sdk_profile_for(&tmp.path().join("server/main.ts"));
729		let web = resolution.sdk_profile_for(&tmp.path().join("web/main.ts"));
730
731		assert!(server.is_global_type(b"Promise"));
732		assert!(!server.is_global_value(b"document"));
733		assert!(
734			!server.is_global_value(b"process"),
735			"`types: [\"node\"]` selects declaration packages, not TypeScript SDK libraries",
736		);
737		assert!(web.is_global_type(b"Promise"));
738		assert!(web.is_global_value(b"document"));
739	}
740
741	#[test]
742	fn load_selects_same_directory_profiles_by_files_and_include() {
743		let tmp = tempdir().unwrap();
744		fs::create_dir_all(tmp.path().join("src")).unwrap();
745		fs::write(
746			tmp.path().join("tsconfig.app.json"),
747			r#"{
748				"compilerOptions":{"target":"ES2022","lib":["ES2022","DOM"]},
749				"include":["src/**/*.ts"]
750			}"#,
751		)
752		.unwrap();
753		fs::write(
754			tmp.path().join("tsconfig.node.json"),
755			r#"{
756				"compilerOptions":{"target":"ES2022","lib":["ES2022"]},
757				"files":["vite.config.ts"]
758			}"#,
759		)
760		.unwrap();
761
762		let resolution = load(tmp.path());
763		assert!(
764			resolution
765				.sdk_profile_for(&tmp.path().join("src/app.ts"))
766				.is_global_value(b"document"),
767			"the app include must select the DOM profile",
768		);
769		assert!(
770			!resolution
771				.sdk_profile_for(&tmp.path().join("vite.config.ts"))
772				.is_global_value(b"document"),
773			"the explicit Node file must not inherit the sibling DOM profile",
774		);
775	}
776
777	#[test]
778	fn load_resolves_package_extends_from_node_modules() {
779		let tmp = tempdir().unwrap();
780		let preset = tmp.path().join("node_modules/@tsconfig/node20");
781		fs::create_dir_all(&preset).unwrap();
782		fs::write(
783			preset.join("tsconfig.json"),
784			r#"{"compilerOptions":{"target":"ES2022","lib":["ES2022"]}}"#,
785		)
786		.unwrap();
787		fs::write(
788			tmp.path().join("tsconfig.json"),
789			r#"{"extends":"@tsconfig/node20/tsconfig.json"}"#,
790		)
791		.unwrap();
792
793		let resolution = load(tmp.path());
794		let profile = resolution.sdk_profile_for(&tmp.path().join("server.ts"));
795		assert!(profile.is_global_type(b"Promise"));
796		assert!(
797			!profile.is_global_value(b"document"),
798			"an npm-resolved Node preset must not fall back to the default DOM profile",
799		);
800	}
801
802	#[test]
803	fn load_merges_extends_arrays_in_order() {
804		let tmp = tempdir().unwrap();
805		fs::write(
806			tmp.path().join("base.json"),
807			r#"{"compilerOptions":{"target":"ES2022","lib":["ES2022","DOM"]}}"#,
808		)
809		.unwrap();
810		fs::write(
811			tmp.path().join("node.json"),
812			r#"{"compilerOptions":{"lib":["ES2022"]}}"#,
813		)
814		.unwrap();
815		fs::write(
816			tmp.path().join("tsconfig.json"),
817			r#"{"extends":["./base.json","./node.json"]}"#,
818		)
819		.unwrap();
820
821		let profile = load(tmp.path())
822			.sdk_profile_for(&tmp.path().join("server.ts"))
823			.clone();
824		assert!(profile.is_global_type(b"Promise"));
825		assert!(
826			!profile.is_global_value(b"document"),
827			"later bases in an extends array must override earlier bases",
828		);
829	}
830
831	#[test]
832	fn load_keeps_the_declaring_config_as_inherited_selector_origin() {
833		let tmp = tempdir().unwrap();
834		fs::create_dir_all(tmp.path().join("packages/app")).unwrap();
835		fs::create_dir_all(tmp.path().join("shared/private")).unwrap();
836		fs::write(
837			tmp.path().join("tsconfig.base.json"),
838			r#"{
839				"compilerOptions":{"lib":["ES2022","DOM"]},
840				"files":["special.ts"],
841				"include":["shared/**/*.ts"],
842				"exclude":["shared/private/**/*.ts"]
843			}"#,
844		)
845		.unwrap();
846		fs::write(
847			tmp.path().join("packages/app/tsconfig.json"),
848			r#"{
849				"extends":"../../tsconfig.base.json",
850				"compilerOptions":{"lib":["ES2022"]}
851			}"#,
852		)
853		.unwrap();
854
855		let resolution = load(tmp.path());
856		for path in ["special.ts", "shared/public.ts"] {
857			assert!(
858				!resolution
859					.sdk_profile_for(&tmp.path().join(path))
860					.is_global_value(b"document"),
861				"{path} must use the child Node profile selected from its base config origin",
862			);
863		}
864		assert!(
865			resolution
866				.sdk_profile_for(&tmp.path().join("shared/private/secret.ts"))
867				.is_global_value(b"document"),
868			"the inherited exclusion must remain anchored to the base config",
869		);
870	}
871
872	#[test]
873	fn load_supports_defensively_bounded_deep_extends_chains() {
874		let tmp = tempdir().unwrap();
875		fs::create_dir_all(tmp.path().join("app")).unwrap();
876		fs::write(
877			tmp.path().join("tsconfig.level0.json"),
878			r#"{"compilerOptions":{"lib":["ES2022"]}}"#,
879		)
880		.unwrap();
881		for level in 1..=5 {
882			fs::write(
883				tmp.path().join(format!("tsconfig.level{level}.json")),
884				format!(r#"{{"extends":"./tsconfig.level{}.json"}}"#, level - 1),
885			)
886			.unwrap();
887		}
888		fs::write(
889			tmp.path().join("app/tsconfig.json"),
890			r#"{"extends":"../tsconfig.level5.json"}"#,
891		)
892		.unwrap();
893
894		let profile = load(tmp.path())
895			.sdk_profile_for(&tmp.path().join("app/server.ts"))
896			.clone();
897		assert!(profile.is_global_type(b"Promise"));
898		assert!(
899			!profile.is_global_value(b"document"),
900			"a valid extends chain deeper than project-reference traversal must keep its base libs",
901		);
902	}
903
904	#[test]
905	fn load_matches_unicode_include_and_exclude_patterns() {
906		let tmp = tempdir().unwrap();
907		fs::create_dir_all(tmp.path().join("src/équipe/privé")).unwrap();
908		fs::write(
909			tmp.path().join("tsconfig.json"),
910			r#"{
911				"compilerOptions":{"lib":["ES2022"]},
912				"include":["src/équipe/**/*.ts"],
913				"exclude":["src/équipe/privé/**/*.ts"]
914			}"#,
915		)
916		.unwrap();
917
918		let resolution = load(tmp.path());
919		assert!(
920			!resolution
921				.sdk_profile_for(&tmp.path().join("src/équipe/public.ts"))
922				.is_global_value(b"document"),
923			"a Unicode include must select the Node profile",
924		);
925		assert!(
926			resolution
927				.sdk_profile_for(&tmp.path().join("src/équipe/privé/secret.ts"))
928				.is_global_value(b"document"),
929			"a Unicode exclude must keep the file outside that profile",
930		);
931	}
932
933	#[test]
934	fn load_ignores_node_modules() {
935		let tmp = tempdir().unwrap();
936		fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
937		fs::write(
938			tmp.path().join("node_modules/foo/tsconfig.json"),
939			r#"{"compilerOptions": {"paths": {"!polluted/*": ["./*"]}}}"#,
940		)
941		.unwrap();
942		let r = load(tmp.path());
943		assert!(
944			r.aliases.iter().all(|a| a.pattern != "!polluted/*"),
945			"node_modules tsconfigs must not pollute aliases: {:?}",
946			r.aliases
947		);
948	}
949
950	#[test]
951	fn strip_jsonc_preserves_utf8_multibyte() {
952		let src = "{ \"k\": \"é à\" } // 中文";
953		let out = strip_jsonc(src);
954		assert!(out.contains("é à"), "UTF-8 multibyte preserved: {out:?}");
955	}
956}