Skip to main content

devscout_rs/
tsgraph.rs

1// The TS/TSX resolver: turns many files' reference fragments (see
2// `extract::extract_ts_fragment`) into resolved edges.
3//
4// The split mirrors the C# side exactly: the extractor records what one file
5// SAYS, this module decides what those words point at once every file is known.
6// What it does NOT do is deliberately bounded -- no node_modules-internal
7// resolution, no `.d.ts` / type-level analysis, and no resolution through a
8// chained method call, which is the same open gap the C# side still carries.
9//
10// Everything here is pure over the fragment map except one read: the repo's own
11// tsconfig chain, which is where a bare specifier's meaning is written down.
12
13use 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 = '#';
24// Extension probe order for a specifier that names no extension. TypeScript
25// sources lead so a repo carrying both `x.ts` and a built `x.js` resolves to
26// the source, which is the file an agent asking "who calls this" wants.
27const PROBE_EXTENSIONS: &[&str] = &[".ts", ".tsx", ".js", ".jsx"];
28// ESM spells a relative import of a TypeScript module with the extension of
29// what it COMPILES to. The mapping is the TypeScript compiler's own, applied
30// only after the literal path has already failed to match a mapped file.
31const 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;
38// Where a repo writes down what its bare specifiers mean. `tsconfig.json` is
39// the default entry point; a workspace whose root config is generated per-app
40// keeps the shared aliases in `tsconfig.base.json` instead, with nothing at
41// the default name at all -- so both are tried, and the one that actually
42// declares `paths` wins rather than whichever happens to exist.
43const TSCONFIG_ROOTS: &[&str] = &["tsconfig.json", "tsconfig.base.json"];
44// One level of barrel following, and one only: a name a file re-exports is
45// looked up in the module it re-exports FROM, and that module answers from its
46// own declarations alone. Two chained barrels resolve to nothing rather than to
47// a walk with no bound.
48const BARREL_HOPS: usize = 1;
49
50/// Builds a TypeScript definition identifier from a file and symbol name.
51pub 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
62// `/`-joined, repo-relative, no filesystem access: a `..` that would climb
63// above the repo root returns `None` rather than an absolute escape.
64fn 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
93// A resolved specifier is a file THIS INDEX MAPPED, never merely a path that
94// exists: an edge to a file the graph does not carry would point a caller at a
95// row that is not there. Probe order is fixed so the choice among several
96// candidate spellings is deterministic.
97fn 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
127// JSON with comments and trailing commas -- the spelling every real tsconfig
128// uses. Returns `None` on anything it cannot parse; a tsconfig this module
129// cannot read means bare specifiers stay external, never that they get guessed.
130// Two deliberate crudenesses: the trailing-comma pass is NOT string-aware (a
131// plain scan over the comment-stripped text), and it does not rescan its own
132// output.
133fn 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
181// Drops a comma that precedes a `}` or `]` (with optional whitespace between):
182// one left-to-right pass, each match consuming the comma AND the
183// whitespace-plus-closer that followed it, so the scan resumes past the closer
184// and never rescans its own replacement.
185fn 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// `paths` is read through `OrderedMap` so the aliases are built in the
213// document's own key order (serde_json's default `Map` sorts, which would
214// reorder a length tie); the untagged fallback treats a non-object `paths` as
215// "no paths" rather than failing the whole read of the tsconfig.
216#[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    // The guard used when picking which config OWNS the aliases: a `paths` that
245    // is present and an object.
246    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    // A bare TRUTHINESS test on `compilerOptions.paths` -- the weaker guard the
254    // chain-selection loop uses, deliberately kept distinct from `paths()` above
255    // so a config declaring a non-object `paths` still wins the chain and still
256    // contributes no alias.
257    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(&current);
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        // A parse failure means "cannot read this config" and stops the chain. A
299        // config that parses but is not an object contributes no option at all,
300        // which is what `TsConfigDoc::default()` is.
301        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(&current).to_string();
310        // Only a RELATIVE extends is followed: a bare one names a package inside
311        // node_modules, which this resolver deliberately does not resolve into.
312        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)]
344/// Represents `TsPathAliases`.
345pub struct TsPathAliases {
346    aliases: Vec<Alias>,
347    base_url_dir: Option<String>,
348}
349
350/// `paths` and `baseUrl` are whole-property overrides in TypeScript's own
351/// inheritance rule, not merged key by key -- the nearest config that declares
352/// one wins it outright. `paths` entries resolve against that config's own
353/// baseUrl when it has one, else against the directory the config sits in
354/// (TypeScript 4.1's rule).
355pub 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    // Longest prefix wins, TypeScript's own tie-break; the name breaks a length
420    // tie so the order is total and identical on every run. `sort_by` is stable,
421    // matching V8's own stable `Array.prototype.sort`.
422    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        // A length guard (`spec.len() < prefix.len() + suffix.len()`), then a
443        // prefix and suffix match. Stripping the prefix first and the suffix off
444        // what REMAINS expresses the same three conditions in one pass -- an
445        // overlapping prefix/suffix pair fails the second strip exactly where the
446        // length guard would reject it.
447        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
469/// Relative specifiers resolve directly; a bare one resolves only as far as the
470/// repo's OWN tsconfig paths/baseUrl already take it. Anything else is
471/// `external` -- the TS-side counterpart of the C# graph's cross-project
472/// handling, and never a guess at a package's internals.
473pub 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/// The four TS edge kinds' counts, appended to `stats.edges_by_kind` in this
496/// order and only when the repo carries a TS fragment at all.
497#[derive(Debug, Clone, Default, PartialEq)]
498pub struct TsEdgeCounts {
499    /// The import value.
500    pub import: usize,
501    /// The call value.
502    pub call: usize,
503    /// The jsx use value.
504    pub jsx_use: usize,
505    /// The dispatch value.
506    pub dispatch: usize,
507}
508
509/// `stats.ts` -- appended LAST inside `stats`, and only when the repo carries
510/// a TS fragment at all.
511#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
512pub struct TsStats {
513    /// The ts file count value.
514    pub ts_file_count: usize,
515    /// The ts def count value.
516    pub ts_def_count: usize,
517    /// The external import count value.
518    pub external_import_count: usize,
519    /// The unresolved ref count value.
520    pub unresolved_ref_count: usize,
521}
522
523/// Represents `TsGraph`.
524pub struct TsGraph {
525    /// The defs value.
526    pub defs: Vec<Def>,
527    /// The edges value.
528    pub edges: Vec<Edge>,
529    /// The edges by kind value.
530    pub edges_by_kind: TsEdgeCounts,
531    /// The stats value.
532    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
543/// Resolves the TS/TSX graph: every file's reference fragments into edges.
544pub 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        // `export default Foo` makes the SAME declaration importable under the
570        // name `default` -- one def, two names, never a second row on one
571        // declaration.
572        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            // First binding of a local name wins, the same first-occurrence
663            // rule the def map above uses: a file that binds one name twice is
664            // already broken TypeScript, and picking the later one would make
665            // the answer depend on statement order for no gain.
666            for b in &imp.bindings {
667                bindings
668                    .entry(b.local.clone())
669                    .or_insert((to.clone(), b.imported.clone()));
670            }
671            // A barrel is a routing table, not a dependency: `import
672            // { useThing } from '../hooks'` names index.ts but DEPENDS on the
673            // module index.ts re-exports it from. Without this edge, `impact`
674            // on the declaring file misses every consumer that came through
675            // the barrel. The edge to the named file stays (it is what the
676            // source literally says); this one carries `via` so a reader can
677            // tell the two apart, and one line contributes at most one edge per
678            // distinct declaring file however many names it pulls through.
679            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                // A namespace binding (`* as ns`, or a whole-module `require`)
727                // carries no name of its own -- only `ns.member` says which
728                // export is meant.
729                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                // Unreachable over a fragment this crate produced: the
777                // extractor emits exactly the three ref kinds above. A cached
778                // fragment carrying anything else is dropped rather than
779                // guessed at, the same stance every other unknown fact gets.
780                _ => 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}