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 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 for &ref_id in graph.get_references(from_project) {
574 let ref_project = graph.get_project(ref_id)?;
575
576 if let Some(resolved) = try_resolve_in_project(ref_project, import_specifier) {
578 return Some(resolved);
579 }
580 }
581
582 None
583}
584
585fn try_resolve_in_project(project: &ResolvedProject, specifier: &str) -> Option<PathBuf> {
587 if specifier.starts_with('.') {
589 return None;
591 }
592
593 let out_dir = project
595 .declaration_dir
596 .as_ref()
597 .or(project.out_dir.as_ref())?;
598
599 let dts_path = out_dir.join(specifier).with_extension("d.ts");
601 if dts_path.exists() {
602 return Some(dts_path);
603 }
604
605 let index_path = out_dir.join(specifier).join("index.d.ts");
607 if index_path.exists() {
608 return Some(index_path);
609 }
610
611 None
612}
613
614fn strip_jsonc(input: &str) -> String {
616 let mut out = String::with_capacity(input.len());
617 let mut chars = input.chars().peekable();
618 let mut in_string = false;
619 let mut escape = false;
620 let mut in_line_comment = false;
621 let mut in_block_comment = false;
622
623 while let Some(ch) = chars.next() {
624 if in_line_comment {
625 if ch == '\n' {
626 in_line_comment = false;
627 out.push(ch);
628 }
629 continue;
630 }
631
632 if in_block_comment {
633 if ch == '*' {
634 if let Some('/') = chars.peek().copied() {
635 chars.next();
636 in_block_comment = false;
637 }
638 } else if ch == '\n' {
639 out.push(ch);
640 }
641 continue;
642 }
643
644 if in_string {
645 out.push(ch);
646 if escape {
647 escape = false;
648 } else if ch == '\\' {
649 escape = true;
650 } else if ch == '"' {
651 in_string = false;
652 }
653 continue;
654 }
655
656 if ch == '"' {
657 in_string = true;
658 out.push(ch);
659 continue;
660 }
661
662 if ch == '/'
663 && let Some(&next) = chars.peek()
664 {
665 if next == '/' {
666 chars.next();
667 in_line_comment = true;
668 continue;
669 }
670 if next == '*' {
671 chars.next();
672 in_block_comment = true;
673 continue;
674 }
675 }
676
677 out.push(ch);
678 }
679
680 out
681}
682
683fn remove_trailing_commas(input: &str) -> String {
684 let mut out = String::with_capacity(input.len());
685 let mut chars = input.chars().peekable();
686 let mut in_string = false;
687 let mut escape = false;
688
689 while let Some(ch) = chars.next() {
690 if in_string {
691 out.push(ch);
692 if escape {
693 escape = false;
694 } else if ch == '\\' {
695 escape = true;
696 } else if ch == '"' {
697 in_string = false;
698 }
699 continue;
700 }
701
702 if ch == '"' {
703 in_string = true;
704 out.push(ch);
705 continue;
706 }
707
708 if ch == ',' {
709 let mut lookahead = chars.clone();
710 while let Some(next) = lookahead.peek().copied() {
711 if next.is_whitespace() {
712 lookahead.next();
713 continue;
714 }
715 break;
716 }
717
718 if let Some(next) = lookahead.peek().copied()
719 && (next == '}' || next == ']')
720 {
721 continue;
722 }
723 }
724
725 out.push(ch);
726 }
727
728 out
729}
730
731#[cfg(test)]
732#[path = "project_refs_tests.rs"]
733mod tests;