use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::extract::TsFragment;
use crate::graph::{Def, Edge, OrderedMap};
const DEF_SEPARATOR: char = '#';
const PROBE_EXTENSIONS: &[&str] = &[".ts", ".tsx", ".js", ".jsx"];
const OUTPUT_EXTENSION_SOURCES: &[(&str, &[&str])] = &[
(".js", &[".ts", ".tsx"]),
(".jsx", &[".tsx"]),
(".mjs", &[".mts", ".ts"]),
(".cjs", &[".cts", ".ts"]),
];
const TSCONFIG_CHAIN_LIMIT: usize = 4;
const TSCONFIG_ROOTS: &[&str] = &["tsconfig.json", "tsconfig.base.json"];
const BARREL_HOPS: usize = 1;
pub fn ts_def_id(file: &str, name: &str) -> String {
format!("{file}{DEF_SEPARATOR}{name}")
}
fn dir_of(rel: &str) -> &str {
match rel.rfind('/') {
None => "",
Some(i) => &rel[..i],
}
}
fn join_rel(base: &str, rest: &str) -> Option<String> {
let mut parts: Vec<&str> = Vec::new();
let joined = format!("{base}/{rest}");
for seg in joined.split('/') {
if seg.is_empty() || seg == "." {
continue;
}
if seg == ".." {
if parts.is_empty() {
return None;
}
parts.pop();
continue;
}
parts.push(seg);
}
Some(parts.join("/"))
}
fn extension_of(rel: &str) -> &str {
let slash = rel.rfind('/').map(|i| i as i64).unwrap_or(-1);
let dot = rel.rfind('.').map(|i| i as i64).unwrap_or(-1);
if dot > slash {
&rel[dot as usize..]
} else {
""
}
}
fn probe_file(base: Option<&str>, file_set: &HashSet<&str>) -> Option<String> {
let base = base?;
if file_set.contains(base) {
return Some(base.to_string());
}
let ext = extension_of(base);
if let Some((_, sources)) = OUTPUT_EXTENSION_SOURCES.iter().find(|(e, _)| *e == ext) {
let stem = &base[..base.len() - ext.len()];
for s in *sources {
let candidate = format!("{stem}{s}");
if file_set.contains(candidate.as_str()) {
return Some(candidate);
}
}
}
for e in PROBE_EXTENSIONS {
let candidate = format!("{base}{e}");
if file_set.contains(candidate.as_str()) {
return Some(candidate);
}
}
for e in PROBE_EXTENSIONS {
let candidate = format!("{base}/index{e}");
if file_set.contains(candidate.as_str()) {
return Some(candidate);
}
}
None
}
fn strip_jsonc(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out = String::new();
let mut i = 0usize;
let mut in_string = false;
while i < chars.len() {
let ch = chars[i];
if in_string {
out.push(ch);
if ch == '\\' {
if let Some(next) = chars.get(i + 1) {
out.push(*next);
}
i += 2;
continue;
}
if ch == '"' {
in_string = false;
}
i += 1;
continue;
}
if ch == '"' {
in_string = true;
out.push(ch);
i += 1;
continue;
}
if ch == '/' && chars.get(i + 1) == Some(&'/') {
while i < chars.len() && chars[i] != '\n' {
i += 1;
}
continue;
}
if ch == '/' && chars.get(i + 1) == Some(&'*') {
i += 2;
while i < chars.len() && !(chars[i] == '*' && chars.get(i + 1) == Some(&'/')) {
i += 1;
}
i += 2;
continue;
}
out.push(ch);
i += 1;
}
drop_trailing_commas(&out)
}
fn drop_trailing_commas(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out = String::new();
let mut i = 0usize;
while i < chars.len() {
if chars[i] != ',' {
out.push(chars[i]);
i += 1;
continue;
}
let mut j = i + 1;
while j < chars.len() && chars[j].is_whitespace() {
j += 1;
}
if j < chars.len() && (chars[j] == '}' || chars[j] == ']') {
for &c in &chars[i + 1..=j] {
out.push(c);
}
i = j + 1;
continue;
}
out.push(',');
i += 1;
}
out
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PathsField {
Map(OrderedMap<Value>),
Other(#[allow(dead_code)] Value),
}
#[derive(Debug, Default, Deserialize)]
struct CompilerOptions {
#[serde(default, rename = "baseUrl")]
base_url: Option<Value>,
#[serde(default)]
paths: Option<PathsField>,
}
#[derive(Debug, Default, Deserialize)]
struct TsConfigDoc {
#[serde(default)]
extends: Option<Value>,
#[serde(default, rename = "compilerOptions")]
compiler_options: Option<CompilerOptions>,
}
impl TsConfigDoc {
fn base_url(&self) -> Option<&str> {
self.compiler_options.as_ref()?.base_url.as_ref()?.as_str()
}
fn paths(&self) -> Option<&OrderedMap<Value>> {
match self.compiler_options.as_ref()?.paths.as_ref()? {
PathsField::Map(m) => Some(m),
PathsField::Other(_) => None,
}
}
fn declares_paths(&self) -> bool {
match self
.compiler_options
.as_ref()
.and_then(|o| o.paths.as_ref())
{
None => false,
Some(PathsField::Map(_)) => true,
Some(PathsField::Other(v)) => match v {
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
Value::String(str_value) => !str_value.is_empty(),
Value::Null => false,
_ => true,
},
}
}
}
struct ChainLink {
dir: String,
cfg: TsConfigDoc,
}
fn read_tsconfig_chain(root: &Path, start: &str) -> Vec<ChainLink> {
let mut chain: Vec<ChainLink> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut rel: Option<String> = Some(start.to_string());
for _ in 0..TSCONFIG_CHAIN_LIMIT {
let Some(current) = rel.clone() else { break };
if !seen.insert(current.clone()) {
break;
}
let path = root.join(¤t);
if !path.exists() {
break;
}
let Ok(text) = fs::read_to_string(&path) else {
break;
};
let stripped = strip_jsonc(&text);
let Ok(value) = serde_json::from_str::<Value>(&stripped) else {
break;
};
let cfg = if value.is_object() {
serde_json::from_str::<TsConfigDoc>(&stripped).unwrap_or_default()
} else {
TsConfigDoc::default()
};
let dir = dir_of(¤t).to_string();
rel = match cfg.extends.as_ref().and_then(Value::as_str) {
Some(ext) if ext.starts_with('.') => {
let spelled = if ext.ends_with(".json") {
ext.to_string()
} else {
format!("{ext}.json")
};
join_rel(&dir, &spelled)
}
_ => None,
};
chain.push(ChainLink { dir, cfg });
}
chain
}
#[derive(Debug, Clone)]
struct AliasTarget {
prefix: Option<String>,
suffix: String,
}
#[derive(Debug, Clone)]
struct Alias {
exact: Option<String>,
prefix: String,
suffix: String,
targets: Vec<AliasTarget>,
wildcard: bool,
}
#[derive(Debug, Clone, Default)]
pub struct TsPathAliases {
aliases: Vec<Alias>,
base_url_dir: Option<String>,
}
pub fn read_ts_path_aliases(root: &Path) -> TsPathAliases {
let mut chain: Vec<ChainLink> = Vec::new();
for start in TSCONFIG_ROOTS {
let candidate = read_tsconfig_chain(root, start);
if candidate.is_empty() {
continue;
}
let declares_paths = candidate.iter().any(|c| c.cfg.declares_paths());
if chain.is_empty() {
chain = candidate;
} else if declares_paths {
chain = candidate;
}
if declares_paths {
break;
}
}
let paths_from = chain.iter().find(|c| c.cfg.paths().is_some());
let base_url_from = chain.iter().find(|c| c.cfg.base_url().is_some());
let base_url_dir = base_url_from.and_then(|c| join_rel(&c.dir, c.cfg.base_url().unwrap_or("")));
let mut aliases: Vec<Alias> = Vec::new();
if let Some(pf) = paths_from {
let base: Option<String> = match pf.cfg.base_url() {
Some(own) => join_rel(&pf.dir, own),
None => base_url_dir.clone().or_else(|| Some(pf.dir.clone())),
};
for (pattern, targets) in pf.cfg.paths().expect("paths_from declares paths").iter() {
let Some(targets) = targets.as_array() else {
continue;
};
let resolved: Vec<AliasTarget> = targets
.iter()
.filter_map(Value::as_str)
.map(|t| match t.find('*') {
None => AliasTarget {
prefix: join_rel(base.as_deref().unwrap_or(""), t),
suffix: String::new(),
},
Some(star) => AliasTarget {
prefix: Some(
join_rel(base.as_deref().unwrap_or(""), &t[..star]).unwrap_or_default(),
),
suffix: t[star + 1..].to_string(),
},
})
.collect();
aliases.push(match pattern.find('*') {
None => Alias {
exact: Some(pattern.clone()),
prefix: pattern.clone(),
suffix: String::new(),
targets: resolved,
wildcard: false,
},
Some(star) => Alias {
exact: None,
prefix: pattern[..star].to_string(),
suffix: pattern[star + 1..].to_string(),
targets: resolved,
wildcard: true,
},
});
}
}
aliases.sort_by(|a, b| {
b.prefix
.len()
.cmp(&a.prefix.len())
.then_with(|| a.prefix.cmp(&b.prefix))
});
TsPathAliases {
aliases,
base_url_dir,
}
}
fn alias_targets(spec: &str, aliases: &[Alias]) -> Vec<Option<String>> {
for a in aliases {
if !a.wildcard {
if a.exact.as_deref() == Some(spec) {
return a.targets.iter().map(|t| t.prefix.clone()).collect();
}
continue;
}
let Some(rest) = spec.strip_prefix(a.prefix.as_str()) else {
continue;
};
let Some(mid) = rest.strip_suffix(a.suffix.as_str()) else {
continue;
};
return a
.targets
.iter()
.map(|t| {
let rest = if t.suffix.is_empty() {
mid.to_string()
} else {
format!("{mid}{}", t.suffix)
};
t.prefix.as_deref().and_then(|p| join_rel(p, &rest))
})
.collect();
}
Vec::new()
}
pub fn resolve_specifier(
from_file: &str,
spec: &str,
file_set: &HashSet<&str>,
alias: &TsPathAliases,
) -> Option<String> {
if spec.starts_with('.') {
return probe_file(join_rel(dir_of(from_file), spec).as_deref(), file_set);
}
for candidate in alias_targets(spec, &alias.aliases) {
if let Some(hit) = probe_file(candidate.as_deref(), file_set) {
return Some(hit);
}
}
if let Some(base) = &alias.base_url_dir {
if let Some(hit) = probe_file(join_rel(base, spec).as_deref(), file_set) {
return Some(hit);
}
}
None
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TsEdgeCounts {
pub import: usize,
pub call: usize,
pub jsx_use: usize,
pub dispatch: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct TsStats {
pub ts_file_count: usize,
pub ts_def_count: usize,
pub external_import_count: usize,
pub unresolved_ref_count: usize,
}
pub struct TsGraph {
pub defs: Vec<Def>,
pub edges: Vec<Edge>,
pub edges_by_kind: TsEdgeCounts,
pub stats: TsStats,
}
struct ResolvedReexport {
spec: String,
line: usize,
star: bool,
names: Vec<(String, String)>,
to: Option<String>,
}
pub fn resolve_ts_graph(fragments: &[(String, TsFragment)], alias: &TsPathAliases) -> TsGraph {
let file_set: HashSet<&str> = fragments.iter().map(|(f, _)| f.as_str()).collect();
let mut defs: Vec<Def> = Vec::new();
let mut exports_by_file: HashMap<&str, HashMap<String, usize>> = HashMap::new();
for (file, frag) in fragments {
let mut by_name: HashMap<String, usize> = HashMap::new();
for d in &frag.defs {
if by_name.contains_key(&d.name) {
continue;
}
by_name.insert(d.name.clone(), defs.len());
defs.push(Def {
id: ts_def_id(file, &d.name),
name: d.name.clone(),
namespace: dir_of(file).to_string(),
kind: d.kind.clone(),
file: file.clone(),
line: d.line,
methods: Vec::new(),
test_methods: Vec::new(),
also_in: Vec::new(),
end_line: d.end_line,
});
}
if let Some(dflt) = &frag.default {
if let Some(&i) = by_name.get(dflt) {
by_name.insert("default".to_string(), i);
}
}
exports_by_file.insert(file.as_str(), by_name);
}
let mut reexports_by_file: HashMap<&str, Vec<ResolvedReexport>> = HashMap::new();
for (file, frag) in fragments {
let rows = frag
.reexports
.iter()
.map(|rx| ResolvedReexport {
spec: rx.spec.clone(),
line: rx.line,
star: rx.star,
names: rx
.names
.iter()
.map(|n| (n.exported.clone(), n.imported.clone()))
.collect(),
to: resolve_specifier(file, &rx.spec, &file_set, alias),
})
.collect();
reexports_by_file.insert(file.as_str(), rows);
}
fn lookup_export(
file: &str,
name: &str,
hops: usize,
exports_by_file: &HashMap<&str, HashMap<String, usize>>,
reexports_by_file: &HashMap<&str, Vec<ResolvedReexport>>,
) -> Option<usize> {
if let Some(own) = exports_by_file.get(file).and_then(|m| m.get(name)) {
return Some(*own);
}
if hops >= BARREL_HOPS {
return None;
}
for rx in reexports_by_file
.get(file)
.map(Vec::as_slice)
.unwrap_or(&[])
{
let Some(to) = &rx.to else { continue };
if rx.star {
if let Some(hit) =
lookup_export(to, name, hops + 1, exports_by_file, reexports_by_file)
{
return Some(hit);
}
continue;
}
for (exported, imported) in &rx.names {
if exported != name {
continue;
}
if let Some(hit) =
lookup_export(to, imported, hops + 1, exports_by_file, reexports_by_file)
{
return Some(hit);
}
}
}
None
}
let mut edges: Vec<Edge> = Vec::new();
let mut counts = TsEdgeCounts::default();
let mut external_imports = 0usize;
let mut unresolved_refs = 0usize;
for (file, frag) in fragments {
let mut bindings: HashMap<String, (String, String)> = HashMap::new();
for imp in &frag.imports {
let to = resolve_specifier(file, &imp.spec, &file_set, alias);
let Some(to) = to else {
external_imports += 1;
continue;
};
edges.push(Edge::Import {
from_file: file.clone(),
from_line: imp.line,
target: imp.spec.clone(),
to_file: to.clone(),
via: None,
});
counts.import += 1;
for b in &imp.bindings {
bindings
.entry(b.local.clone())
.or_insert((to.clone(), b.imported.clone()));
}
let mut followed: HashSet<String> = HashSet::new();
for b in &imp.bindings {
if b.imported == "*" {
continue;
}
let Some(target) =
lookup_export(&to, &b.imported, 0, &exports_by_file, &reexports_by_file)
else {
continue;
};
let target_file = defs[target].file.clone();
if target_file == to || followed.contains(&target_file) {
continue;
}
followed.insert(target_file.clone());
edges.push(Edge::Import {
from_file: file.clone(),
from_line: imp.line,
target: imp.spec.clone(),
to_file: target_file,
via: Some(to.clone()),
});
counts.import += 1;
}
}
for rx in reexports_by_file
.get(file.as_str())
.map(Vec::as_slice)
.unwrap_or(&[])
{
match &rx.to {
None => external_imports += 1,
Some(to) => {
edges.push(Edge::Import {
from_file: file.clone(),
from_line: rx.line,
target: rx.spec.clone(),
to_file: to.clone(),
via: None,
});
counts.import += 1;
}
}
}
for r in &frag.refs {
let target = match bindings.get(&r.name) {
Some((to, imported)) if imported == "*" => match &r.member {
Some(m) => lookup_export(to, m, 0, &exports_by_file, &reexports_by_file),
None => None,
},
Some((to, imported)) => {
lookup_export(to, imported, 0, &exports_by_file, &reexports_by_file)
}
None if r.member.is_none() => exports_by_file
.get(file.as_str())
.and_then(|m| m.get(&r.name))
.copied(),
None => None,
};
let Some(target) = target else {
unresolved_refs += 1;
continue;
};
let to = defs[target].id.clone();
let to_file = defs[target].file.clone();
match r.kind.as_str() {
"call" => {
edges.push(Edge::Call {
from_file: file.clone(),
from_line: r.line,
to,
to_file,
});
counts.call += 1;
}
"jsx-use" => {
edges.push(Edge::JsxUse {
from_file: file.clone(),
from_line: r.line,
to,
to_file,
});
counts.jsx_use += 1;
}
"dispatch" => {
edges.push(Edge::Dispatch {
from_file: file.clone(),
from_line: r.line,
to,
to_file,
});
counts.dispatch += 1;
}
_ => unresolved_refs += 1,
}
}
}
let stats = TsStats {
ts_file_count: fragments.len(),
ts_def_count: defs.len(),
external_import_count: external_imports,
unresolved_ref_count: unresolved_refs,
};
TsGraph {
defs,
edges,
edges_by_kind: counts,
stats,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::{TsBinding, TsFragmentDef, TsImport, TsReexport, TsReexportName, TsRef};
fn frag(
defs: Vec<(&str, &str, usize)>,
imports: Vec<TsImport>,
reexports: Vec<TsReexport>,
refs: Vec<TsRef>,
) -> TsFragment {
TsFragment {
ts: 1,
defs: defs
.into_iter()
.map(|(n, k, l)| TsFragmentDef {
name: n.into(),
kind: k.into(),
line: l,
end_line: l,
})
.collect(),
imports,
reexports,
refs,
default: None,
}
}
fn import(spec: &str, line: usize, bindings: &[(&str, &str)]) -> TsImport {
TsImport {
spec: spec.into(),
line,
bindings: bindings
.iter()
.map(|(l, i)| TsBinding {
local: (*l).into(),
imported: (*i).into(),
})
.collect(),
}
}
fn call_ref(name: &str, line: usize) -> TsRef {
TsRef {
kind: "call".into(),
name: name.into(),
member: None,
line,
}
}
fn no_alias() -> TsPathAliases {
TsPathAliases::default()
}
#[test]
fn join_rel_refuses_to_climb_above_the_repo_root() {
assert_eq!(
join_rel("src", "../util/format"),
Some("util/format".to_string())
);
assert_eq!(join_rel("", "../escape"), None);
}
#[test]
fn probe_order_prefers_a_typescript_source_over_a_built_js_sibling() {
let files: HashSet<&str> = ["src/x.ts", "src/x.js"].into_iter().collect();
assert_eq!(
probe_file(Some("src/x"), &files),
Some("src/x.ts".to_string())
);
}
#[test]
fn an_esm_js_specifier_resolves_to_the_ts_source_it_compiles_from() {
let files: HashSet<&str> = ["src/x.ts"].into_iter().collect();
assert_eq!(
probe_file(Some("src/x.js"), &files),
Some("src/x.ts".to_string())
);
}
#[test]
fn a_directory_specifier_resolves_through_its_index_file() {
let files: HashSet<&str> = ["src/hooks/index.ts"].into_iter().collect();
assert_eq!(
probe_file(Some("src/hooks"), &files),
Some("src/hooks/index.ts".to_string())
);
}
#[test]
fn strip_jsonc_drops_comments_and_one_trailing_comma_per_closer() {
let text = "{\n // a\n \"a\": 1, /* b */\n \"b\": [1, 2,],\n}";
let value: Value = serde_json::from_str(&strip_jsonc(text)).expect("parses");
assert_eq!(value["a"], serde_json::json!(1));
assert_eq!(value["b"], serde_json::json!([1, 2]));
}
#[test]
fn a_relative_import_becomes_one_import_edge_and_a_resolved_call() {
let fragments = vec![
(
"src/a.ts".to_string(),
frag(
vec![],
vec![import("./b", 1, &[("go", "go")])],
vec![],
vec![call_ref("go", 3)],
),
),
(
"src/b.ts".to_string(),
frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
),
];
let g = resolve_ts_graph(&fragments, &no_alias());
assert_eq!(
g.edges_by_kind,
TsEdgeCounts {
import: 1,
call: 1,
jsx_use: 0,
dispatch: 0
}
);
assert_eq!(g.stats.ts_def_count, 1);
assert_eq!(g.stats.unresolved_ref_count, 0);
}
#[test]
fn an_unresolvable_specifier_is_external_and_never_an_edge() {
let fragments = vec![(
"src/a.ts".to_string(),
frag(
vec![],
vec![import("./nowhere", 1, &[("go", "go")])],
vec![],
vec![call_ref("go", 3)],
),
)];
let g = resolve_ts_graph(&fragments, &no_alias());
assert_eq!(g.edges.len(), 0);
assert_eq!(g.stats.external_import_count, 1);
assert_eq!(g.stats.unresolved_ref_count, 1);
}
#[test]
fn one_barrel_hop_is_followed_and_two_are_not() {
let barrel = |spec: &str, exported: &str| TsReexport {
spec: spec.into(),
line: 1,
star: false,
names: vec![TsReexportName {
exported: exported.into(),
imported: exported.into(),
}],
};
let one = vec![
(
"src/a.ts".to_string(),
frag(
vec![],
vec![import("./b", 1, &[("go", "go")])],
vec![],
vec![],
),
),
(
"src/b.ts".to_string(),
frag(vec![], vec![], vec![barrel("./c", "go")], vec![]),
),
(
"src/c.ts".to_string(),
frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
),
];
let g1 = resolve_ts_graph(&one, &no_alias());
assert!(matches!(
g1.edges.iter().find(|e| matches!(e, Edge::Import { via: Some(_), .. })),
Some(Edge::Import { to_file, .. }) if to_file == "src/c.ts"
));
let two = vec![
(
"src/a.ts".to_string(),
frag(
vec![],
vec![import("./b", 1, &[("go", "go")])],
vec![],
vec![],
),
),
(
"src/b.ts".to_string(),
frag(vec![], vec![], vec![barrel("./c", "go")], vec![]),
),
(
"src/c.ts".to_string(),
frag(vec![], vec![], vec![barrel("./d", "go")], vec![]),
),
(
"src/d.ts".to_string(),
frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
),
];
let g2 = resolve_ts_graph(&two, &no_alias());
assert!(
!g2.edges
.iter()
.any(|e| matches!(e, Edge::Import { via: Some(_), .. })),
"two chained barrels resolve to nothing"
);
}
#[test]
fn a_namespace_binding_resolves_only_through_a_member() {
let fragments = vec![
(
"src/a.ts".to_string(),
frag(
vec![],
vec![import("./b", 1, &[("ns", "*")])],
vec![],
vec![
TsRef {
kind: "call".into(),
name: "ns".into(),
member: Some("go".into()),
line: 3,
},
call_ref("ns", 4),
],
),
),
(
"src/b.ts".to_string(),
frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
),
];
let g = resolve_ts_graph(&fragments, &no_alias());
assert_eq!(g.edges_by_kind.call, 1);
assert_eq!(g.stats.unresolved_ref_count, 1);
}
}