Skip to main content

tsz_cli/
project_refs.rs

1//! Project References Support
2//!
3//! This module implements TypeScript project references, which enable:
4//! - Splitting large codebases into smaller projects
5//! - Faster incremental builds through project-level caching
6//! - Better editor support with scoped type checking
7//! - Cleaner dependency management between project boundaries
8//!
9//! # Key Concepts
10//!
11//! - **Composite Project**: A project with `composite: true` that can be referenced
12//! - **Project Reference**: A `{ path: string, prepend?: boolean }` entry in tsconfig.json
13//! - **Build Order**: Topologically sorted order of projects based on dependencies
14//! - **Declaration Output**: .d.ts files that reference consuming projects use
15
16use 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/// A project reference as specified in tsconfig.json
24#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
25#[serde(rename_all = "camelCase")]
26pub struct ProjectReference {
27    /// Path to the referenced project's tsconfig.json or directory
28    pub path: String,
29    /// If true, prepend the output of this project to the output of the referencing project
30    #[serde(default)]
31    pub prepend: bool,
32    /// Circular reference allowed (non-standard extension for gradual migration)
33    #[serde(default)]
34    pub circular: bool,
35}
36
37/// Extended `TsConfig` that includes project references
38#[derive(Debug, Clone, Deserialize, Default)]
39#[serde(rename_all = "camelCase")]
40pub struct TsConfigWithReferences {
41    #[serde(flatten)]
42    pub base: TsConfig,
43    /// List of project references
44    #[serde(default)]
45    pub references: Option<Vec<ProjectReference>>,
46}
47
48/// Extended `CompilerOptions` with composite project settings
49#[derive(Debug, Clone, Deserialize, Default)]
50#[serde(rename_all = "camelCase")]
51pub struct CompositeCompilerOptions {
52    #[serde(flatten)]
53    pub base: CompilerOptions,
54    /// Whether this is a composite project that can be referenced
55    #[serde(default)]
56    pub composite: Option<bool>,
57    /// Force consistent casing in file names
58    #[serde(default)]
59    pub force_consistent_casing_in_file_names: Option<bool>,
60    /// Disable solution searching for this project
61    #[serde(default)]
62    pub disable_solution_searching: Option<bool>,
63    /// Disable source project reference redirect
64    #[serde(default)]
65    pub disable_source_of_project_reference_redirect: Option<bool>,
66    /// Disable referenced project load
67    #[serde(default)]
68    pub disable_referenced_project_load: Option<bool>,
69}
70
71/// A resolved project with its configuration and metadata
72#[derive(Debug, Clone)]
73pub struct ResolvedProject {
74    /// Absolute path to the project's tsconfig.json
75    pub config_path: PathBuf,
76    /// The project's root directory
77    pub root_dir: PathBuf,
78    /// The parsed configuration
79    pub config: TsConfigWithReferences,
80    /// Resolved references to other projects
81    pub resolved_references: Vec<ResolvedProjectReference>,
82    /// Whether this is a composite project
83    pub is_composite: bool,
84    /// Output directory for declarations
85    pub declaration_dir: Option<PathBuf>,
86    /// Output directory for JavaScript
87    pub out_dir: Option<PathBuf>,
88}
89
90/// A resolved reference to another project
91#[derive(Debug, Clone)]
92pub struct ResolvedProjectReference {
93    /// Absolute path to the referenced project's tsconfig.json
94    pub config_path: PathBuf,
95    /// The original reference from the config
96    pub original: ProjectReference,
97    /// Whether the reference was successfully resolved
98    pub is_valid: bool,
99    /// Error message if resolution failed
100    pub error: Option<String>,
101}
102
103/// Unique identifier for a project in the reference graph
104pub type ProjectId = usize;
105
106/// Graph of project references for build ordering
107#[derive(Debug, Default)]
108pub struct ProjectReferenceGraph {
109    /// All projects indexed by their ID
110    projects: Vec<ResolvedProject>,
111    /// Map from config path to project ID
112    path_to_id: FxHashMap<PathBuf, ProjectId>,
113    /// Adjacency list: project ID -> IDs of projects it references
114    references: FxHashMap<ProjectId, Vec<ProjectId>>,
115    /// Reverse adjacency: project ID -> IDs of projects that reference it
116    dependents: FxHashMap<ProjectId, Vec<ProjectId>>,
117}
118
119impl ProjectReferenceGraph {
120    /// Create a new empty graph
121    pub fn new() -> Self {
122        Self::default()
123    }
124
125    /// Load a project reference graph starting from a root tsconfig
126    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        // Start with the root project
132        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        // BFS to load all referenced projects
142        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            // Queue referenced projects for loading
152            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        // Build the reference edges
160        graph.build_edges()?;
161
162        Ok(graph)
163    }
164
165    /// Add a project to the graph
166    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    /// Build reference edges between projects
176    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    /// Get project by ID
198    pub fn get_project(&self, id: ProjectId) -> Option<&ResolvedProject> {
199        self.projects.get(id)
200    }
201
202    /// Get project ID by config path
203    pub fn get_project_id(&self, config_path: &Path) -> Option<ProjectId> {
204        self.path_to_id.get(config_path).copied()
205    }
206
207    /// Get all projects
208    pub fn projects(&self) -> &[ResolvedProject] {
209        &self.projects
210    }
211
212    /// Get the number of projects
213    pub const fn project_count(&self) -> usize {
214        self.projects.len()
215    }
216
217    /// Get direct references of a project
218    pub fn get_references(&self, id: ProjectId) -> &[ProjectId] {
219        self.references.get(&id).map_or(&[], |v| v.as_slice())
220    }
221
222    /// Get direct dependents of a project (projects that reference it)
223    pub fn get_dependents(&self, id: ProjectId) -> &[ProjectId] {
224        self.dependents.get(&id).map_or(&[], |v| v.as_slice())
225    }
226
227    /// Check for circular references
228    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                // Found a cycle - extract it from path
260                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    /// Get a topologically sorted build order
271    /// Returns Err if there are cycles that prevent ordering
272    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        // Kahn's algorithm for topological sort
293        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)| deg == 0)
306            .map(|(&id, _)| id)
307            .collect();
308        queue.sort(); // Deterministic order
309
310        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(); // Keep deterministic
323        }
324
325        // Reverse because we want dependencies first
326        order.reverse();
327        Ok(order)
328    }
329
330    /// Get all transitive dependencies of a project
331    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    /// Get all projects that would be affected by changes in a project
347    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
363/// Load a project from its tsconfig.json path
364pub 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    // Resolve project references
379    let resolved_references = resolve_project_references(&root_dir, &config.references)?;
380
381    // Check if composite - CompilerOptions doesn't have composite field,
382    // so we check the raw source JSON
383    let is_composite = check_composite_from_source(&source);
384
385    // Get output directories
386    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
412/// Parse tsconfig with references support
413pub 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
421/// Check if composite is set in the raw source (workaround for type limitations)
422fn check_composite_from_source(source: &str) -> bool {
423    // Use proper JSON parsing to extract the composite field
424    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
436/// Resolve project references to absolute paths
437fn 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
455/// Resolve a single project reference
456fn resolve_single_reference(
457    root_dir: &Path,
458    reference: &ProjectReference,
459) -> ResolvedProjectReference {
460    let ref_path = PathBuf::from(&reference.path);
461
462    // Make path absolute
463    let abs_path = if ref_path.is_absolute() {
464        ref_path
465    } else {
466        root_dir.join(&ref_path)
467    };
468
469    // Check if it's a directory or a file
470    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        // Assume directory and append tsconfig.json
476        abs_path.join("tsconfig.json")
477    };
478
479    // Canonicalize if possible
480    let canonical_path =
481        std::fs::canonicalize(&config_path).unwrap_or_else(|_| config_path.clone());
482
483    // Validate the reference exists
484    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
504/// Validate that a project meets composite requirements
505pub 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    // Composite projects must emit declarations
515    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    // Composite projects should have rootDir set
521    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    // Check that all references point to composite projects
526    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
538/// Get the declaration output path for a source file in a composite project
539pub 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    // Need either declarationDir or outDir
546    let out_base = project
547        .declaration_dir
548        .as_ref()
549        .or(project.out_dir.as_ref())?;
550
551    // Get the relative path from rootDir
552    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    // Change extension to .d.ts
560    let mut dts_path = out_base.join(relative);
561    dts_path.set_extension("d.ts");
562
563    Some(dts_path)
564}
565
566/// Resolve an import from a referencing project to a referenced project's declarations
567pub fn resolve_cross_project_import(
568    graph: &ProjectReferenceGraph,
569    from_project: ProjectId,
570    import_specifier: &str,
571) -> Option<PathBuf> {
572    // Check each referenced project
573    for &ref_id in graph.get_references(from_project) {
574        let ref_project = graph.get_project(ref_id)?;
575
576        // Try to resolve the import in the referenced project
577        if let Some(resolved) = try_resolve_in_project(ref_project, import_specifier) {
578            return Some(resolved);
579        }
580    }
581
582    None
583}
584
585/// Try to resolve an import specifier within a project
586fn try_resolve_in_project(project: &ResolvedProject, specifier: &str) -> Option<PathBuf> {
587    // Handle relative imports
588    if specifier.starts_with('.') {
589        // Would need full module resolution here
590        return None;
591    }
592
593    // Handle package-like imports
594    let out_dir = project
595        .declaration_dir
596        .as_ref()
597        .or(project.out_dir.as_ref())?;
598
599    // Try to find a matching .d.ts file
600    let dts_path = out_dir.join(specifier).with_extension("d.ts");
601    if dts_path.exists() {
602        return Some(dts_path);
603    }
604
605    // Try index.d.ts
606    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
614// Helper functions copied from config.rs (ideally these would be shared)
615fn 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;