1use std::collections::{HashMap, HashSet};
20use std::fs;
21use std::path::Path;
22
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25
26use crate::extract::TsFragment;
27use crate::graph::{Def, Edge, OrderedMap};
28
29const DEF_SEPARATOR: char = '#';
30const PROBE_EXTENSIONS: &[&str] = &[".ts", ".tsx", ".js", ".jsx"];
34const OUTPUT_EXTENSION_SOURCES: &[(&str, &[&str])] = &[
38 (".js", &[".ts", ".tsx"]),
39 (".jsx", &[".tsx"]),
40 (".mjs", &[".mts", ".ts"]),
41 (".cjs", &[".cts", ".ts"]),
42];
43const TSCONFIG_CHAIN_LIMIT: usize = 4;
44const TSCONFIG_ROOTS: &[&str] = &["tsconfig.json", "tsconfig.base.json"];
50const BARREL_HOPS: usize = 8;
64
65pub fn ts_def_id(file: &str, name: &str) -> String {
67 format!("{file}{DEF_SEPARATOR}{name}")
68}
69
70fn dir_of(rel: &str) -> &str {
71 match rel.rfind('/') {
72 None => "",
73 Some(i) => &rel[..i],
74 }
75}
76
77fn join_rel(base: &str, rest: &str) -> Option<String> {
80 let mut parts: Vec<&str> = Vec::new();
81 let joined = format!("{base}/{rest}");
82 for seg in joined.split('/') {
83 if seg.is_empty() || seg == "." {
84 continue;
85 }
86 if seg == ".." {
87 if parts.is_empty() {
88 return None;
89 }
90 parts.pop();
91 continue;
92 }
93 parts.push(seg);
94 }
95 Some(parts.join("/"))
96}
97
98fn extension_of(rel: &str) -> &str {
99 let slash = rel.rfind('/').map(|i| i as i64).unwrap_or(-1);
100 let dot = rel.rfind('.').map(|i| i as i64).unwrap_or(-1);
101 if dot > slash {
102 &rel[dot as usize..]
103 } else {
104 ""
105 }
106}
107
108fn probe_file(base: Option<&str>, file_set: &HashSet<&str>) -> Option<String> {
113 let base = base?;
114 if file_set.contains(base) {
115 return Some(base.to_string());
116 }
117 let ext = extension_of(base);
118 if let Some((_, sources)) = OUTPUT_EXTENSION_SOURCES.iter().find(|(e, _)| *e == ext) {
119 let stem = &base[..base.len() - ext.len()];
120 for s in *sources {
121 let candidate = format!("{stem}{s}");
122 if file_set.contains(candidate.as_str()) {
123 return Some(candidate);
124 }
125 }
126 }
127 for e in PROBE_EXTENSIONS {
128 let candidate = format!("{base}{e}");
129 if file_set.contains(candidate.as_str()) {
130 return Some(candidate);
131 }
132 }
133 for e in PROBE_EXTENSIONS {
134 let candidate = format!("{base}/index{e}");
135 if file_set.contains(candidate.as_str()) {
136 return Some(candidate);
137 }
138 }
139 None
140}
141
142fn strip_jsonc(text: &str) -> String {
149 let chars: Vec<char> = text.chars().collect();
150 let mut out = String::new();
151 let mut i = 0usize;
152 let mut in_string = false;
153 while i < chars.len() {
154 let ch = chars[i];
155 if in_string {
156 out.push(ch);
157 if ch == '\\' {
158 if let Some(next) = chars.get(i + 1) {
159 out.push(*next);
160 }
161 i += 2;
162 continue;
163 }
164 if ch == '"' {
165 in_string = false;
166 }
167 i += 1;
168 continue;
169 }
170 if ch == '"' {
171 in_string = true;
172 out.push(ch);
173 i += 1;
174 continue;
175 }
176 if ch == '/' && chars.get(i + 1) == Some(&'/') {
177 while i < chars.len() && chars[i] != '\n' {
178 i += 1;
179 }
180 continue;
181 }
182 if ch == '/' && chars.get(i + 1) == Some(&'*') {
183 i += 2;
184 while i < chars.len() && !(chars[i] == '*' && chars.get(i + 1) == Some(&'/')) {
185 i += 1;
186 }
187 i += 2;
188 continue;
189 }
190 out.push(ch);
191 i += 1;
192 }
193 drop_trailing_commas(&out)
194}
195
196fn drop_trailing_commas(text: &str) -> String {
201 let chars: Vec<char> = text.chars().collect();
202 let mut out = String::new();
203 let mut i = 0usize;
204 while i < chars.len() {
205 if chars[i] != ',' {
206 out.push(chars[i]);
207 i += 1;
208 continue;
209 }
210 let mut j = i + 1;
211 while j < chars.len() && chars[j].is_whitespace() {
212 j += 1;
213 }
214 if j < chars.len() && (chars[j] == '}' || chars[j] == ']') {
215 for &c in &chars[i + 1..=j] {
216 out.push(c);
217 }
218 i = j + 1;
219 continue;
220 }
221 out.push(',');
222 i += 1;
223 }
224 out
225}
226
227#[derive(Debug, Deserialize)]
232#[serde(untagged)]
233enum PathsField {
234 Map(OrderedMap<Value>),
235 Other(#[allow(dead_code)] Value),
236}
237
238#[derive(Debug, Default, Deserialize)]
239struct CompilerOptions {
240 #[serde(default, rename = "baseUrl")]
241 base_url: Option<Value>,
242 #[serde(default)]
243 paths: Option<PathsField>,
244}
245
246#[derive(Debug, Default, Deserialize)]
247struct TsConfigDoc {
248 #[serde(default)]
249 extends: Option<Value>,
250 #[serde(default, rename = "compilerOptions")]
251 compiler_options: Option<CompilerOptions>,
252}
253
254impl TsConfigDoc {
255 fn base_url(&self) -> Option<&str> {
256 self.compiler_options.as_ref()?.base_url.as_ref()?.as_str()
257 }
258
259 fn paths(&self) -> Option<&OrderedMap<Value>> {
262 match self.compiler_options.as_ref()?.paths.as_ref()? {
263 PathsField::Map(m) => Some(m),
264 PathsField::Other(_) => None,
265 }
266 }
267
268 fn declares_paths(&self) -> bool {
273 match self
274 .compiler_options
275 .as_ref()
276 .and_then(|o| o.paths.as_ref())
277 {
278 None => false,
279 Some(PathsField::Map(_)) => true,
280 Some(PathsField::Other(v)) => match v {
281 Value::Bool(b) => *b,
282 Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
283 Value::String(str_value) => !str_value.is_empty(),
284 Value::Null => false,
285 _ => true,
286 },
287 }
288 }
289}
290
291struct ChainLink {
292 dir: String,
293 cfg: TsConfigDoc,
294}
295
296fn read_tsconfig_chain(root: &Path, start: &str) -> Vec<ChainLink> {
297 let mut chain: Vec<ChainLink> = Vec::new();
298 let mut seen: HashSet<String> = HashSet::new();
299 let mut rel: Option<String> = Some(start.to_string());
300 for _ in 0..TSCONFIG_CHAIN_LIMIT {
301 let Some(current) = rel.clone() else { break };
302 if !seen.insert(current.clone()) {
303 break;
304 }
305 let path = root.join(¤t);
306 if !path.exists() {
307 break;
308 }
309 let Ok(text) = fs::read_to_string(&path) else {
310 break;
311 };
312 let stripped = strip_jsonc(&text);
313 let Ok(value) = serde_json::from_str::<Value>(&stripped) else {
317 break;
318 };
319 let cfg = if value.is_object() {
320 serde_json::from_str::<TsConfigDoc>(&stripped).unwrap_or_default()
321 } else {
322 TsConfigDoc::default()
323 };
324 let dir = dir_of(¤t).to_string();
325 rel = match cfg.extends.as_ref().and_then(Value::as_str) {
328 Some(ext) if ext.starts_with('.') => {
329 let spelled = if ext.ends_with(".json") {
330 ext.to_string()
331 } else {
332 format!("{ext}.json")
333 };
334 join_rel(&dir, &spelled)
335 }
336 _ => None,
337 };
338 chain.push(ChainLink { dir, cfg });
339 }
340 chain
341}
342
343#[derive(Debug, Clone)]
344struct AliasTarget {
345 prefix: Option<String>,
346 suffix: String,
347}
348
349#[derive(Debug, Clone)]
350struct Alias {
351 exact: Option<String>,
352 prefix: String,
353 suffix: String,
354 targets: Vec<AliasTarget>,
355 wildcard: bool,
356}
357
358#[derive(Debug, Clone, Default)]
359pub struct TsPathAliases {
361 aliases: Vec<Alias>,
362 base_url_dir: Option<String>,
363}
364
365#[derive(Debug, Clone, Default)]
366pub struct TsAliasScopes {
373 root: TsPathAliases,
374 nested: Vec<(String, TsPathAliases)>,
375}
376
377pub fn read_ts_path_aliases(root: &Path) -> TsPathAliases {
385 let mut chain: Vec<ChainLink> = Vec::new();
386 for start in TSCONFIG_ROOTS {
387 let candidate = read_tsconfig_chain(root, start);
388 if candidate.is_empty() {
389 continue;
390 }
391 let declares_paths = candidate.iter().any(|c| c.cfg.declares_paths());
392 if chain.is_empty() || declares_paths {
393 chain = candidate;
394 }
395 if declares_paths {
396 break;
397 }
398 }
399 aliases_from_chain(&chain)
400}
401
402fn aliases_from_chain(chain: &[ChainLink]) -> TsPathAliases {
407 let paths_from = chain.iter().find(|c| c.cfg.paths().is_some());
408 let base_url_from = chain.iter().find(|c| c.cfg.base_url().is_some());
409 let base_url_dir = base_url_from.and_then(|c| join_rel(&c.dir, c.cfg.base_url().unwrap_or("")));
410 let mut aliases: Vec<Alias> = Vec::new();
411 if let Some(pf) = paths_from {
412 let base: Option<String> = match pf.cfg.base_url() {
413 Some(own) => join_rel(&pf.dir, own),
414 None => base_url_dir.clone().or_else(|| Some(pf.dir.clone())),
415 };
416 for (pattern, targets) in pf.cfg.paths().expect("paths_from declares paths").iter() {
417 let Some(targets) = targets.as_array() else {
418 continue;
419 };
420 let resolved: Vec<AliasTarget> = targets
421 .iter()
422 .filter_map(Value::as_str)
423 .map(|t| match t.find('*') {
424 None => AliasTarget {
425 prefix: join_rel(base.as_deref().unwrap_or(""), t),
426 suffix: String::new(),
427 },
428 Some(star) => AliasTarget {
429 prefix: Some(
430 join_rel(base.as_deref().unwrap_or(""), &t[..star]).unwrap_or_default(),
431 ),
432 suffix: t[star + 1..].to_string(),
433 },
434 })
435 .collect();
436 aliases.push(match pattern.find('*') {
437 None => Alias {
438 exact: Some(pattern.clone()),
439 prefix: pattern.clone(),
440 suffix: String::new(),
441 targets: resolved,
442 wildcard: false,
443 },
444 Some(star) => Alias {
445 exact: None,
446 prefix: pattern[..star].to_string(),
447 suffix: pattern[star + 1..].to_string(),
448 targets: resolved,
449 wildcard: true,
450 },
451 });
452 }
453 }
454 aliases.sort_by(|a, b| {
458 b.prefix
459 .len()
460 .cmp(&a.prefix.len())
461 .then_with(|| a.prefix.cmp(&b.prefix))
462 });
463 TsPathAliases {
464 aliases,
465 base_url_dir,
466 }
467}
468
469fn owning_tsconfig_dir(
479 root: &Path,
480 dir: &str,
481 owner_cache: &mut HashMap<String, Option<String>>,
482 alias_cache: &mut HashMap<String, Option<TsPathAliases>>,
483) -> Option<String> {
484 if dir.is_empty() {
485 return None;
486 }
487 if let Some(hit) = owner_cache.get(dir) {
488 return hit.clone();
489 }
490 let own = alias_cache
491 .entry(dir.to_string())
492 .or_insert_with(|| {
493 let config = root.join(dir).join("tsconfig.json");
494 if !config.is_file() {
495 return None;
496 }
497 let chain = read_tsconfig_chain(root, &format!("{dir}/tsconfig.json"));
498 let aliases = aliases_from_chain(&chain);
499 let owns_something = !aliases.aliases.is_empty() || aliases.base_url_dir.is_some();
500 owns_something.then_some(aliases)
501 })
502 .is_some();
503 let answer = if own {
504 Some(dir.to_string())
505 } else {
506 owning_tsconfig_dir(root, dir_of(dir), owner_cache, alias_cache)
507 };
508 owner_cache.insert(dir.to_string(), answer.clone());
509 answer
510}
511
512pub fn read_ts_alias_scopes<'a>(
521 root: &Path,
522 files: impl IntoIterator<Item = &'a str>,
523) -> TsAliasScopes {
524 let root_aliases = read_ts_path_aliases(root);
525 let mut owner_cache: HashMap<String, Option<String>> = HashMap::new();
526 let mut alias_cache: HashMap<String, Option<TsPathAliases>> = HashMap::new();
527 let mut owners: HashSet<String> = HashSet::new();
528 for file in files {
529 if let Some(owner) =
530 owning_tsconfig_dir(root, dir_of(file), &mut owner_cache, &mut alias_cache)
531 {
532 owners.insert(owner);
533 }
534 }
535 let mut nested: Vec<(String, TsPathAliases)> = owners
536 .into_iter()
537 .filter_map(|dir| {
538 let aliases = alias_cache.remove(&dir).flatten()?;
539 Some((dir, aliases))
540 })
541 .collect();
542 nested.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
547 TsAliasScopes {
548 root: root_aliases,
549 nested,
550 }
551}
552
553fn scope_for<'a>(from_file: &str, scopes: &'a TsAliasScopes) -> &'a TsPathAliases {
558 for (dir, aliases) in &scopes.nested {
559 if from_file.len() > dir.len()
560 && from_file.starts_with(dir.as_str())
561 && from_file.as_bytes()[dir.len()] == b'/'
562 {
563 return aliases;
564 }
565 }
566 &scopes.root
567}
568
569fn alias_targets(spec: &str, aliases: &[Alias]) -> Vec<Option<String>> {
570 for a in aliases {
571 if !a.wildcard {
572 if a.exact.as_deref() == Some(spec) {
573 return a.targets.iter().map(|t| t.prefix.clone()).collect();
574 }
575 continue;
576 }
577 let Some(rest) = spec.strip_prefix(a.prefix.as_str()) else {
583 continue;
584 };
585 let Some(mid) = rest.strip_suffix(a.suffix.as_str()) else {
586 continue;
587 };
588 return a
589 .targets
590 .iter()
591 .map(|t| {
592 let rest = if t.suffix.is_empty() {
593 mid.to_string()
594 } else {
595 format!("{mid}{}", t.suffix)
596 };
597 t.prefix.as_deref().and_then(|p| join_rel(p, &rest))
598 })
599 .collect();
600 }
601 Vec::new()
602}
603
604pub fn resolve_specifier(
611 from_file: &str,
612 spec: &str,
613 file_set: &HashSet<&str>,
614 alias: &TsAliasScopes,
615) -> Option<String> {
616 if spec.starts_with('.') {
617 return probe_file(join_rel(dir_of(from_file), spec).as_deref(), file_set);
618 }
619 let scope = scope_for(from_file, alias);
620 for candidate in alias_targets(spec, &scope.aliases) {
621 if let Some(hit) = probe_file(candidate.as_deref(), file_set) {
622 return Some(hit);
623 }
624 }
625 if let Some(base) = &scope.base_url_dir {
626 if let Some(hit) = probe_file(join_rel(base, spec).as_deref(), file_set) {
627 return Some(hit);
628 }
629 }
630 None
631}
632
633#[derive(Debug, Clone, Default, PartialEq)]
636pub struct TsEdgeCounts {
637 pub import: usize,
639 pub call: usize,
641 pub jsx_use: usize,
643 pub dispatch: usize,
645}
646
647#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
651pub struct TsStats {
652 pub ts_file_count: usize,
654 pub ts_def_count: usize,
656 pub external_import_count: usize,
658 pub unresolved_ref_count: usize,
660}
661
662pub struct TsGraph {
664 pub defs: Vec<Def>,
666 pub edges: Vec<Edge>,
668 pub edges_by_kind: TsEdgeCounts,
670 pub stats: TsStats,
672}
673
674struct ResolvedReexport {
675 spec: String,
676 line: usize,
677 star: bool,
678 names: Vec<(String, String)>,
679 to: Option<String>,
680}
681
682#[allow(
684 clippy::too_many_lines,
685 reason = "one ordered pass building every def, export and edge table the resolution below reads from, all over the same fragment set"
686)]
687pub fn resolve_ts_graph(fragments: &[(String, TsFragment)], alias: &TsAliasScopes) -> TsGraph {
688 let file_set: HashSet<&str> = fragments.iter().map(|(f, _)| f.as_str()).collect();
689
690 let mut defs: Vec<Def> = Vec::new();
691 let mut exports_by_file: HashMap<&str, HashMap<String, usize>> = HashMap::new();
692 for (file, frag) in fragments {
693 let mut by_name: HashMap<String, usize> = HashMap::new();
694 for d in &frag.defs {
695 if by_name.contains_key(&d.name) {
696 continue;
697 }
698 by_name.insert(d.name.clone(), defs.len());
699 defs.push(Def {
700 id: ts_def_id(file, &d.name),
701 name: d.name.clone(),
702 namespace: dir_of(file).to_string(),
703 kind: d.kind.clone(),
704 file: file.clone(),
705 line: d.line,
706 methods: Vec::new(),
707 test_methods: Vec::new(),
708 also_in: Vec::new(),
709 end_line: d.end_line,
710 });
711 }
712 if let Some(dflt) = &frag.default {
716 if let Some(&i) = by_name.get(dflt) {
717 by_name.insert("default".to_string(), i);
718 }
719 }
720 exports_by_file.insert(file.as_str(), by_name);
721 }
722
723 let mut reexports_by_file: HashMap<&str, Vec<ResolvedReexport>> = HashMap::new();
724 for (file, frag) in fragments {
725 let rows = frag
726 .reexports
727 .iter()
728 .map(|rx| ResolvedReexport {
729 spec: rx.spec.clone(),
730 line: rx.line,
731 star: rx.star,
732 names: rx
733 .names
734 .iter()
735 .map(|n| (n.exported.clone(), n.imported.clone()))
736 .collect(),
737 to: resolve_specifier(file, &rx.spec, &file_set, alias),
738 })
739 .collect();
740 reexports_by_file.insert(file.as_str(), rows);
741 }
742
743 fn lookup_export(
754 file: &str,
755 name: &str,
756 hops: usize,
757 exports_by_file: &HashMap<&str, HashMap<String, usize>>,
758 reexports_by_file: &HashMap<&str, Vec<ResolvedReexport>>,
759 visited: &mut HashMap<(String, String), usize>,
760 ) -> Option<usize> {
761 let key = (file.to_string(), name.to_string());
762 if visited.get(&key).is_some_and(|&seen| seen <= hops) {
763 return None;
764 }
765 visited.insert(key, hops);
766 if let Some(own) = exports_by_file.get(file).and_then(|m| m.get(name)) {
771 return Some(*own);
772 }
773 if hops >= BARREL_HOPS {
774 return None;
775 }
776 for rx in reexports_by_file
777 .get(file)
778 .map(Vec::as_slice)
779 .unwrap_or(&[])
780 {
781 let Some(to) = &rx.to else { continue };
782 if rx.star {
783 if let Some(hit) = lookup_export(
784 to,
785 name,
786 hops + 1,
787 exports_by_file,
788 reexports_by_file,
789 visited,
790 ) {
791 return Some(hit);
792 }
793 continue;
794 }
795 for (exported, imported) in &rx.names {
799 if exported != name {
800 continue;
801 }
802 if let Some(hit) = lookup_export(
803 to,
804 imported,
805 hops + 1,
806 exports_by_file,
807 reexports_by_file,
808 visited,
809 ) {
810 return Some(hit);
811 }
812 }
813 }
814 None
815 }
816
817 let mut edges: Vec<Edge> = Vec::new();
818 let mut counts = TsEdgeCounts::default();
819 let mut external_imports = 0usize;
820 let mut unresolved_refs = 0usize;
821
822 for (file, frag) in fragments {
823 let mut bindings: HashMap<String, (String, String)> = HashMap::new();
824 for imp in &frag.imports {
825 let to = resolve_specifier(file, &imp.spec, &file_set, alias);
826 let Some(to) = to else {
827 external_imports += 1;
828 continue;
829 };
830 edges.push(Edge::Import {
831 from_file: file.clone(),
832 from_line: imp.line,
833 target: imp.spec.clone(),
834 to_file: to.clone(),
835 via: None,
836 });
837 counts.import += 1;
838 for b in &imp.bindings {
843 bindings
844 .entry(b.local.clone())
845 .or_insert((to.clone(), b.imported.clone()));
846 }
847 let mut followed: HashSet<String> = HashSet::new();
856 for b in &imp.bindings {
857 if b.imported == "*" {
858 continue;
859 }
860 let mut visited: HashMap<(String, String), usize> = HashMap::new();
861 let Some(target) = lookup_export(
862 &to,
863 &b.imported,
864 0,
865 &exports_by_file,
866 &reexports_by_file,
867 &mut visited,
868 ) else {
869 continue;
870 };
871 let target_file = defs[target].file.clone();
872 if target_file == to || followed.contains(&target_file) {
873 continue;
874 }
875 followed.insert(target_file.clone());
876 edges.push(Edge::Import {
877 from_file: file.clone(),
878 from_line: imp.line,
879 target: imp.spec.clone(),
880 to_file: target_file,
881 via: Some(to.clone()),
882 });
883 counts.import += 1;
884 }
885 }
886 for rx in reexports_by_file
887 .get(file.as_str())
888 .map(Vec::as_slice)
889 .unwrap_or(&[])
890 {
891 match &rx.to {
892 None => external_imports += 1,
893 Some(to) => {
894 edges.push(Edge::Import {
895 from_file: file.clone(),
896 from_line: rx.line,
897 target: rx.spec.clone(),
898 to_file: to.clone(),
899 via: None,
900 });
901 counts.import += 1;
902 }
903 }
904 }
905
906 for r in &frag.refs {
907 let target = match bindings.get(&r.name) {
908 Some((to, imported)) if imported == "*" => match &r.member {
912 Some(m) => {
913 let mut visited: HashMap<(String, String), usize> = HashMap::new();
914 lookup_export(to, m, 0, &exports_by_file, &reexports_by_file, &mut visited)
915 }
916 None => None,
917 },
918 Some((to, imported)) => {
919 let mut visited: HashMap<(String, String), usize> = HashMap::new();
920 lookup_export(
921 to,
922 imported,
923 0,
924 &exports_by_file,
925 &reexports_by_file,
926 &mut visited,
927 )
928 }
929 None if r.member.is_none() => exports_by_file
930 .get(file.as_str())
931 .and_then(|m| m.get(&r.name))
932 .copied(),
933 None => None,
934 };
935 let Some(target) = target else {
936 unresolved_refs += 1;
937 continue;
938 };
939 let to = defs[target].id.clone();
940 let to_file = defs[target].file.clone();
941 match r.kind.as_str() {
942 "call" => {
943 edges.push(Edge::Call {
944 from_file: file.clone(),
945 from_line: r.line,
946 to,
947 to_file,
948 });
949 counts.call += 1;
950 }
951 "jsx-use" => {
952 edges.push(Edge::JsxUse {
953 from_file: file.clone(),
954 from_line: r.line,
955 to,
956 to_file,
957 });
958 counts.jsx_use += 1;
959 }
960 "dispatch" => {
961 edges.push(Edge::Dispatch {
962 from_file: file.clone(),
963 from_line: r.line,
964 to,
965 to_file,
966 });
967 counts.dispatch += 1;
968 }
969 _ => unresolved_refs += 1,
974 }
975 }
976 }
977
978 let stats = TsStats {
979 ts_file_count: fragments.len(),
980 ts_def_count: defs.len(),
981 external_import_count: external_imports,
982 unresolved_ref_count: unresolved_refs,
983 };
984 TsGraph {
985 defs,
986 edges,
987 edges_by_kind: counts,
988 stats,
989 }
990}
991
992#[cfg(test)]
993mod tests {
994 use super::*;
995 use crate::extract::{TsBinding, TsFragmentDef, TsImport, TsReexport, TsReexportName, TsRef};
996 use std::sync::atomic::{AtomicU64, Ordering};
997
998 fn frag(
999 defs: Vec<(&str, &str, usize)>,
1000 imports: Vec<TsImport>,
1001 reexports: Vec<TsReexport>,
1002 refs: Vec<TsRef>,
1003 ) -> TsFragment {
1004 TsFragment {
1005 ts: 1,
1006 defs: defs
1007 .into_iter()
1008 .map(|(n, k, l)| TsFragmentDef {
1009 name: n.into(),
1010 kind: k.into(),
1011 line: l,
1012 end_line: l,
1013 })
1014 .collect(),
1015 imports,
1016 reexports,
1017 refs,
1018 default: None,
1019 }
1020 }
1021
1022 fn import(spec: &str, line: usize, bindings: &[(&str, &str)]) -> TsImport {
1023 TsImport {
1024 spec: spec.into(),
1025 line,
1026 bindings: bindings
1027 .iter()
1028 .map(|(l, i)| TsBinding {
1029 local: (*l).into(),
1030 imported: (*i).into(),
1031 })
1032 .collect(),
1033 }
1034 }
1035
1036 fn call_ref(name: &str, line: usize) -> TsRef {
1037 TsRef {
1038 kind: "call".into(),
1039 name: name.into(),
1040 member: None,
1041 line,
1042 }
1043 }
1044
1045 fn named_barrel(spec: &str, exported: &str) -> TsReexport {
1046 TsReexport {
1047 spec: spec.into(),
1048 line: 1,
1049 star: false,
1050 names: vec![TsReexportName {
1051 exported: exported.into(),
1052 imported: exported.into(),
1053 }],
1054 }
1055 }
1056
1057 fn star_barrel(spec: &str) -> TsReexport {
1058 TsReexport {
1059 spec: spec.into(),
1060 line: 1,
1061 star: true,
1062 names: vec![],
1063 }
1064 }
1065
1066 fn no_alias() -> TsAliasScopes {
1067 TsAliasScopes::default()
1068 }
1069
1070 struct TmpDir(std::path::PathBuf);
1075
1076 static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1077
1078 impl TmpDir {
1079 fn new(label: &str) -> Self {
1080 let n = TMP_COUNTER.fetch_add(1, Ordering::SeqCst);
1081 let dir = std::env::temp_dir().join(format!(
1082 "scout-tsgraph-test-{label}-{}-{n}",
1083 std::process::id()
1084 ));
1085 fs::create_dir_all(&dir).expect("create temp dir");
1086 TmpDir(dir)
1087 }
1088
1089 fn write(&self, rel: &str, contents: &str) {
1090 let path = self.0.join(rel);
1091 if let Some(parent) = path.parent() {
1092 fs::create_dir_all(parent).expect("create parent dirs");
1093 }
1094 fs::write(&path, contents).expect("write fixture file");
1095 }
1096 }
1097
1098 impl Drop for TmpDir {
1099 fn drop(&mut self) {
1100 let _ = fs::remove_dir_all(&self.0);
1101 }
1102 }
1103
1104 #[test]
1105 fn a_root_tsconfig_alias_resolves_a_bare_specifier() {
1106 let tmp = TmpDir::new("root-alias");
1107 tmp.write(
1108 "tsconfig.base.json",
1109 r#"{
1110 "compilerOptions": {
1111 "baseUrl": ".",
1112 "paths": { "@core/*": ["packages/core/src/*"] }
1113 }
1114}"#,
1115 );
1116 let files = ["apps/api/src/handler.ts"];
1117 let scopes = read_ts_alias_scopes(&tmp.0, files.iter().copied());
1118 let file_set: HashSet<&str> = [
1119 "apps/api/src/handler.ts",
1120 "packages/core/src/formatLabel.ts",
1121 ]
1122 .into_iter()
1123 .collect();
1124 assert_eq!(
1125 resolve_specifier(
1126 "apps/api/src/handler.ts",
1127 "@core/formatLabel",
1128 &file_set,
1129 &scopes
1130 ),
1131 Some("packages/core/src/formatLabel.ts".to_string())
1132 );
1133 }
1134
1135 #[test]
1136 fn a_nested_tsconfig_owns_the_files_beneath_it() {
1137 let tmp = TmpDir::new("nested-owns");
1138 tmp.write(
1139 "tsconfig.base.json",
1140 r#"{
1141 "compilerOptions": {
1142 "baseUrl": ".",
1143 "paths": { "@core/*": ["packages/core/src/*"] }
1144 }
1145}"#,
1146 );
1147 tmp.write(
1148 "apps/web/tsconfig.json",
1149 r#"{
1150 "extends": "../../tsconfig.base.json",
1151 "compilerOptions": {
1152 "baseUrl": ".",
1153 "paths": { "@/*": ["./src/*"] }
1154 }
1155}"#,
1156 );
1157 let files = ["apps/web/src/pages/Home.tsx", "apps/api/src/handler.ts"];
1158 let scopes = read_ts_alias_scopes(&tmp.0, files.iter().copied());
1159 let file_set: HashSet<&str> = [
1160 "apps/web/src/pages/Home.tsx",
1161 "apps/api/src/handler.ts",
1162 "packages/core/src/formatLabel.ts",
1163 "apps/web/src/components/Button.tsx",
1164 ]
1165 .into_iter()
1166 .collect();
1167
1168 assert_eq!(
1169 resolve_specifier(
1170 "apps/web/src/pages/Home.tsx",
1171 "@/components/Button",
1172 &file_set,
1173 &scopes
1174 ),
1175 Some("apps/web/src/components/Button.tsx".to_string()),
1176 "the nested config's own @/ alias resolves"
1177 );
1178 assert_eq!(
1179 resolve_specifier(
1180 "apps/web/src/pages/Home.tsx",
1181 "@core/formatLabel",
1182 &file_set,
1183 &scopes
1184 ),
1185 None,
1186 "the nested paths override is whole-property: it turns off the root's @core alias too"
1187 );
1188 assert_eq!(
1189 resolve_specifier(
1190 "apps/api/src/handler.ts",
1191 "@core/formatLabel",
1192 &file_set,
1193 &scopes
1194 ),
1195 Some("packages/core/src/formatLabel.ts".to_string()),
1196 "a file with no nested owner still resolves through the root chain"
1197 );
1198 }
1199
1200 #[test]
1201 fn a_nested_paths_entry_shadows_the_root_entry_with_the_same_pattern() {
1202 let tmp = TmpDir::new("shadow");
1203 tmp.write(
1204 "tsconfig.base.json",
1205 r#"{
1206 "compilerOptions": {
1207 "baseUrl": ".",
1208 "paths": { "@shared/*": ["packages/shared/src/*"] }
1209 }
1210}"#,
1211 );
1212 tmp.write(
1213 "apps/web/tsconfig.json",
1214 r#"{
1215 "extends": "../../tsconfig.base.json",
1216 "compilerOptions": {
1217 "baseUrl": ".",
1218 "paths": { "@shared/*": ["./vendor/shared/*"] }
1219 }
1220}"#,
1221 );
1222 let files = ["apps/web/src/index.ts", "apps/other/src/index.ts"];
1223 let scopes = read_ts_alias_scopes(&tmp.0, files.iter().copied());
1224 let file_set: HashSet<&str> = [
1225 "apps/web/src/index.ts",
1226 "apps/other/src/index.ts",
1227 "packages/shared/src/thing.ts",
1228 "apps/web/vendor/shared/thing.ts",
1229 ]
1230 .into_iter()
1231 .collect();
1232
1233 assert_eq!(
1234 resolve_specifier("apps/web/src/index.ts", "@shared/thing", &file_set, &scopes),
1235 Some("apps/web/vendor/shared/thing.ts".to_string()),
1236 "the file under the nested config resolves to the nested target"
1237 );
1238 assert_eq!(
1239 resolve_specifier(
1240 "apps/other/src/index.ts",
1241 "@shared/thing",
1242 &file_set,
1243 &scopes
1244 ),
1245 Some("packages/shared/src/thing.ts".to_string()),
1246 "a file outside the nested config still resolves to the root target"
1247 );
1248 }
1249
1250 #[test]
1251 fn a_nested_tsconfig_without_paths_or_base_url_falls_back_to_the_root_scope() {
1252 let tmp = TmpDir::new("silent-nested");
1253 tmp.write(
1254 "tsconfig.base.json",
1255 r#"{
1256 "compilerOptions": {
1257 "baseUrl": ".",
1258 "paths": { "@core/*": ["packages/core/src/*"] }
1259 }
1260}"#,
1261 );
1262 tmp.write(
1263 "apps/web/tsconfig.json",
1264 r#"{
1265 "compilerOptions": { "strict": true }
1266}"#,
1267 );
1268 let files = ["apps/web/src/index.ts"];
1269 let scopes = read_ts_alias_scopes(&tmp.0, files.iter().copied());
1270 let file_set: HashSet<&str> = ["apps/web/src/index.ts", "packages/core/src/formatLabel.ts"]
1271 .into_iter()
1272 .collect();
1273
1274 assert_eq!(
1275 resolve_specifier(
1276 "apps/web/src/index.ts",
1277 "@core/formatLabel",
1278 &file_set,
1279 &scopes
1280 ),
1281 Some("packages/core/src/formatLabel.ts".to_string()),
1282 "a nested config declaring neither paths nor baseUrl does not switch the root alias off"
1283 );
1284 }
1285
1286 #[test]
1287 fn a_silent_nested_tsconfig_is_stepped_over_to_the_next_owner_above_it() {
1288 let tmp = TmpDir::new("stepped-over");
1293 tmp.write(
1294 "apps/tsconfig.json",
1295 r#"{
1296 "compilerOptions": {
1297 "baseUrl": ".",
1298 "paths": { "@app/*": ["shared/*"] }
1299 }
1300}"#,
1301 );
1302 tmp.write(
1303 "apps/web/tsconfig.json",
1304 r#"{
1305 "compilerOptions": { "strict": true }
1306}"#,
1307 );
1308 let file_set: HashSet<&str> = [
1309 "apps/web/src/x.ts",
1310 "apps/tool/y.ts",
1311 "apps/shared/label.ts",
1312 ]
1313 .into_iter()
1314 .collect();
1315 let alone = read_ts_alias_scopes(&tmp.0, ["apps/web/src/x.ts"]);
1316 let with_sibling = read_ts_alias_scopes(&tmp.0, ["apps/web/src/x.ts", "apps/tool/y.ts"]);
1317 for scopes in [&alone, &with_sibling] {
1318 assert_eq!(
1319 resolve_specifier("apps/web/src/x.ts", "@app/label", &file_set, scopes),
1320 Some("apps/shared/label.ts".to_string()),
1321 "the next owner above a silent config answers, sibling or no sibling"
1322 );
1323 }
1324 }
1325
1326 #[test]
1327 fn nested_scopes_are_ordered_longest_directory_first() {
1328 let tmp = TmpDir::new("nested-order");
1329 tmp.write(
1330 "apps/web/tsconfig.json",
1331 r#"{
1332 "compilerOptions": {
1333 "baseUrl": ".",
1334 "paths": { "@/*": ["./from-web/*"] }
1335 }
1336}"#,
1337 );
1338 tmp.write(
1339 "apps/web/admin/tsconfig.json",
1340 r#"{
1341 "compilerOptions": {
1342 "baseUrl": ".",
1343 "paths": { "@/*": ["./from-admin/*"] }
1344 }
1345}"#,
1346 );
1347 let files = ["apps/web/other/file.ts", "apps/web/admin/src/page.ts"];
1348 let scopes = read_ts_alias_scopes(&tmp.0, files.iter().copied());
1349 let file_set: HashSet<&str> = [
1350 "apps/web/other/file.ts",
1351 "apps/web/admin/src/page.ts",
1352 "apps/web/from-web/thing.ts",
1353 "apps/web/admin/from-admin/thing.ts",
1354 ]
1355 .into_iter()
1356 .collect();
1357
1358 assert_eq!(
1359 resolve_specifier("apps/web/admin/src/page.ts", "@/thing", &file_set, &scopes),
1360 Some("apps/web/admin/from-admin/thing.ts".to_string()),
1361 "the deeper nested config owns files beneath it, not the shallower one"
1362 );
1363 assert_eq!(
1364 resolve_specifier("apps/web/other/file.ts", "@/thing", &file_set, &scopes),
1365 Some("apps/web/from-web/thing.ts".to_string()),
1366 "a file outside the deeper config still resolves through the shallower one"
1367 );
1368 }
1369
1370 #[test]
1371 fn join_rel_refuses_to_climb_above_the_repo_root() {
1372 assert_eq!(
1373 join_rel("src", "../util/format"),
1374 Some("util/format".to_string())
1375 );
1376 assert_eq!(join_rel("", "../escape"), None);
1377 }
1378
1379 #[test]
1380 fn probe_order_prefers_a_typescript_source_over_a_built_js_sibling() {
1381 let files: HashSet<&str> = ["src/x.ts", "src/x.js"].into_iter().collect();
1382 assert_eq!(
1383 probe_file(Some("src/x"), &files),
1384 Some("src/x.ts".to_string())
1385 );
1386 }
1387
1388 #[test]
1389 fn an_esm_js_specifier_resolves_to_the_ts_source_it_compiles_from() {
1390 let files: HashSet<&str> = ["src/x.ts"].into_iter().collect();
1391 assert_eq!(
1392 probe_file(Some("src/x.js"), &files),
1393 Some("src/x.ts".to_string())
1394 );
1395 }
1396
1397 #[test]
1398 fn a_directory_specifier_resolves_through_its_index_file() {
1399 let files: HashSet<&str> = ["src/hooks/index.ts"].into_iter().collect();
1400 assert_eq!(
1401 probe_file(Some("src/hooks"), &files),
1402 Some("src/hooks/index.ts".to_string())
1403 );
1404 }
1405
1406 #[test]
1407 fn strip_jsonc_drops_comments_and_one_trailing_comma_per_closer() {
1408 let text = "{\n // a\n \"a\": 1, /* b */\n \"b\": [1, 2,],\n}";
1409 let value: Value = serde_json::from_str(&strip_jsonc(text)).expect("parses");
1410 assert_eq!(value["a"], serde_json::json!(1));
1411 assert_eq!(value["b"], serde_json::json!([1, 2]));
1412 }
1413
1414 #[test]
1415 fn a_relative_import_becomes_one_import_edge_and_a_resolved_call() {
1416 let fragments = vec![
1417 (
1418 "src/a.ts".to_string(),
1419 frag(
1420 vec![],
1421 vec![import("./b", 1, &[("go", "go")])],
1422 vec![],
1423 vec![call_ref("go", 3)],
1424 ),
1425 ),
1426 (
1427 "src/b.ts".to_string(),
1428 frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
1429 ),
1430 ];
1431 let g = resolve_ts_graph(&fragments, &no_alias());
1432 assert_eq!(
1433 g.edges_by_kind,
1434 TsEdgeCounts {
1435 import: 1,
1436 call: 1,
1437 jsx_use: 0,
1438 dispatch: 0
1439 }
1440 );
1441 assert_eq!(g.stats.ts_def_count, 1);
1442 assert_eq!(g.stats.unresolved_ref_count, 0);
1443 }
1444
1445 #[test]
1446 fn an_unresolvable_specifier_is_external_and_never_an_edge() {
1447 let fragments = vec![(
1448 "src/a.ts".to_string(),
1449 frag(
1450 vec![],
1451 vec![import("./nowhere", 1, &[("go", "go")])],
1452 vec![],
1453 vec![call_ref("go", 3)],
1454 ),
1455 )];
1456 let g = resolve_ts_graph(&fragments, &no_alias());
1457 assert_eq!(g.edges.len(), 0);
1458 assert_eq!(g.stats.external_import_count, 1);
1459 assert_eq!(g.stats.unresolved_ref_count, 1);
1460 }
1461
1462 #[test]
1463 fn chained_barrels_resolve_to_the_declaring_file_within_the_hop_cap() {
1464 let fragments = vec![
1465 (
1466 "src/a.ts".to_string(),
1467 frag(
1468 vec![],
1469 vec![import("./b", 1, &[("go", "go")])],
1470 vec![],
1471 vec![call_ref("go", 3)],
1472 ),
1473 ),
1474 (
1475 "src/b.ts".to_string(),
1476 frag(vec![], vec![], vec![named_barrel("./c", "go")], vec![]),
1477 ),
1478 (
1479 "src/c.ts".to_string(),
1480 frag(vec![], vec![], vec![star_barrel("./d")], vec![]),
1481 ),
1482 (
1483 "src/d.ts".to_string(),
1484 frag(vec![], vec![], vec![named_barrel("./e", "go")], vec![]),
1485 ),
1486 (
1487 "src/e.ts".to_string(),
1488 frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
1489 ),
1490 ];
1491 let g = resolve_ts_graph(&fragments, &no_alias());
1492 assert!(matches!(
1493 g.edges.iter().find(|e| matches!(
1494 e,
1495 Edge::Import { via: Some(v), .. } if v == "src/b.ts"
1496 )),
1497 Some(Edge::Import { to_file, .. }) if to_file == "src/e.ts"
1498 ));
1499 assert!(matches!(
1500 g.edges.iter().find(|e| matches!(e, Edge::Call { .. })),
1501 Some(Edge::Call { to_file, .. }) if to_file == "src/e.ts"
1502 ));
1503
1504 let mut deep = vec![(
1508 "src/deep_a.ts".to_string(),
1509 frag(
1510 vec![],
1511 vec![import("./deep0", 1, &[("go", "go")])],
1512 vec![],
1513 vec![],
1514 ),
1515 )];
1516 for i in 0..=BARREL_HOPS {
1517 let next = if i == BARREL_HOPS {
1518 "deep_decl".to_string()
1519 } else {
1520 format!("deep{}", i + 1)
1521 };
1522 deep.push((
1523 format!("src/deep{i}.ts"),
1524 frag(
1525 vec![],
1526 vec![],
1527 vec![named_barrel(&format!("./{next}"), "go")],
1528 vec![],
1529 ),
1530 ));
1531 }
1532 deep.push((
1533 "src/deep_decl.ts".to_string(),
1534 frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
1535 ));
1536 let g2 = resolve_ts_graph(&deep, &no_alias());
1537 assert!(
1538 !g2.edges
1539 .iter()
1540 .any(|e| matches!(e, Edge::Import { via: Some(_), .. })),
1541 "a chain one barrel past the hop cap resolves to nothing"
1542 );
1543 }
1544
1545 #[test]
1546 fn a_cyclic_star_reexport_pair_terminates_and_resolves_nothing_for_an_absent_name() {
1547 let fragments = vec![
1548 (
1549 "src/a.ts".to_string(),
1550 frag(
1551 vec![],
1552 vec![import("./b", 1, &[("missing", "missing")])],
1553 vec![],
1554 vec![call_ref("missing", 3)],
1555 ),
1556 ),
1557 (
1558 "src/b.ts".to_string(),
1559 frag(vec![], vec![], vec![star_barrel("./c")], vec![]),
1560 ),
1561 (
1562 "src/c.ts".to_string(),
1563 frag(vec![], vec![], vec![star_barrel("./b")], vec![]),
1564 ),
1565 ];
1566 let g = resolve_ts_graph(&fragments, &no_alias());
1570 assert!(
1571 !g.edges
1572 .iter()
1573 .any(|e| matches!(e, Edge::Import { via: Some(_), .. })),
1574 "a name absent from a cyclic star-reexport pair carries no via edge"
1575 );
1576 assert!(
1577 !g.edges.iter().any(|e| matches!(e, Edge::Call { .. })),
1578 "a name absent from a cyclic star-reexport pair resolves no call"
1579 );
1580 }
1581
1582 #[test]
1583 fn a_cycle_still_resolves_a_name_one_of_its_members_declares() {
1584 let fragments = vec![
1585 (
1586 "src/a.ts".to_string(),
1587 frag(
1588 vec![],
1589 vec![import("./b", 1, &[("go", "go")])],
1590 vec![],
1591 vec![call_ref("go", 3)],
1592 ),
1593 ),
1594 (
1595 "src/b.ts".to_string(),
1596 frag(vec![], vec![], vec![star_barrel("./c")], vec![]),
1597 ),
1598 (
1599 "src/c.ts".to_string(),
1600 frag(
1601 vec![("go", "function", 1)],
1602 vec![],
1603 vec![star_barrel("./b")],
1604 vec![],
1605 ),
1606 ),
1607 ];
1608 let g = resolve_ts_graph(&fragments, &no_alias());
1609 assert!(matches!(
1610 g.edges.iter().find(|e| matches!(e, Edge::Import { via: Some(_), .. })),
1611 Some(Edge::Import { to_file, .. }) if to_file == "src/c.ts"
1612 ));
1613 assert!(matches!(
1614 g.edges.iter().find(|e| matches!(e, Edge::Call { .. })),
1615 Some(Edge::Call { to_file, .. }) if to_file == "src/c.ts"
1616 ));
1617 }
1618
1619 #[test]
1620 fn a_pair_first_cut_by_the_cap_is_walked_again_from_a_shorter_path() {
1621 let long_links = BARREL_HOPS - 1;
1627 let mut fragments = vec![
1628 (
1629 "src/a.ts".to_string(),
1630 frag(
1631 vec![],
1632 vec![import("./b", 1, &[("go", "go")])],
1633 vec![],
1634 vec![call_ref("go", 3)],
1635 ),
1636 ),
1637 (
1638 "src/b.ts".to_string(),
1639 frag(
1640 vec![],
1641 vec![],
1642 vec![star_barrel("./long1"), star_barrel("./short")],
1643 vec![],
1644 ),
1645 ),
1646 (
1647 "src/short.ts".to_string(),
1648 frag(vec![], vec![], vec![star_barrel("./deep")], vec![]),
1649 ),
1650 (
1651 "src/deep.ts".to_string(),
1652 frag(vec![], vec![], vec![star_barrel("./x")], vec![]),
1653 ),
1654 (
1655 "src/x.ts".to_string(),
1656 frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
1657 ),
1658 ];
1659 for i in 1..=long_links {
1660 let next = if i == long_links {
1661 "./deep".to_string()
1662 } else {
1663 format!("./long{}", i + 1)
1664 };
1665 fragments.push((
1666 format!("src/long{i}.ts"),
1667 frag(vec![], vec![], vec![star_barrel(&next)], vec![]),
1668 ));
1669 }
1670 let g = resolve_ts_graph(&fragments, &no_alias());
1671 assert!(
1672 matches!(
1673 g.edges.iter().find(|e| matches!(e, Edge::Call { .. })),
1674 Some(Edge::Call { to_file, .. }) if to_file == "src/x.ts"
1675 ),
1676 "the short chain must still reach src/x.ts: {:?}",
1677 g.edges
1678 );
1679 }
1680
1681 #[test]
1682 fn a_namespace_binding_resolves_only_through_a_member() {
1683 let fragments = vec![
1684 (
1685 "src/a.ts".to_string(),
1686 frag(
1687 vec![],
1688 vec![import("./b", 1, &[("ns", "*")])],
1689 vec![],
1690 vec![
1691 TsRef {
1692 kind: "call".into(),
1693 name: "ns".into(),
1694 member: Some("go".into()),
1695 line: 3,
1696 },
1697 call_ref("ns", 4),
1698 ],
1699 ),
1700 ),
1701 (
1702 "src/b.ts".to_string(),
1703 frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
1704 ),
1705 ];
1706 let g = resolve_ts_graph(&fragments, &no_alias());
1707 assert_eq!(g.edges_by_kind.call, 1);
1708 assert_eq!(g.stats.unresolved_ref_count, 1);
1709 }
1710}