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(®ex::escape(&character.to_string()));
523 index += 1;
524 }
525 }
526 }
527 regex.push('$');
528 Regex::new(®ex).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}