1use std::collections::{HashMap, HashSet};
14use std::fs;
15use std::path::Path;
16
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20use crate::extract::TsFragment;
21use crate::graph::{Def, Edge, OrderedMap};
22
23const DEF_SEPARATOR: char = '#';
24const PROBE_EXTENSIONS: &[&str] = &[".ts", ".tsx", ".js", ".jsx"];
28const OUTPUT_EXTENSION_SOURCES: &[(&str, &[&str])] = &[
32 (".js", &[".ts", ".tsx"]),
33 (".jsx", &[".tsx"]),
34 (".mjs", &[".mts", ".ts"]),
35 (".cjs", &[".cts", ".ts"]),
36];
37const TSCONFIG_CHAIN_LIMIT: usize = 4;
38const TSCONFIG_ROOTS: &[&str] = &["tsconfig.json", "tsconfig.base.json"];
44const BARREL_HOPS: usize = 1;
49
50pub fn ts_def_id(file: &str, name: &str) -> String {
52 format!("{file}{DEF_SEPARATOR}{name}")
53}
54
55fn dir_of(rel: &str) -> &str {
56 match rel.rfind('/') {
57 None => "",
58 Some(i) => &rel[..i],
59 }
60}
61
62fn join_rel(base: &str, rest: &str) -> Option<String> {
65 let mut parts: Vec<&str> = Vec::new();
66 let joined = format!("{base}/{rest}");
67 for seg in joined.split('/') {
68 if seg.is_empty() || seg == "." {
69 continue;
70 }
71 if seg == ".." {
72 if parts.is_empty() {
73 return None;
74 }
75 parts.pop();
76 continue;
77 }
78 parts.push(seg);
79 }
80 Some(parts.join("/"))
81}
82
83fn extension_of(rel: &str) -> &str {
84 let slash = rel.rfind('/').map(|i| i as i64).unwrap_or(-1);
85 let dot = rel.rfind('.').map(|i| i as i64).unwrap_or(-1);
86 if dot > slash {
87 &rel[dot as usize..]
88 } else {
89 ""
90 }
91}
92
93fn probe_file(base: Option<&str>, file_set: &HashSet<&str>) -> Option<String> {
98 let base = base?;
99 if file_set.contains(base) {
100 return Some(base.to_string());
101 }
102 let ext = extension_of(base);
103 if let Some((_, sources)) = OUTPUT_EXTENSION_SOURCES.iter().find(|(e, _)| *e == ext) {
104 let stem = &base[..base.len() - ext.len()];
105 for s in *sources {
106 let candidate = format!("{stem}{s}");
107 if file_set.contains(candidate.as_str()) {
108 return Some(candidate);
109 }
110 }
111 }
112 for e in PROBE_EXTENSIONS {
113 let candidate = format!("{base}{e}");
114 if file_set.contains(candidate.as_str()) {
115 return Some(candidate);
116 }
117 }
118 for e in PROBE_EXTENSIONS {
119 let candidate = format!("{base}/index{e}");
120 if file_set.contains(candidate.as_str()) {
121 return Some(candidate);
122 }
123 }
124 None
125}
126
127fn strip_jsonc(text: &str) -> String {
134 let chars: Vec<char> = text.chars().collect();
135 let mut out = String::new();
136 let mut i = 0usize;
137 let mut in_string = false;
138 while i < chars.len() {
139 let ch = chars[i];
140 if in_string {
141 out.push(ch);
142 if ch == '\\' {
143 if let Some(next) = chars.get(i + 1) {
144 out.push(*next);
145 }
146 i += 2;
147 continue;
148 }
149 if ch == '"' {
150 in_string = false;
151 }
152 i += 1;
153 continue;
154 }
155 if ch == '"' {
156 in_string = true;
157 out.push(ch);
158 i += 1;
159 continue;
160 }
161 if ch == '/' && chars.get(i + 1) == Some(&'/') {
162 while i < chars.len() && chars[i] != '\n' {
163 i += 1;
164 }
165 continue;
166 }
167 if ch == '/' && chars.get(i + 1) == Some(&'*') {
168 i += 2;
169 while i < chars.len() && !(chars[i] == '*' && chars.get(i + 1) == Some(&'/')) {
170 i += 1;
171 }
172 i += 2;
173 continue;
174 }
175 out.push(ch);
176 i += 1;
177 }
178 drop_trailing_commas(&out)
179}
180
181fn drop_trailing_commas(text: &str) -> String {
186 let chars: Vec<char> = text.chars().collect();
187 let mut out = String::new();
188 let mut i = 0usize;
189 while i < chars.len() {
190 if chars[i] != ',' {
191 out.push(chars[i]);
192 i += 1;
193 continue;
194 }
195 let mut j = i + 1;
196 while j < chars.len() && chars[j].is_whitespace() {
197 j += 1;
198 }
199 if j < chars.len() && (chars[j] == '}' || chars[j] == ']') {
200 for &c in &chars[i + 1..=j] {
201 out.push(c);
202 }
203 i = j + 1;
204 continue;
205 }
206 out.push(',');
207 i += 1;
208 }
209 out
210}
211
212#[derive(Debug, Deserialize)]
217#[serde(untagged)]
218enum PathsField {
219 Map(OrderedMap<Value>),
220 Other(#[allow(dead_code)] Value),
221}
222
223#[derive(Debug, Default, Deserialize)]
224struct CompilerOptions {
225 #[serde(default, rename = "baseUrl")]
226 base_url: Option<Value>,
227 #[serde(default)]
228 paths: Option<PathsField>,
229}
230
231#[derive(Debug, Default, Deserialize)]
232struct TsConfigDoc {
233 #[serde(default)]
234 extends: Option<Value>,
235 #[serde(default, rename = "compilerOptions")]
236 compiler_options: Option<CompilerOptions>,
237}
238
239impl TsConfigDoc {
240 fn base_url(&self) -> Option<&str> {
241 self.compiler_options.as_ref()?.base_url.as_ref()?.as_str()
242 }
243
244 fn paths(&self) -> Option<&OrderedMap<Value>> {
247 match self.compiler_options.as_ref()?.paths.as_ref()? {
248 PathsField::Map(m) => Some(m),
249 PathsField::Other(_) => None,
250 }
251 }
252
253 fn declares_paths(&self) -> bool {
258 match self
259 .compiler_options
260 .as_ref()
261 .and_then(|o| o.paths.as_ref())
262 {
263 None => false,
264 Some(PathsField::Map(_)) => true,
265 Some(PathsField::Other(v)) => match v {
266 Value::Bool(b) => *b,
267 Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
268 Value::String(str_value) => !str_value.is_empty(),
269 Value::Null => false,
270 _ => true,
271 },
272 }
273 }
274}
275
276struct ChainLink {
277 dir: String,
278 cfg: TsConfigDoc,
279}
280
281fn read_tsconfig_chain(root: &Path, start: &str) -> Vec<ChainLink> {
282 let mut chain: Vec<ChainLink> = Vec::new();
283 let mut seen: HashSet<String> = HashSet::new();
284 let mut rel: Option<String> = Some(start.to_string());
285 for _ in 0..TSCONFIG_CHAIN_LIMIT {
286 let Some(current) = rel.clone() else { break };
287 if !seen.insert(current.clone()) {
288 break;
289 }
290 let path = root.join(¤t);
291 if !path.exists() {
292 break;
293 }
294 let Ok(text) = fs::read_to_string(&path) else {
295 break;
296 };
297 let stripped = strip_jsonc(&text);
298 let Ok(value) = serde_json::from_str::<Value>(&stripped) else {
302 break;
303 };
304 let cfg = if value.is_object() {
305 serde_json::from_str::<TsConfigDoc>(&stripped).unwrap_or_default()
306 } else {
307 TsConfigDoc::default()
308 };
309 let dir = dir_of(¤t).to_string();
310 rel = match cfg.extends.as_ref().and_then(Value::as_str) {
313 Some(ext) if ext.starts_with('.') => {
314 let spelled = if ext.ends_with(".json") {
315 ext.to_string()
316 } else {
317 format!("{ext}.json")
318 };
319 join_rel(&dir, &spelled)
320 }
321 _ => None,
322 };
323 chain.push(ChainLink { dir, cfg });
324 }
325 chain
326}
327
328#[derive(Debug, Clone)]
329struct AliasTarget {
330 prefix: Option<String>,
331 suffix: String,
332}
333
334#[derive(Debug, Clone)]
335struct Alias {
336 exact: Option<String>,
337 prefix: String,
338 suffix: String,
339 targets: Vec<AliasTarget>,
340 wildcard: bool,
341}
342
343#[derive(Debug, Clone, Default)]
344pub struct TsPathAliases {
346 aliases: Vec<Alias>,
347 base_url_dir: Option<String>,
348}
349
350pub fn read_ts_path_aliases(root: &Path) -> TsPathAliases {
356 let mut chain: Vec<ChainLink> = Vec::new();
357 for start in TSCONFIG_ROOTS {
358 let candidate = read_tsconfig_chain(root, start);
359 if candidate.is_empty() {
360 continue;
361 }
362 let declares_paths = candidate.iter().any(|c| c.cfg.declares_paths());
363 if chain.is_empty() {
364 chain = candidate;
365 } else if declares_paths {
366 chain = candidate;
367 }
368 if declares_paths {
369 break;
370 }
371 }
372 let paths_from = chain.iter().find(|c| c.cfg.paths().is_some());
373 let base_url_from = chain.iter().find(|c| c.cfg.base_url().is_some());
374 let base_url_dir = base_url_from.and_then(|c| join_rel(&c.dir, c.cfg.base_url().unwrap_or("")));
375 let mut aliases: Vec<Alias> = Vec::new();
376 if let Some(pf) = paths_from {
377 let base: Option<String> = match pf.cfg.base_url() {
378 Some(own) => join_rel(&pf.dir, own),
379 None => base_url_dir.clone().or_else(|| Some(pf.dir.clone())),
380 };
381 for (pattern, targets) in pf.cfg.paths().expect("paths_from declares paths").iter() {
382 let Some(targets) = targets.as_array() else {
383 continue;
384 };
385 let resolved: Vec<AliasTarget> = targets
386 .iter()
387 .filter_map(Value::as_str)
388 .map(|t| match t.find('*') {
389 None => AliasTarget {
390 prefix: join_rel(base.as_deref().unwrap_or(""), t),
391 suffix: String::new(),
392 },
393 Some(star) => AliasTarget {
394 prefix: Some(
395 join_rel(base.as_deref().unwrap_or(""), &t[..star]).unwrap_or_default(),
396 ),
397 suffix: t[star + 1..].to_string(),
398 },
399 })
400 .collect();
401 aliases.push(match pattern.find('*') {
402 None => Alias {
403 exact: Some(pattern.clone()),
404 prefix: pattern.clone(),
405 suffix: String::new(),
406 targets: resolved,
407 wildcard: false,
408 },
409 Some(star) => Alias {
410 exact: None,
411 prefix: pattern[..star].to_string(),
412 suffix: pattern[star + 1..].to_string(),
413 targets: resolved,
414 wildcard: true,
415 },
416 });
417 }
418 }
419 aliases.sort_by(|a, b| {
423 b.prefix
424 .len()
425 .cmp(&a.prefix.len())
426 .then_with(|| a.prefix.cmp(&b.prefix))
427 });
428 TsPathAliases {
429 aliases,
430 base_url_dir,
431 }
432}
433
434fn alias_targets(spec: &str, aliases: &[Alias]) -> Vec<Option<String>> {
435 for a in aliases {
436 if !a.wildcard {
437 if a.exact.as_deref() == Some(spec) {
438 return a.targets.iter().map(|t| t.prefix.clone()).collect();
439 }
440 continue;
441 }
442 let Some(rest) = spec.strip_prefix(a.prefix.as_str()) else {
448 continue;
449 };
450 let Some(mid) = rest.strip_suffix(a.suffix.as_str()) else {
451 continue;
452 };
453 return a
454 .targets
455 .iter()
456 .map(|t| {
457 let rest = if t.suffix.is_empty() {
458 mid.to_string()
459 } else {
460 format!("{mid}{}", t.suffix)
461 };
462 t.prefix.as_deref().and_then(|p| join_rel(p, &rest))
463 })
464 .collect();
465 }
466 Vec::new()
467}
468
469pub fn resolve_specifier(
474 from_file: &str,
475 spec: &str,
476 file_set: &HashSet<&str>,
477 alias: &TsPathAliases,
478) -> Option<String> {
479 if spec.starts_with('.') {
480 return probe_file(join_rel(dir_of(from_file), spec).as_deref(), file_set);
481 }
482 for candidate in alias_targets(spec, &alias.aliases) {
483 if let Some(hit) = probe_file(candidate.as_deref(), file_set) {
484 return Some(hit);
485 }
486 }
487 if let Some(base) = &alias.base_url_dir {
488 if let Some(hit) = probe_file(join_rel(base, spec).as_deref(), file_set) {
489 return Some(hit);
490 }
491 }
492 None
493}
494
495#[derive(Debug, Clone, Default, PartialEq)]
498pub struct TsEdgeCounts {
499 pub import: usize,
501 pub call: usize,
503 pub jsx_use: usize,
505 pub dispatch: usize,
507}
508
509#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
512pub struct TsStats {
513 pub ts_file_count: usize,
515 pub ts_def_count: usize,
517 pub external_import_count: usize,
519 pub unresolved_ref_count: usize,
521}
522
523pub struct TsGraph {
525 pub defs: Vec<Def>,
527 pub edges: Vec<Edge>,
529 pub edges_by_kind: TsEdgeCounts,
531 pub stats: TsStats,
533}
534
535struct ResolvedReexport {
536 spec: String,
537 line: usize,
538 star: bool,
539 names: Vec<(String, String)>,
540 to: Option<String>,
541}
542
543pub fn resolve_ts_graph(fragments: &[(String, TsFragment)], alias: &TsPathAliases) -> TsGraph {
545 let file_set: HashSet<&str> = fragments.iter().map(|(f, _)| f.as_str()).collect();
546
547 let mut defs: Vec<Def> = Vec::new();
548 let mut exports_by_file: HashMap<&str, HashMap<String, usize>> = HashMap::new();
549 for (file, frag) in fragments {
550 let mut by_name: HashMap<String, usize> = HashMap::new();
551 for d in &frag.defs {
552 if by_name.contains_key(&d.name) {
553 continue;
554 }
555 by_name.insert(d.name.clone(), defs.len());
556 defs.push(Def {
557 id: ts_def_id(file, &d.name),
558 name: d.name.clone(),
559 namespace: dir_of(file).to_string(),
560 kind: d.kind.clone(),
561 file: file.clone(),
562 line: d.line,
563 methods: Vec::new(),
564 test_methods: Vec::new(),
565 also_in: Vec::new(),
566 end_line: d.end_line,
567 });
568 }
569 if let Some(dflt) = &frag.default {
573 if let Some(&i) = by_name.get(dflt) {
574 by_name.insert("default".to_string(), i);
575 }
576 }
577 exports_by_file.insert(file.as_str(), by_name);
578 }
579
580 let mut reexports_by_file: HashMap<&str, Vec<ResolvedReexport>> = HashMap::new();
581 for (file, frag) in fragments {
582 let rows = frag
583 .reexports
584 .iter()
585 .map(|rx| ResolvedReexport {
586 spec: rx.spec.clone(),
587 line: rx.line,
588 star: rx.star,
589 names: rx
590 .names
591 .iter()
592 .map(|n| (n.exported.clone(), n.imported.clone()))
593 .collect(),
594 to: resolve_specifier(file, &rx.spec, &file_set, alias),
595 })
596 .collect();
597 reexports_by_file.insert(file.as_str(), rows);
598 }
599
600 fn lookup_export(
601 file: &str,
602 name: &str,
603 hops: usize,
604 exports_by_file: &HashMap<&str, HashMap<String, usize>>,
605 reexports_by_file: &HashMap<&str, Vec<ResolvedReexport>>,
606 ) -> Option<usize> {
607 if let Some(own) = exports_by_file.get(file).and_then(|m| m.get(name)) {
608 return Some(*own);
609 }
610 if hops >= BARREL_HOPS {
611 return None;
612 }
613 for rx in reexports_by_file
614 .get(file)
615 .map(Vec::as_slice)
616 .unwrap_or(&[])
617 {
618 let Some(to) = &rx.to else { continue };
619 if rx.star {
620 if let Some(hit) =
621 lookup_export(to, name, hops + 1, exports_by_file, reexports_by_file)
622 {
623 return Some(hit);
624 }
625 continue;
626 }
627 for (exported, imported) in &rx.names {
628 if exported != name {
629 continue;
630 }
631 if let Some(hit) =
632 lookup_export(to, imported, hops + 1, exports_by_file, reexports_by_file)
633 {
634 return Some(hit);
635 }
636 }
637 }
638 None
639 }
640
641 let mut edges: Vec<Edge> = Vec::new();
642 let mut counts = TsEdgeCounts::default();
643 let mut external_imports = 0usize;
644 let mut unresolved_refs = 0usize;
645
646 for (file, frag) in fragments {
647 let mut bindings: HashMap<String, (String, String)> = HashMap::new();
648 for imp in &frag.imports {
649 let to = resolve_specifier(file, &imp.spec, &file_set, alias);
650 let Some(to) = to else {
651 external_imports += 1;
652 continue;
653 };
654 edges.push(Edge::Import {
655 from_file: file.clone(),
656 from_line: imp.line,
657 target: imp.spec.clone(),
658 to_file: to.clone(),
659 via: None,
660 });
661 counts.import += 1;
662 for b in &imp.bindings {
667 bindings
668 .entry(b.local.clone())
669 .or_insert((to.clone(), b.imported.clone()));
670 }
671 let mut followed: HashSet<String> = HashSet::new();
680 for b in &imp.bindings {
681 if b.imported == "*" {
682 continue;
683 }
684 let Some(target) =
685 lookup_export(&to, &b.imported, 0, &exports_by_file, &reexports_by_file)
686 else {
687 continue;
688 };
689 let target_file = defs[target].file.clone();
690 if target_file == to || followed.contains(&target_file) {
691 continue;
692 }
693 followed.insert(target_file.clone());
694 edges.push(Edge::Import {
695 from_file: file.clone(),
696 from_line: imp.line,
697 target: imp.spec.clone(),
698 to_file: target_file,
699 via: Some(to.clone()),
700 });
701 counts.import += 1;
702 }
703 }
704 for rx in reexports_by_file
705 .get(file.as_str())
706 .map(Vec::as_slice)
707 .unwrap_or(&[])
708 {
709 match &rx.to {
710 None => external_imports += 1,
711 Some(to) => {
712 edges.push(Edge::Import {
713 from_file: file.clone(),
714 from_line: rx.line,
715 target: rx.spec.clone(),
716 to_file: to.clone(),
717 via: None,
718 });
719 counts.import += 1;
720 }
721 }
722 }
723
724 for r in &frag.refs {
725 let target = match bindings.get(&r.name) {
726 Some((to, imported)) if imported == "*" => match &r.member {
730 Some(m) => lookup_export(to, m, 0, &exports_by_file, &reexports_by_file),
731 None => None,
732 },
733 Some((to, imported)) => {
734 lookup_export(to, imported, 0, &exports_by_file, &reexports_by_file)
735 }
736 None if r.member.is_none() => exports_by_file
737 .get(file.as_str())
738 .and_then(|m| m.get(&r.name))
739 .copied(),
740 None => None,
741 };
742 let Some(target) = target else {
743 unresolved_refs += 1;
744 continue;
745 };
746 let to = defs[target].id.clone();
747 let to_file = defs[target].file.clone();
748 match r.kind.as_str() {
749 "call" => {
750 edges.push(Edge::Call {
751 from_file: file.clone(),
752 from_line: r.line,
753 to,
754 to_file,
755 });
756 counts.call += 1;
757 }
758 "jsx-use" => {
759 edges.push(Edge::JsxUse {
760 from_file: file.clone(),
761 from_line: r.line,
762 to,
763 to_file,
764 });
765 counts.jsx_use += 1;
766 }
767 "dispatch" => {
768 edges.push(Edge::Dispatch {
769 from_file: file.clone(),
770 from_line: r.line,
771 to,
772 to_file,
773 });
774 counts.dispatch += 1;
775 }
776 _ => unresolved_refs += 1,
781 }
782 }
783 }
784
785 let stats = TsStats {
786 ts_file_count: fragments.len(),
787 ts_def_count: defs.len(),
788 external_import_count: external_imports,
789 unresolved_ref_count: unresolved_refs,
790 };
791 TsGraph {
792 defs,
793 edges,
794 edges_by_kind: counts,
795 stats,
796 }
797}
798
799#[cfg(test)]
800mod tests {
801 use super::*;
802 use crate::extract::{TsBinding, TsFragmentDef, TsImport, TsReexport, TsReexportName, TsRef};
803
804 fn frag(
805 defs: Vec<(&str, &str, usize)>,
806 imports: Vec<TsImport>,
807 reexports: Vec<TsReexport>,
808 refs: Vec<TsRef>,
809 ) -> TsFragment {
810 TsFragment {
811 ts: 1,
812 defs: defs
813 .into_iter()
814 .map(|(n, k, l)| TsFragmentDef {
815 name: n.into(),
816 kind: k.into(),
817 line: l,
818 end_line: l,
819 })
820 .collect(),
821 imports,
822 reexports,
823 refs,
824 default: None,
825 }
826 }
827
828 fn import(spec: &str, line: usize, bindings: &[(&str, &str)]) -> TsImport {
829 TsImport {
830 spec: spec.into(),
831 line,
832 bindings: bindings
833 .iter()
834 .map(|(l, i)| TsBinding {
835 local: (*l).into(),
836 imported: (*i).into(),
837 })
838 .collect(),
839 }
840 }
841
842 fn call_ref(name: &str, line: usize) -> TsRef {
843 TsRef {
844 kind: "call".into(),
845 name: name.into(),
846 member: None,
847 line,
848 }
849 }
850
851 fn no_alias() -> TsPathAliases {
852 TsPathAliases::default()
853 }
854
855 #[test]
856 fn join_rel_refuses_to_climb_above_the_repo_root() {
857 assert_eq!(
858 join_rel("src", "../util/format"),
859 Some("util/format".to_string())
860 );
861 assert_eq!(join_rel("", "../escape"), None);
862 }
863
864 #[test]
865 fn probe_order_prefers_a_typescript_source_over_a_built_js_sibling() {
866 let files: HashSet<&str> = ["src/x.ts", "src/x.js"].into_iter().collect();
867 assert_eq!(
868 probe_file(Some("src/x"), &files),
869 Some("src/x.ts".to_string())
870 );
871 }
872
873 #[test]
874 fn an_esm_js_specifier_resolves_to_the_ts_source_it_compiles_from() {
875 let files: HashSet<&str> = ["src/x.ts"].into_iter().collect();
876 assert_eq!(
877 probe_file(Some("src/x.js"), &files),
878 Some("src/x.ts".to_string())
879 );
880 }
881
882 #[test]
883 fn a_directory_specifier_resolves_through_its_index_file() {
884 let files: HashSet<&str> = ["src/hooks/index.ts"].into_iter().collect();
885 assert_eq!(
886 probe_file(Some("src/hooks"), &files),
887 Some("src/hooks/index.ts".to_string())
888 );
889 }
890
891 #[test]
892 fn strip_jsonc_drops_comments_and_one_trailing_comma_per_closer() {
893 let text = "{\n // a\n \"a\": 1, /* b */\n \"b\": [1, 2,],\n}";
894 let value: Value = serde_json::from_str(&strip_jsonc(text)).expect("parses");
895 assert_eq!(value["a"], serde_json::json!(1));
896 assert_eq!(value["b"], serde_json::json!([1, 2]));
897 }
898
899 #[test]
900 fn a_relative_import_becomes_one_import_edge_and_a_resolved_call() {
901 let fragments = vec![
902 (
903 "src/a.ts".to_string(),
904 frag(
905 vec![],
906 vec![import("./b", 1, &[("go", "go")])],
907 vec![],
908 vec![call_ref("go", 3)],
909 ),
910 ),
911 (
912 "src/b.ts".to_string(),
913 frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
914 ),
915 ];
916 let g = resolve_ts_graph(&fragments, &no_alias());
917 assert_eq!(
918 g.edges_by_kind,
919 TsEdgeCounts {
920 import: 1,
921 call: 1,
922 jsx_use: 0,
923 dispatch: 0
924 }
925 );
926 assert_eq!(g.stats.ts_def_count, 1);
927 assert_eq!(g.stats.unresolved_ref_count, 0);
928 }
929
930 #[test]
931 fn an_unresolvable_specifier_is_external_and_never_an_edge() {
932 let fragments = vec![(
933 "src/a.ts".to_string(),
934 frag(
935 vec![],
936 vec![import("./nowhere", 1, &[("go", "go")])],
937 vec![],
938 vec![call_ref("go", 3)],
939 ),
940 )];
941 let g = resolve_ts_graph(&fragments, &no_alias());
942 assert_eq!(g.edges.len(), 0);
943 assert_eq!(g.stats.external_import_count, 1);
944 assert_eq!(g.stats.unresolved_ref_count, 1);
945 }
946
947 #[test]
948 fn one_barrel_hop_is_followed_and_two_are_not() {
949 let barrel = |spec: &str, exported: &str| TsReexport {
950 spec: spec.into(),
951 line: 1,
952 star: false,
953 names: vec![TsReexportName {
954 exported: exported.into(),
955 imported: exported.into(),
956 }],
957 };
958 let one = vec![
959 (
960 "src/a.ts".to_string(),
961 frag(
962 vec![],
963 vec![import("./b", 1, &[("go", "go")])],
964 vec![],
965 vec![],
966 ),
967 ),
968 (
969 "src/b.ts".to_string(),
970 frag(vec![], vec![], vec![barrel("./c", "go")], vec![]),
971 ),
972 (
973 "src/c.ts".to_string(),
974 frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
975 ),
976 ];
977 let g1 = resolve_ts_graph(&one, &no_alias());
978 assert!(matches!(
979 g1.edges.iter().find(|e| matches!(e, Edge::Import { via: Some(_), .. })),
980 Some(Edge::Import { to_file, .. }) if to_file == "src/c.ts"
981 ));
982
983 let two = vec![
984 (
985 "src/a.ts".to_string(),
986 frag(
987 vec![],
988 vec![import("./b", 1, &[("go", "go")])],
989 vec![],
990 vec![],
991 ),
992 ),
993 (
994 "src/b.ts".to_string(),
995 frag(vec![], vec![], vec![barrel("./c", "go")], vec![]),
996 ),
997 (
998 "src/c.ts".to_string(),
999 frag(vec![], vec![], vec![barrel("./d", "go")], vec![]),
1000 ),
1001 (
1002 "src/d.ts".to_string(),
1003 frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
1004 ),
1005 ];
1006 let g2 = resolve_ts_graph(&two, &no_alias());
1007 assert!(
1008 !g2.edges
1009 .iter()
1010 .any(|e| matches!(e, Edge::Import { via: Some(_), .. })),
1011 "two chained barrels resolve to nothing"
1012 );
1013 }
1014
1015 #[test]
1016 fn a_namespace_binding_resolves_only_through_a_member() {
1017 let fragments = vec![
1018 (
1019 "src/a.ts".to_string(),
1020 frag(
1021 vec![],
1022 vec![import("./b", 1, &[("ns", "*")])],
1023 vec![],
1024 vec![
1025 TsRef {
1026 kind: "call".into(),
1027 name: "ns".into(),
1028 member: Some("go".into()),
1029 line: 3,
1030 },
1031 call_ref("ns", 4),
1032 ],
1033 ),
1034 ),
1035 (
1036 "src/b.ts".to_string(),
1037 frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
1038 ),
1039 ];
1040 let g = resolve_ts_graph(&fragments, &no_alias());
1041 assert_eq!(g.edges_by_kind.call, 1);
1042 assert_eq!(g.stats.unresolved_ref_count, 1);
1043 }
1044}