use anyhow::{Context, Result, anyhow, bail};
use rustc_hash::{FxHashMap, FxHashSet};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use super::config::{CompilerOptions, TsConfig};
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ProjectReference {
pub path: String,
#[serde(default)]
pub prepend: bool,
#[serde(default)]
pub circular: bool,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TsConfigWithReferences {
#[serde(flatten)]
pub base: TsConfig,
#[serde(default)]
pub references: Option<Vec<ProjectReference>>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct CompositeCompilerOptions {
#[serde(flatten)]
pub base: CompilerOptions,
#[serde(default)]
pub composite: Option<bool>,
#[serde(default)]
pub force_consistent_casing_in_file_names: Option<bool>,
#[serde(default)]
pub disable_solution_searching: Option<bool>,
#[serde(default)]
pub disable_source_of_project_reference_redirect: Option<bool>,
#[serde(default)]
pub disable_referenced_project_load: Option<bool>,
}
#[derive(Debug, Clone)]
pub struct ResolvedProject {
pub config_path: PathBuf,
pub root_dir: PathBuf,
pub config: TsConfigWithReferences,
pub resolved_references: Vec<ResolvedProjectReference>,
pub is_composite: bool,
pub declaration_dir: Option<PathBuf>,
pub out_dir: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct ResolvedProjectReference {
pub config_path: PathBuf,
pub original: ProjectReference,
pub is_valid: bool,
pub error: Option<String>,
}
pub type ProjectId = usize;
#[derive(Debug, Default)]
pub struct ProjectReferenceGraph {
projects: Vec<ResolvedProject>,
path_to_id: FxHashMap<PathBuf, ProjectId>,
references: FxHashMap<ProjectId, Vec<ProjectId>>,
dependents: FxHashMap<ProjectId, Vec<ProjectId>>,
}
impl ProjectReferenceGraph {
pub fn new() -> Self {
Self::default()
}
pub fn load(root_config_path: &Path) -> Result<Self> {
let mut graph = Self::new();
let mut visited = FxHashSet::default();
let mut stack = Vec::new();
let canonical_root = std::fs::canonicalize(root_config_path).with_context(|| {
format!(
"failed to canonicalize root config: {}",
root_config_path.display()
)
})?;
stack.push(canonical_root);
while let Some(config_path) = stack.pop() {
if visited.contains(&config_path) {
continue;
}
visited.insert(config_path.clone());
let project = load_project(&config_path)?;
graph.add_project(project.clone());
for ref_info in &project.resolved_references {
if ref_info.is_valid && !visited.contains(&ref_info.config_path) {
stack.push(ref_info.config_path.clone());
}
}
}
graph.build_edges()?;
Ok(graph)
}
fn add_project(&mut self, project: ResolvedProject) -> ProjectId {
let id = self.projects.len();
self.path_to_id.insert(project.config_path.clone(), id);
self.projects.push(project);
self.references.insert(id, Vec::new());
self.dependents.insert(id, Vec::new());
id
}
fn build_edges(&mut self) -> Result<()> {
for (id, project) in self.projects.iter().enumerate() {
for ref_info in &project.resolved_references {
if !ref_info.is_valid {
continue;
}
if let Some(&ref_id) = self.path_to_id.get(&ref_info.config_path) {
self.references
.get_mut(&id)
.expect("project id exists in references map (inserted in build_graph)")
.push(ref_id);
self.dependents
.get_mut(&ref_id)
.expect("reference id exists in dependents map (inserted in build_graph)")
.push(id);
}
}
}
Ok(())
}
pub fn get_project(&self, id: ProjectId) -> Option<&ResolvedProject> {
self.projects.get(id)
}
pub fn get_project_id(&self, config_path: &Path) -> Option<ProjectId> {
self.path_to_id.get(config_path).copied()
}
pub fn projects(&self) -> &[ResolvedProject] {
&self.projects
}
pub const fn project_count(&self) -> usize {
self.projects.len()
}
pub fn get_references(&self, id: ProjectId) -> &[ProjectId] {
self.references.get(&id).map_or(&[], |v| v.as_slice())
}
pub fn get_dependents(&self, id: ProjectId) -> &[ProjectId] {
self.dependents.get(&id).map_or(&[], |v| v.as_slice())
}
pub fn detect_cycles(&self) -> Vec<Vec<ProjectId>> {
let mut cycles = Vec::new();
let mut visited = FxHashSet::default();
let mut rec_stack = FxHashSet::default();
let mut path = Vec::new();
for id in 0..self.projects.len() {
if !visited.contains(&id) {
self.detect_cycles_dfs(id, &mut visited, &mut rec_stack, &mut path, &mut cycles);
}
}
cycles
}
fn detect_cycles_dfs(
&self,
node: ProjectId,
visited: &mut FxHashSet<ProjectId>,
rec_stack: &mut FxHashSet<ProjectId>,
path: &mut Vec<ProjectId>,
cycles: &mut Vec<Vec<ProjectId>>,
) {
visited.insert(node);
rec_stack.insert(node);
path.push(node);
for &neighbor in self.get_references(node) {
if !visited.contains(&neighbor) {
self.detect_cycles_dfs(neighbor, visited, rec_stack, path, cycles);
} else if rec_stack.contains(&neighbor) {
if let Some(start_idx) = path.iter().position(|&x| x == neighbor) {
cycles.push(path[start_idx..].to_vec());
}
}
}
path.pop();
rec_stack.remove(&node);
}
pub fn build_order(&self) -> Result<Vec<ProjectId>> {
let cycles = self.detect_cycles();
if !cycles.is_empty() {
let cycle_desc: Vec<String> = cycles
.iter()
.map(|cycle| {
let names: Vec<String> = cycle
.iter()
.filter_map(|&id| self.projects.get(id))
.map(|p| p.config_path.display().to_string())
.collect();
names.join(" -> ")
})
.collect();
bail!(
"Circular project references detected:\n{}",
cycle_desc.join("\n")
);
}
let mut in_degree: FxHashMap<ProjectId, usize> = FxHashMap::default();
for id in 0..self.projects.len() {
in_degree.insert(id, 0);
}
for refs in self.references.values() {
for &ref_id in refs {
*in_degree.entry(ref_id).or_insert(0) += 1;
}
}
let mut queue: Vec<ProjectId> = in_degree
.iter()
.filter(|&(_, °)| deg == 0)
.map(|(&id, _)| id)
.collect();
queue.sort();
let mut order = Vec::new();
while let Some(node) = queue.pop() {
order.push(node);
for &neighbor in self.get_references(node) {
let deg = in_degree
.get_mut(&neighbor)
.expect("all graph nodes initialized in in_degree map");
*deg -= 1;
if *deg == 0 {
queue.push(neighbor);
}
}
queue.sort(); }
order.reverse();
Ok(order)
}
pub fn transitive_dependencies(&self, id: ProjectId) -> FxHashSet<ProjectId> {
let mut deps = FxHashSet::default();
let mut stack = vec![id];
while let Some(current) = stack.pop() {
for &dep_id in self.get_references(current) {
if deps.insert(dep_id) {
stack.push(dep_id);
}
}
}
deps
}
pub fn affected_projects(&self, id: ProjectId) -> FxHashSet<ProjectId> {
let mut affected = FxHashSet::default();
let mut stack = vec![id];
while let Some(current) = stack.pop() {
for &dep_id in self.get_dependents(current) {
if affected.insert(dep_id) {
stack.push(dep_id);
}
}
}
affected
}
}
pub fn load_project(config_path: &Path) -> Result<ResolvedProject> {
let source = std::fs::read_to_string(config_path)
.with_context(|| format!("failed to read tsconfig: {}", config_path.display()))?;
let config = parse_tsconfig_with_references(&source)
.with_context(|| format!("failed to parse tsconfig: {}", config_path.display()))?;
let root_dir = config_path
.parent()
.ok_or_else(|| anyhow!("tsconfig has no parent directory"))?
.to_path_buf();
let root_dir = std::fs::canonicalize(&root_dir).unwrap_or(root_dir);
let resolved_references = resolve_project_references(&root_dir, &config.references)?;
let is_composite = check_composite_from_source(&source);
let declaration_dir = config
.base
.compiler_options
.as_ref()
.and_then(|opts| opts.declaration_dir.as_ref())
.map(|d| root_dir.join(d));
let out_dir = config
.base
.compiler_options
.as_ref()
.and_then(|opts| opts.out_dir.as_ref())
.map(|d| root_dir.join(d));
Ok(ResolvedProject {
config_path: std::fs::canonicalize(config_path)
.unwrap_or_else(|_| config_path.to_path_buf()),
root_dir,
config,
resolved_references,
is_composite,
declaration_dir,
out_dir,
})
}
pub fn parse_tsconfig_with_references(source: &str) -> Result<TsConfigWithReferences> {
let stripped = strip_jsonc(source);
let normalized = remove_trailing_commas(&stripped);
let config = serde_json::from_str(&normalized)
.context("failed to parse tsconfig JSON with references")?;
Ok(config)
}
fn check_composite_from_source(source: &str) -> bool {
let stripped = strip_jsonc(source);
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&stripped) {
value
.get("compilerOptions")
.and_then(|opts| opts.get("composite"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
} else {
false
}
}
fn resolve_project_references(
root_dir: &Path,
references: &Option<Vec<ProjectReference>>,
) -> Result<Vec<ResolvedProjectReference>> {
let Some(refs) = references else {
return Ok(Vec::new());
};
let mut resolved = Vec::with_capacity(refs.len());
for ref_entry in refs {
let resolved_ref = resolve_single_reference(root_dir, ref_entry);
resolved.push(resolved_ref);
}
Ok(resolved)
}
fn resolve_single_reference(
root_dir: &Path,
reference: &ProjectReference,
) -> ResolvedProjectReference {
let ref_path = PathBuf::from(&reference.path);
let abs_path = if ref_path.is_absolute() {
ref_path
} else {
root_dir.join(&ref_path)
};
let config_path = if abs_path.is_dir() {
abs_path.join("tsconfig.json")
} else if abs_path.extension().is_some_and(|ext| ext == "json") {
abs_path
} else {
abs_path.join("tsconfig.json")
};
let canonical_path =
std::fs::canonicalize(&config_path).unwrap_or_else(|_| config_path.clone());
let (is_valid, error) = if canonical_path.exists() {
(true, None)
} else {
(
false,
Some(format!(
"Referenced project not found: {}",
config_path.display()
)),
)
};
ResolvedProjectReference {
config_path: canonical_path,
original: reference.clone(),
is_valid,
error,
}
}
pub fn validate_composite_project(project: &ResolvedProject) -> Result<Vec<String>> {
let mut errors = Vec::new();
if !project.is_composite {
return Ok(errors);
}
let opts = project.config.base.compiler_options.as_ref();
let emits_declarations = opts.and_then(|o| o.declaration).unwrap_or(false);
if !emits_declarations {
errors.push("Composite projects must have 'declaration: true'".to_string());
}
if opts.and_then(|o| o.root_dir.as_ref()).is_none() {
errors.push("Composite projects should specify 'rootDir'".to_string());
}
for ref_info in &project.resolved_references {
if !ref_info.is_valid {
errors.push(format!(
"Invalid reference: {}",
ref_info.error.as_deref().unwrap_or("unknown error")
));
}
}
Ok(errors)
}
pub fn get_declaration_output_path(
project: &ResolvedProject,
source_file: &Path,
) -> Option<PathBuf> {
let opts = project.config.base.compiler_options.as_ref()?;
let out_base = project
.declaration_dir
.as_ref()
.or(project.out_dir.as_ref())?;
let root_dir = opts
.root_dir
.as_ref()
.map_or_else(|| project.root_dir.clone(), |r| project.root_dir.join(r));
let relative = source_file.strip_prefix(&root_dir).ok()?;
let mut dts_path = out_base.join(relative);
dts_path.set_extension("d.ts");
Some(dts_path)
}
pub fn resolve_cross_project_import(
graph: &ProjectReferenceGraph,
from_project: ProjectId,
import_specifier: &str,
) -> Option<PathBuf> {
for &ref_id in graph.get_references(from_project) {
let ref_project = graph.get_project(ref_id)?;
if let Some(resolved) = try_resolve_in_project(ref_project, import_specifier) {
return Some(resolved);
}
}
None
}
fn try_resolve_in_project(project: &ResolvedProject, specifier: &str) -> Option<PathBuf> {
if specifier.starts_with('.') {
return None;
}
let out_dir = project
.declaration_dir
.as_ref()
.or(project.out_dir.as_ref())?;
let dts_path = out_dir.join(specifier).with_extension("d.ts");
if dts_path.exists() {
return Some(dts_path);
}
let index_path = out_dir.join(specifier).join("index.d.ts");
if index_path.exists() {
return Some(index_path);
}
None
}
fn strip_jsonc(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let mut in_string = false;
let mut escape = false;
let mut in_line_comment = false;
let mut in_block_comment = false;
while let Some(ch) = chars.next() {
if in_line_comment {
if ch == '\n' {
in_line_comment = false;
out.push(ch);
}
continue;
}
if in_block_comment {
if ch == '*' {
if let Some('/') = chars.peek().copied() {
chars.next();
in_block_comment = false;
}
} else if ch == '\n' {
out.push(ch);
}
continue;
}
if in_string {
out.push(ch);
if escape {
escape = false;
} else if ch == '\\' {
escape = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
if ch == '"' {
in_string = true;
out.push(ch);
continue;
}
if ch == '/'
&& let Some(&next) = chars.peek()
{
if next == '/' {
chars.next();
in_line_comment = true;
continue;
}
if next == '*' {
chars.next();
in_block_comment = true;
continue;
}
}
out.push(ch);
}
out
}
fn remove_trailing_commas(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let mut in_string = false;
let mut escape = false;
while let Some(ch) = chars.next() {
if in_string {
out.push(ch);
if escape {
escape = false;
} else if ch == '\\' {
escape = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
if ch == '"' {
in_string = true;
out.push(ch);
continue;
}
if ch == ',' {
let mut lookahead = chars.clone();
while let Some(next) = lookahead.peek().copied() {
if next.is_whitespace() {
lookahead.next();
continue;
}
break;
}
if let Some(next) = lookahead.peek().copied()
&& (next == '}' || next == ']')
{
continue;
}
}
out.push(ch);
}
out
}
#[cfg(test)]
#[path = "project_refs_tests.rs"]
mod tests;