Skip to main content

tsz_cli/
incremental.rs

1//! Incremental Compilation Support
2//!
3//! This module implements TypeScript's incremental compilation feature, which enables:
4//! - Faster rebuilds by caching compilation results
5//! - .tsbuildinfo file persistence for cross-session caching
6//! - Smart dependency tracking for minimal recompilation
7//!
8//! # Build Info Format
9//!
10//! The .tsbuildinfo file stores:
11//! - Version information for cache invalidation
12//! - File hashes for change detection
13//! - Dependency graphs between files
14//! - Emitted file signatures for output caching
15
16use anyhow::{Context, Result};
17use rustc_hash::FxHashSet;
18use serde::{Deserialize, Serialize};
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21use std::time::SystemTime;
22
23/// Version of the build info format
24pub const BUILD_INFO_VERSION: &str = "0.1.0";
25
26/// Build information persisted between compilations
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct BuildInfo {
30    /// Version of the build info format
31    pub version: String,
32    /// Compiler version that created this build info
33    pub compiler_version: String,
34    /// Root files that were compiled
35    pub root_files: Vec<String>,
36    /// Information about each compiled file
37    pub file_infos: BTreeMap<String, FileInfo>,
38    /// Dependency graph: file -> files it imports
39    pub dependencies: BTreeMap<String, Vec<String>>,
40    /// Semantic diagnostics for files (cached from previous builds)
41    #[serde(default)]
42    pub semantic_diagnostics_per_file: BTreeMap<String, Vec<CachedDiagnostic>>,
43    /// Emit output signatures (for output file caching)
44    pub emit_signatures: BTreeMap<String, EmitSignature>,
45    /// Path to the most recently changed .d.ts file
46    /// Used by project references for fast invalidation checking
47    #[serde(
48        rename = "latestChangedDtsFile",
49        skip_serializing_if = "Option::is_none"
50    )]
51    pub latest_changed_dts_file: Option<String>,
52    /// Options that affect compilation
53    #[serde(default)]
54    pub options: BuildInfoOptions,
55    /// Timestamp of when the build was completed
56    pub build_time: u64,
57}
58
59/// Information about a single compiled file
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub struct FileInfo {
63    /// File version (content hash or modification time)
64    pub version: String,
65    /// Signature of the file's exports (for dependency tracking)
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub signature: Option<String>,
68    /// Whether this file has changed since last build
69    #[serde(default)]
70    pub affected_files_pending_emit: bool,
71    /// The file's import dependencies
72    #[serde(default)]
73    pub implied_format: Option<String>,
74}
75
76/// Emit output signature for caching
77#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct EmitSignature {
80    /// Hash of the emitted JavaScript
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub js: Option<String>,
83    /// Hash of the emitted declaration file
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub dts: Option<String>,
86    /// Hash of the emitted source map
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub map: Option<String>,
89}
90
91/// Compiler options that affect build caching
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct BuildInfoOptions {
95    /// Target ECMAScript version
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub target: Option<String>,
98    /// Module system
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub module: Option<String>,
101    /// Whether to emit declarations
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub declaration: Option<bool>,
104    /// Strict mode enabled
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub strict: Option<bool>,
107}
108
109/// Cached diagnostic information for incremental builds
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct CachedDiagnostic {
113    pub file: String,
114    pub start: u32,
115    pub length: u32,
116    pub message_text: String,
117    pub category: u8,
118    pub code: u32,
119    #[serde(skip_serializing_if = "Vec::is_empty", default)]
120    pub related_information: Vec<CachedRelatedInformation>,
121}
122
123/// Cached related information for diagnostics
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct CachedRelatedInformation {
127    pub file: String,
128    pub start: u32,
129    pub length: u32,
130    pub message_text: String,
131    pub category: u8,
132    pub code: u32,
133}
134
135impl Default for BuildInfo {
136    fn default() -> Self {
137        Self {
138            version: BUILD_INFO_VERSION.to_string(),
139            compiler_version: env!("CARGO_PKG_VERSION").to_string(),
140            root_files: Vec::new(),
141            file_infos: BTreeMap::new(),
142            dependencies: BTreeMap::new(),
143            semantic_diagnostics_per_file: BTreeMap::new(),
144            emit_signatures: BTreeMap::new(),
145            latest_changed_dts_file: None,
146            options: BuildInfoOptions::default(),
147            build_time: SystemTime::now()
148                .duration_since(SystemTime::UNIX_EPOCH)
149                .map(|d| d.as_secs())
150                .unwrap_or(0),
151        }
152    }
153}
154
155impl BuildInfo {
156    /// Create a new empty build info
157    pub fn new() -> Self {
158        Self::default()
159    }
160
161    /// Load build info from a file
162    /// Returns Ok(None) if the file exists but is incompatible (version mismatch)
163    /// Returns `Ok(Some(build_info))` if the file is valid and compatible
164    pub fn load(path: &Path) -> Result<Option<Self>> {
165        let content = std::fs::read_to_string(path)
166            .with_context(|| format!("failed to read build info: {}", path.display()))?;
167
168        let build_info: Self = serde_json::from_str(&content)
169            .with_context(|| format!("failed to parse build info: {}", path.display()))?;
170
171        // Validate version compatibility (Format version)
172        if build_info.version != BUILD_INFO_VERSION {
173            return Ok(None);
174        }
175
176        // Validate compiler version compatibility
177        // This ensures changes in hashing algorithms or internal logic trigger a rebuild
178        if build_info.compiler_version != env!("CARGO_PKG_VERSION") {
179            return Ok(None);
180        }
181
182        Ok(Some(build_info))
183    }
184
185    /// Save build info to a file
186    pub fn save(&self, path: &Path) -> Result<()> {
187        // Create parent directories if needed
188        if let Some(parent) = path.parent() {
189            std::fs::create_dir_all(parent)
190                .with_context(|| format!("failed to create directory: {}", parent.display()))?;
191        }
192
193        let content =
194            serde_json::to_string_pretty(self).context("failed to serialize build info")?;
195
196        std::fs::write(path, content)
197            .with_context(|| format!("failed to write build info: {}", path.display()))?;
198
199        Ok(())
200    }
201
202    /// Add or update file info
203    pub fn set_file_info(&mut self, path: &str, info: FileInfo) {
204        self.file_infos.insert(path.to_string(), info);
205    }
206
207    /// Get file info
208    pub fn get_file_info(&self, path: &str) -> Option<&FileInfo> {
209        self.file_infos.get(path)
210    }
211
212    /// Set dependencies for a file
213    pub fn set_dependencies(&mut self, path: &str, deps: Vec<String>) {
214        self.dependencies.insert(path.to_string(), deps);
215    }
216
217    /// Get dependencies for a file
218    pub fn get_dependencies(&self, path: &str) -> Option<&[String]> {
219        self.dependencies.get(path).map(std::vec::Vec::as_slice)
220    }
221
222    /// Set emit signature for a file
223    pub fn set_emit_signature(&mut self, path: &str, signature: EmitSignature) {
224        self.emit_signatures.insert(path.to_string(), signature);
225    }
226
227    /// Check if a file has changed since last build
228    pub fn has_file_changed(&self, path: &str, current_version: &str) -> bool {
229        match self.file_infos.get(path) {
230            Some(info) => info.version != current_version,
231            None => true, // New file
232        }
233    }
234
235    /// Get all files that depend on a given file
236    pub fn get_dependents(&self, path: &str) -> Vec<String> {
237        self.dependencies
238            .iter()
239            .filter(|(_, deps)| deps.iter().any(|d| d == path))
240            .map(|(file, _)| file.clone())
241            .collect()
242    }
243}
244
245/// Tracks changes between builds
246#[derive(Debug, Default)]
247pub struct ChangeTracker {
248    /// Files that have been modified
249    changed_files: FxHashSet<PathBuf>,
250    /// Files that need to be recompiled (changed + dependents)
251    affected_files: FxHashSet<PathBuf>,
252    /// Files that are new since last build
253    new_files: FxHashSet<PathBuf>,
254    /// Files that have been deleted
255    deleted_files: FxHashSet<PathBuf>,
256}
257
258impl ChangeTracker {
259    /// Create a new change tracker
260    pub fn new() -> Self {
261        Self::default()
262    }
263
264    /// Compute changes by comparing current files with build info
265    pub fn compute_changes(
266        &mut self,
267        build_info: &BuildInfo,
268        current_files: &[PathBuf],
269    ) -> Result<()> {
270        let current_set: FxHashSet<_> = current_files.iter().collect();
271
272        // Find new files
273        for file in current_files {
274            let path_str = file.to_string_lossy();
275            if !build_info.file_infos.contains_key(path_str.as_ref()) {
276                self.new_files.insert(file.clone());
277                self.affected_files.insert(file.clone());
278            }
279        }
280
281        // Find deleted files
282        for path_str in build_info.file_infos.keys() {
283            let path = PathBuf::from(path_str);
284            if !current_set.contains(&path) {
285                self.deleted_files.insert(path);
286            }
287        }
288
289        // Check for modified files
290        for file in current_files {
291            if self.new_files.contains(file) {
292                continue;
293            }
294
295            let current_version = compute_file_version(file)?;
296            let path_str = file.to_string_lossy();
297
298            if build_info.has_file_changed(&path_str, &current_version) {
299                self.changed_files.insert(file.clone());
300                self.affected_files.insert(file.clone());
301            }
302        }
303
304        // Add dependents of changed files
305        let mut dependents_to_add = Vec::new();
306        for changed in &self.changed_files {
307            let path_str = changed.to_string_lossy();
308            for dep in build_info.get_dependents(&path_str) {
309                dependents_to_add.push(PathBuf::from(dep));
310            }
311        }
312
313        // Also handle deleted file dependents
314        for deleted in &self.deleted_files {
315            let path_str = deleted.to_string_lossy();
316            for dep in build_info.get_dependents(&path_str) {
317                dependents_to_add.push(PathBuf::from(dep));
318            }
319        }
320
321        for dep in dependents_to_add {
322            if current_set.contains(&dep) {
323                self.affected_files.insert(dep);
324            }
325        }
326
327        Ok(())
328    }
329
330    /// Compute changes with absolute file paths
331    /// Automatically normalizes paths relative to `base_dir` for comparison with `BuildInfo`
332    pub fn compute_changes_with_base(
333        &mut self,
334        build_info: &BuildInfo,
335        current_files: &[PathBuf],
336        base_dir: &Path,
337    ) -> Result<()> {
338        // Normalize absolute paths to relative paths for BuildInfo comparison
339        let current_files_relative: Vec<PathBuf> = current_files
340            .iter()
341            .filter_map(|path| {
342                path.strip_prefix(base_dir)
343                    .ok()
344                    .map(std::path::Path::to_path_buf)
345            })
346            .collect();
347
348        // Compute changes using relative paths, but store absolute paths in results
349        let current_set: FxHashSet<_> = current_files_relative.iter().collect();
350
351        // Find new files
352        for (i, file_rel) in current_files_relative.iter().enumerate() {
353            let path_str = file_rel.to_string_lossy();
354            if !build_info.file_infos.contains_key(path_str.as_ref()) {
355                let abs_path = &current_files[i];
356                self.new_files.insert(abs_path.clone());
357                self.affected_files.insert(abs_path.clone());
358            }
359        }
360
361        // Find deleted files
362        for path_str in build_info.file_infos.keys() {
363            let path = PathBuf::from(path_str);
364            if !current_set.contains(&path) {
365                self.deleted_files.insert(path);
366            }
367        }
368
369        // Check for modified files
370        for (i, file_rel) in current_files_relative.iter().enumerate() {
371            let abs_path = &current_files[i];
372            if self.new_files.contains(abs_path) {
373                continue;
374            }
375
376            let current_version = compute_file_version(abs_path)?;
377            let path_str = file_rel.to_string_lossy();
378
379            if build_info.has_file_changed(&path_str, &current_version) {
380                self.changed_files.insert(abs_path.clone());
381                self.affected_files.insert(abs_path.clone());
382            }
383        }
384
385        Ok(())
386    }
387
388    /// Get files that have changed
389    pub const fn changed_files(&self) -> &FxHashSet<PathBuf> {
390        &self.changed_files
391    }
392
393    /// Get all files that need to be recompiled
394    pub const fn affected_files(&self) -> &FxHashSet<PathBuf> {
395        &self.affected_files
396    }
397
398    /// Get new files
399    pub const fn new_files(&self) -> &FxHashSet<PathBuf> {
400        &self.new_files
401    }
402
403    /// Get deleted files
404    pub const fn deleted_files(&self) -> &FxHashSet<PathBuf> {
405        &self.deleted_files
406    }
407
408    /// Check if any files have changed
409    pub fn has_changes(&self) -> bool {
410        !self.changed_files.is_empty()
411            || !self.new_files.is_empty()
412            || !self.deleted_files.is_empty()
413    }
414
415    /// Get total number of affected files
416    pub fn affected_count(&self) -> usize {
417        self.affected_files.len()
418    }
419}
420
421/// Compute a version string for a file (content hash)
422pub fn compute_file_version(path: &Path) -> Result<String> {
423    use std::collections::hash_map::DefaultHasher;
424    use std::hash::{Hash, Hasher};
425
426    let content =
427        std::fs::read(path).with_context(|| format!("failed to read file: {}", path.display()))?;
428
429    let mut hasher = DefaultHasher::new();
430    content.hash(&mut hasher);
431    let hash = hasher.finish();
432
433    Ok(format!("{hash:016x}"))
434}
435
436/// Compute a signature for a file's exports (for dependency tracking)
437pub fn compute_export_signature(exports: &[String]) -> String {
438    use std::collections::hash_map::DefaultHasher;
439    use std::hash::{Hash, Hasher};
440
441    let mut hasher = DefaultHasher::new();
442    for export in exports {
443        export.hash(&mut hasher);
444    }
445
446    format!("{:016x}", hasher.finish())
447}
448
449/// Builder for creating build info incrementally
450pub struct BuildInfoBuilder {
451    build_info: BuildInfo,
452    base_dir: PathBuf,
453}
454
455impl BuildInfoBuilder {
456    /// Create a new builder
457    pub fn new(base_dir: PathBuf) -> Self {
458        Self {
459            build_info: BuildInfo::new(),
460            base_dir,
461        }
462    }
463
464    /// Create a builder from existing build info
465    pub const fn from_existing(build_info: BuildInfo, base_dir: PathBuf) -> Self {
466        Self {
467            build_info,
468            base_dir,
469        }
470    }
471
472    /// Set root files
473    pub fn set_root_files(&mut self, files: Vec<String>) -> &mut Self {
474        self.build_info.root_files = files;
475        self
476    }
477
478    /// Add a file to the build info
479    pub fn add_file(&mut self, path: &Path, exports: &[String]) -> Result<&mut Self> {
480        let relative_path = self.relative_path(path);
481        let version = compute_file_version(path)?;
482        let signature = if exports.is_empty() {
483            None
484        } else {
485            Some(compute_export_signature(exports))
486        };
487
488        self.build_info.set_file_info(
489            &relative_path,
490            FileInfo {
491                version,
492                signature,
493                affected_files_pending_emit: false,
494                implied_format: None,
495            },
496        );
497
498        Ok(self)
499    }
500
501    /// Set dependencies for a file
502    pub fn set_file_dependencies(&mut self, path: &Path, deps: Vec<PathBuf>) -> &mut Self {
503        let relative_path = self.relative_path(path);
504        let relative_deps: Vec<String> = deps.iter().map(|d| self.relative_path(d)).collect();
505
506        self.build_info
507            .set_dependencies(&relative_path, relative_deps);
508        self
509    }
510
511    /// Set emit signature for a file
512    pub fn set_file_emit(
513        &mut self,
514        path: &Path,
515        js_hash: Option<&str>,
516        dts_hash: Option<&str>,
517    ) -> &mut Self {
518        let relative_path = self.relative_path(path);
519        self.build_info.set_emit_signature(
520            &relative_path,
521            EmitSignature {
522                js: js_hash.map(String::from),
523                dts: dts_hash.map(String::from),
524                map: None,
525            },
526        );
527        self
528    }
529
530    /// Set compiler options
531    pub fn set_options(&mut self, options: BuildInfoOptions) -> &mut Self {
532        self.build_info.options = options;
533        self
534    }
535
536    /// Build the final build info
537    pub fn build(mut self) -> BuildInfo {
538        self.build_info.build_time = SystemTime::now()
539            .duration_since(SystemTime::UNIX_EPOCH)
540            .map(|d| d.as_secs())
541            .unwrap_or(0);
542        self.build_info
543    }
544
545    /// Get a relative path from the base directory
546    fn relative_path(&self, path: &Path) -> String {
547        path.strip_prefix(&self.base_dir)
548            .unwrap_or(path)
549            .to_string_lossy()
550            .replace('\\', "/")
551    }
552}
553
554/// Determine the default .tsbuildinfo path based on configuration
555pub fn default_build_info_path(config_path: &Path, out_dir: Option<&Path>) -> PathBuf {
556    let config_name = config_path
557        .file_stem()
558        .and_then(|s| s.to_str())
559        .unwrap_or("tsconfig");
560
561    let build_info_name = format!("{config_name}.tsbuildinfo");
562
563    if let Some(out) = out_dir {
564        out.join(&build_info_name)
565    } else {
566        config_path
567            .parent()
568            .unwrap_or_else(|| Path::new("."))
569            .join(&build_info_name)
570    }
571}
572
573#[cfg(test)]
574#[path = "incremental_tests.rs"]
575mod tests;