1use anyhow::{Context, Result, anyhow, bail};
17use rustc_hash::{FxHashMap, FxHashSet};
18use serde::{Deserialize, Serialize};
19use std::path::{Path, PathBuf};
20
21use super::config::{CompilerOptions, TsConfig};
22
23#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
25#[serde(rename_all = "camelCase")]
26pub struct ProjectReference {
27 pub path: String,
29 #[serde(default)]
31 pub prepend: bool,
32 #[serde(default)]
34 pub circular: bool,
35}
36
37#[derive(Debug, Clone, Deserialize, Default)]
39#[serde(rename_all = "camelCase")]
40pub struct TsConfigWithReferences {
41 #[serde(flatten)]
42 pub base: TsConfig,
43 #[serde(default)]
45 pub references: Option<Vec<ProjectReference>>,
46}
47
48#[derive(Debug, Clone, Deserialize, Default)]
50#[serde(rename_all = "camelCase")]
51pub struct CompositeCompilerOptions {
52 #[serde(flatten)]
53 pub base: CompilerOptions,
54 #[serde(default)]
56 pub composite: Option<bool>,
57 #[serde(default)]
59 pub force_consistent_casing_in_file_names: Option<bool>,
60 #[serde(default)]
62 pub disable_solution_searching: Option<bool>,
63 #[serde(default)]
65 pub disable_source_of_project_reference_redirect: Option<bool>,
66 #[serde(default)]
68 pub disable_referenced_project_load: Option<bool>,
69}
70
71#[derive(Debug, Clone)]
73pub struct ResolvedProject {
74 pub config_path: PathBuf,
76 pub root_dir: PathBuf,
78 pub config: TsConfigWithReferences,
80 pub resolved_references: Vec<ResolvedProjectReference>,
82 pub is_composite: bool,
84 pub declaration_dir: Option<PathBuf>,
86 pub out_dir: Option<PathBuf>,
88}
89
90#[derive(Debug, Clone)]
92pub struct ResolvedProjectReference {
93 pub config_path: PathBuf,
95 pub original: ProjectReference,
97 pub is_valid: bool,
99 pub error: Option<String>,
101}
102
103pub type ProjectId = usize;
105
106#[derive(Debug, Default)]
108pub struct ProjectReferenceGraph {
109 projects: Vec<ResolvedProject>,
111 path_to_id: FxHashMap<PathBuf, ProjectId>,
113 references: FxHashMap<ProjectId, Vec<ProjectId>>,
115 dependents: FxHashMap<ProjectId, Vec<ProjectId>>,
117}
118
119impl ProjectReferenceGraph {
120 pub fn new() -> Self {
122 Self::default()
123 }
124
125 pub fn load(root_config_path: &Path) -> Result<Self> {
127 let mut graph = Self::new();
128 let mut visited = FxHashSet::default();
129 let mut stack = Vec::new();
130
131 let canonical_root = std::fs::canonicalize(root_config_path).with_context(|| {
133 format!(
134 "failed to canonicalize root config: {}",
135 root_config_path.display()
136 )
137 })?;
138
139 stack.push(canonical_root);
140
141 while let Some(config_path) = stack.pop() {
143 if visited.contains(&config_path) {
144 continue;
145 }
146 visited.insert(config_path.clone());
147
148 let project = load_project(&config_path)?;
149 let _project_id = graph.add_project(project.clone());
150
151 for ref_info in &project.resolved_references {
153 if ref_info.is_valid && !visited.contains(&ref_info.config_path) {
154 stack.push(ref_info.config_path.clone());
155 }
156 }
157 }
158
159 graph.build_edges()?;
161
162 Ok(graph)
163 }
164
165 fn add_project(&mut self, project: ResolvedProject) -> ProjectId {
167 let id = self.projects.len();
168 self.path_to_id.insert(project.config_path.clone(), id);
169 self.projects.push(project);
170 self.references.insert(id, Vec::new());
171 self.dependents.insert(id, Vec::new());
172 id
173 }
174
175 fn build_edges(&mut self) -> Result<()> {
177 for (id, project) in self.projects.iter().enumerate() {
178 for ref_info in &project.resolved_references {
179 if !ref_info.is_valid {
180 continue;
181 }
182 if let Some(&ref_id) = self.path_to_id.get(&ref_info.config_path) {
183 self.references
184 .get_mut(&id)
185 .expect("project id exists in references map (inserted in build_graph)")
186 .push(ref_id);
187 self.dependents
188 .get_mut(&ref_id)
189 .expect("reference id exists in dependents map (inserted in build_graph)")
190 .push(id);
191 }
192 }
193 }
194 Ok(())
195 }
196
197 pub fn get_project(&self, id: ProjectId) -> Option<&ResolvedProject> {
199 self.projects.get(id)
200 }
201
202 pub fn get_project_id(&self, config_path: &Path) -> Option<ProjectId> {
204 self.path_to_id.get(config_path).copied()
205 }
206
207 pub fn projects(&self) -> &[ResolvedProject] {
209 &self.projects
210 }
211
212 pub const fn project_count(&self) -> usize {
214 self.projects.len()
215 }
216
217 pub fn get_references(&self, id: ProjectId) -> &[ProjectId] {
219 self.references.get(&id).map_or(&[], |v| v.as_slice())
220 }
221
222 pub fn get_dependents(&self, id: ProjectId) -> &[ProjectId] {
224 self.dependents.get(&id).map_or(&[], |v| v.as_slice())
225 }
226
227 pub fn detect_cycles(&self) -> Vec<Vec<ProjectId>> {
229 let mut cycles = Vec::new();
230 let mut visited = FxHashSet::default();
231 let mut rec_stack = FxHashSet::default();
232 let mut path = Vec::new();
233
234 for id in 0..self.projects.len() {
235 if !visited.contains(&id) {
236 self.detect_cycles_dfs(id, &mut visited, &mut rec_stack, &mut path, &mut cycles);
237 }
238 }
239
240 cycles
241 }
242
243 fn detect_cycles_dfs(
244 &self,
245 node: ProjectId,
246 visited: &mut FxHashSet<ProjectId>,
247 rec_stack: &mut FxHashSet<ProjectId>,
248 path: &mut Vec<ProjectId>,
249 cycles: &mut Vec<Vec<ProjectId>>,
250 ) {
251 visited.insert(node);
252 rec_stack.insert(node);
253 path.push(node);
254
255 for &neighbor in self.get_references(node) {
256 if !visited.contains(&neighbor) {
257 self.detect_cycles_dfs(neighbor, visited, rec_stack, path, cycles);
258 } else if rec_stack.contains(&neighbor) {
259 if let Some(start_idx) = path.iter().position(|&x| x == neighbor) {
261 cycles.push(path[start_idx..].to_vec());
262 }
263 }
264 }
265
266 path.pop();
267 rec_stack.remove(&node);
268 }
269
270 pub fn build_order(&self) -> Result<Vec<ProjectId>> {
273 let cycles = self.detect_cycles();
274 if !cycles.is_empty() {
275 let cycle_desc: Vec<String> = cycles
276 .iter()
277 .map(|cycle| {
278 let names: Vec<String> = cycle
279 .iter()
280 .filter_map(|&id| self.projects.get(id))
281 .map(|p| p.config_path.display().to_string())
282 .collect();
283 names.join(" -> ")
284 })
285 .collect();
286 bail!(
287 "Circular project references detected:\n{}",
288 cycle_desc.join("\n")
289 );
290 }
291
292 let mut in_degree: FxHashMap<ProjectId, usize> = FxHashMap::default();
294 for id in 0..self.projects.len() {
295 in_degree.insert(id, 0);
296 }
297 for refs in self.references.values() {
298 for &ref_id in refs {
299 *in_degree.entry(ref_id).or_insert(0) += 1;
300 }
301 }
302
303 let mut queue: Vec<ProjectId> = in_degree
304 .iter()
305 .filter(|&(_, °)| deg == 0)
306 .map(|(&id, _)| id)
307 .collect();
308 queue.sort(); let mut order = Vec::new();
311 while let Some(node) = queue.pop() {
312 order.push(node);
313 for &neighbor in self.get_references(node) {
314 let deg = in_degree
315 .get_mut(&neighbor)
316 .expect("all graph nodes initialized in in_degree map");
317 *deg -= 1;
318 if *deg == 0 {
319 queue.push(neighbor);
320 }
321 }
322 queue.sort(); }
324
325 order.reverse();
327 Ok(order)
328 }
329
330 pub fn transitive_dependencies(&self, id: ProjectId) -> FxHashSet<ProjectId> {
332 let mut deps = FxHashSet::default();
333 let mut stack = vec![id];
334
335 while let Some(current) = stack.pop() {
336 for &dep_id in self.get_references(current) {
337 if deps.insert(dep_id) {
338 stack.push(dep_id);
339 }
340 }
341 }
342
343 deps
344 }
345
346 pub fn affected_projects(&self, id: ProjectId) -> FxHashSet<ProjectId> {
348 let mut affected = FxHashSet::default();
349 let mut stack = vec![id];
350
351 while let Some(current) = stack.pop() {
352 for &dep_id in self.get_dependents(current) {
353 if affected.insert(dep_id) {
354 stack.push(dep_id);
355 }
356 }
357 }
358
359 affected
360 }
361}
362
363pub fn load_project(config_path: &Path) -> Result<ResolvedProject> {
365 let source = std::fs::read_to_string(config_path)
366 .with_context(|| format!("failed to read tsconfig: {}", config_path.display()))?;
367
368 let config = parse_tsconfig_with_references(&source)
369 .with_context(|| format!("failed to parse tsconfig: {}", config_path.display()))?;
370
371 let root_dir = config_path
372 .parent()
373 .ok_or_else(|| anyhow!("tsconfig has no parent directory"))?
374 .to_path_buf();
375
376 let root_dir = std::fs::canonicalize(&root_dir).unwrap_or(root_dir);
377
378 let resolved_references = resolve_project_references(&root_dir, &config.references)?;
380
381 let is_composite = check_composite_from_source(&source);
384
385 let declaration_dir = config
387 .base
388 .compiler_options
389 .as_ref()
390 .and_then(|opts| opts.declaration_dir.as_ref())
391 .map(|d| root_dir.join(d));
392
393 let out_dir = config
394 .base
395 .compiler_options
396 .as_ref()
397 .and_then(|opts| opts.out_dir.as_ref())
398 .map(|d| root_dir.join(d));
399
400 Ok(ResolvedProject {
401 config_path: std::fs::canonicalize(config_path)
402 .unwrap_or_else(|_| config_path.to_path_buf()),
403 root_dir,
404 config,
405 resolved_references,
406 is_composite,
407 declaration_dir,
408 out_dir,
409 })
410}
411
412pub fn parse_tsconfig_with_references(source: &str) -> Result<TsConfigWithReferences> {
414 let stripped = strip_jsonc(source);
415 let normalized = remove_trailing_commas(&stripped);
416 let config = serde_json::from_str(&normalized)
417 .context("failed to parse tsconfig JSON with references")?;
418 Ok(config)
419}
420
421fn check_composite_from_source(source: &str) -> bool {
423 let stripped = strip_jsonc(source);
425 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&stripped) {
426 value
427 .get("compilerOptions")
428 .and_then(|opts| opts.get("composite"))
429 .and_then(serde_json::Value::as_bool)
430 .unwrap_or(false)
431 } else {
432 false
433 }
434}
435
436fn resolve_project_references(
438 root_dir: &Path,
439 references: &Option<Vec<ProjectReference>>,
440) -> Result<Vec<ResolvedProjectReference>> {
441 let Some(refs) = references else {
442 return Ok(Vec::new());
443 };
444
445 let mut resolved = Vec::with_capacity(refs.len());
446
447 for ref_entry in refs {
448 let resolved_ref = resolve_single_reference(root_dir, ref_entry);
449 resolved.push(resolved_ref);
450 }
451
452 Ok(resolved)
453}
454
455fn resolve_single_reference(
457 root_dir: &Path,
458 reference: &ProjectReference,
459) -> ResolvedProjectReference {
460 let ref_path = PathBuf::from(&reference.path);
461
462 let abs_path = if ref_path.is_absolute() {
464 ref_path
465 } else {
466 root_dir.join(&ref_path)
467 };
468
469 let config_path = if abs_path.is_dir() {
471 abs_path.join("tsconfig.json")
472 } else if abs_path.extension().is_some_and(|ext| ext == "json") {
473 abs_path
474 } else {
475 abs_path.join("tsconfig.json")
477 };
478
479 let canonical_path =
481 std::fs::canonicalize(&config_path).unwrap_or_else(|_| config_path.clone());
482
483 let (is_valid, error) = if canonical_path.exists() {
485 (true, None)
486 } else {
487 (
488 false,
489 Some(format!(
490 "Referenced project not found: {}",
491 config_path.display()
492 )),
493 )
494 };
495
496 ResolvedProjectReference {
497 config_path: canonical_path,
498 original: reference.clone(),
499 is_valid,
500 error,
501 }
502}
503
504pub fn validate_composite_project(project: &ResolvedProject) -> Result<Vec<String>> {
506 let mut errors = Vec::new();
507
508 if !project.is_composite {
509 return Ok(errors);
510 }
511
512 let opts = project.config.base.compiler_options.as_ref();
513
514 let emits_declarations = opts.and_then(|o| o.declaration).unwrap_or(false);
516 if !emits_declarations {
517 errors.push("Composite projects must have 'declaration: true'".to_string());
518 }
519
520 if opts.and_then(|o| o.root_dir.as_ref()).is_none() {
522 errors.push("Composite projects should specify 'rootDir'".to_string());
523 }
524
525 for ref_info in &project.resolved_references {
527 if !ref_info.is_valid {
528 errors.push(format!(
529 "Invalid reference: {}",
530 ref_info.error.as_deref().unwrap_or("unknown error")
531 ));
532 }
533 }
534
535 Ok(errors)
536}
537
538pub fn get_declaration_output_path(
540 project: &ResolvedProject,
541 source_file: &Path,
542) -> Option<PathBuf> {
543 let opts = project.config.base.compiler_options.as_ref()?;
544
545 let out_base = project
547 .declaration_dir
548 .as_ref()
549 .or(project.out_dir.as_ref())?;
550
551 let root_dir = opts
553 .root_dir
554 .as_ref()
555 .map_or_else(|| project.root_dir.clone(), |r| project.root_dir.join(r));
556
557 let relative = source_file.strip_prefix(&root_dir).ok()?;
558
559 let mut dts_path = out_base.join(relative);
561 dts_path.set_extension("d.ts");
562
563 Some(dts_path)
564}
565
566pub fn resolve_cross_project_import(
568 graph: &ProjectReferenceGraph,
569 from_project: ProjectId,
570 import_specifier: &str,
571) -> Option<PathBuf> {
572 let _project = graph.get_project(from_project)?;
573
574 for &ref_id in graph.get_references(from_project) {
576 let ref_project = graph.get_project(ref_id)?;
577
578 if let Some(resolved) = try_resolve_in_project(ref_project, import_specifier) {
580 return Some(resolved);
581 }
582 }
583
584 None
585}
586
587fn try_resolve_in_project(project: &ResolvedProject, specifier: &str) -> Option<PathBuf> {
589 if specifier.starts_with('.') {
591 return None;
593 }
594
595 let out_dir = project
597 .declaration_dir
598 .as_ref()
599 .or(project.out_dir.as_ref())?;
600
601 let dts_path = out_dir.join(specifier).with_extension("d.ts");
603 if dts_path.exists() {
604 return Some(dts_path);
605 }
606
607 let index_path = out_dir.join(specifier).join("index.d.ts");
609 if index_path.exists() {
610 return Some(index_path);
611 }
612
613 None
614}
615
616fn strip_jsonc(input: &str) -> String {
618 let mut out = String::with_capacity(input.len());
619 let mut chars = input.chars().peekable();
620 let mut in_string = false;
621 let mut escape = false;
622 let mut in_line_comment = false;
623 let mut in_block_comment = false;
624
625 while let Some(ch) = chars.next() {
626 if in_line_comment {
627 if ch == '\n' {
628 in_line_comment = false;
629 out.push(ch);
630 }
631 continue;
632 }
633
634 if in_block_comment {
635 if ch == '*' {
636 if let Some('/') = chars.peek().copied() {
637 chars.next();
638 in_block_comment = false;
639 }
640 } else if ch == '\n' {
641 out.push(ch);
642 }
643 continue;
644 }
645
646 if in_string {
647 out.push(ch);
648 if escape {
649 escape = false;
650 } else if ch == '\\' {
651 escape = true;
652 } else if ch == '"' {
653 in_string = false;
654 }
655 continue;
656 }
657
658 if ch == '"' {
659 in_string = true;
660 out.push(ch);
661 continue;
662 }
663
664 if ch == '/'
665 && let Some(&next) = chars.peek()
666 {
667 if next == '/' {
668 chars.next();
669 in_line_comment = true;
670 continue;
671 }
672 if next == '*' {
673 chars.next();
674 in_block_comment = true;
675 continue;
676 }
677 }
678
679 out.push(ch);
680 }
681
682 out
683}
684
685fn remove_trailing_commas(input: &str) -> String {
686 let mut out = String::with_capacity(input.len());
687 let mut chars = input.chars().peekable();
688 let mut in_string = false;
689 let mut escape = false;
690
691 while let Some(ch) = chars.next() {
692 if in_string {
693 out.push(ch);
694 if escape {
695 escape = false;
696 } else if ch == '\\' {
697 escape = true;
698 } else if ch == '"' {
699 in_string = false;
700 }
701 continue;
702 }
703
704 if ch == '"' {
705 in_string = true;
706 out.push(ch);
707 continue;
708 }
709
710 if ch == ',' {
711 let mut lookahead = chars.clone();
712 while let Some(next) = lookahead.peek().copied() {
713 if next.is_whitespace() {
714 lookahead.next();
715 continue;
716 }
717 break;
718 }
719
720 if let Some(next) = lookahead.peek().copied()
721 && (next == '}' || next == ']')
722 {
723 continue;
724 }
725 }
726
727 out.push(ch);
728 }
729
730 out
731}
732
733#[cfg(test)]
734#[path = "project_refs_tests.rs"]
735mod tests;