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        let _previous_set: FxHashSet<_> = build_info.file_infos.keys().map(PathBuf::from).collect();
272
273        // Find new files
274        for file in current_files {
275            let path_str = file.to_string_lossy();
276            if !build_info.file_infos.contains_key(path_str.as_ref()) {
277                self.new_files.insert(file.clone());
278                self.affected_files.insert(file.clone());
279            }
280        }
281
282        // Find deleted files
283        for path_str in build_info.file_infos.keys() {
284            let path = PathBuf::from(path_str);
285            if !current_set.contains(&path) {
286                self.deleted_files.insert(path);
287            }
288        }
289
290        // Check for modified files
291        for file in current_files {
292            if self.new_files.contains(file) {
293                continue;
294            }
295
296            let current_version = compute_file_version(file)?;
297            let path_str = file.to_string_lossy();
298
299            if build_info.has_file_changed(&path_str, &current_version) {
300                self.changed_files.insert(file.clone());
301                self.affected_files.insert(file.clone());
302            }
303        }
304
305        // Add dependents of changed files
306        let mut dependents_to_add = Vec::new();
307        for changed in &self.changed_files {
308            let path_str = changed.to_string_lossy();
309            for dep in build_info.get_dependents(&path_str) {
310                dependents_to_add.push(PathBuf::from(dep));
311            }
312        }
313
314        // Also handle deleted file dependents
315        for deleted in &self.deleted_files {
316            let path_str = deleted.to_string_lossy();
317            for dep in build_info.get_dependents(&path_str) {
318                dependents_to_add.push(PathBuf::from(dep));
319            }
320        }
321
322        for dep in dependents_to_add {
323            if current_set.contains(&dep) {
324                self.affected_files.insert(dep);
325            }
326        }
327
328        Ok(())
329    }
330
331    /// Compute changes with absolute file paths
332    /// Automatically normalizes paths relative to `base_dir` for comparison with `BuildInfo`
333    pub fn compute_changes_with_base(
334        &mut self,
335        build_info: &BuildInfo,
336        current_files: &[PathBuf],
337        base_dir: &Path,
338    ) -> Result<()> {
339        // Normalize absolute paths to relative paths for BuildInfo comparison
340        let current_files_relative: Vec<PathBuf> = current_files
341            .iter()
342            .filter_map(|path| {
343                path.strip_prefix(base_dir)
344                    .ok()
345                    .map(std::path::Path::to_path_buf)
346            })
347            .collect();
348
349        // Compute changes using relative paths, but store absolute paths in results
350        let current_set: FxHashSet<_> = current_files_relative.iter().collect();
351        let _previous_set: FxHashSet<_> = build_info.file_infos.keys().map(PathBuf::from).collect();
352
353        // Find new files
354        for (i, file_rel) in current_files_relative.iter().enumerate() {
355            let path_str = file_rel.to_string_lossy();
356            if !build_info.file_infos.contains_key(path_str.as_ref()) {
357                let abs_path = &current_files[i];
358                self.new_files.insert(abs_path.clone());
359                self.affected_files.insert(abs_path.clone());
360            }
361        }
362
363        // Find deleted files
364        for path_str in build_info.file_infos.keys() {
365            let path = PathBuf::from(path_str);
366            if !current_set.contains(&path) {
367                self.deleted_files.insert(path);
368            }
369        }
370
371        // Check for modified files
372        for (i, file_rel) in current_files_relative.iter().enumerate() {
373            let abs_path = &current_files[i];
374            if self.new_files.contains(abs_path) {
375                continue;
376            }
377
378            let current_version = compute_file_version(abs_path)?;
379            let path_str = file_rel.to_string_lossy();
380
381            if build_info.has_file_changed(&path_str, &current_version) {
382                self.changed_files.insert(abs_path.clone());
383                self.affected_files.insert(abs_path.clone());
384            }
385        }
386
387        Ok(())
388    }
389
390    /// Get files that have changed
391    pub const fn changed_files(&self) -> &FxHashSet<PathBuf> {
392        &self.changed_files
393    }
394
395    /// Get all files that need to be recompiled
396    pub const fn affected_files(&self) -> &FxHashSet<PathBuf> {
397        &self.affected_files
398    }
399
400    /// Get new files
401    pub const fn new_files(&self) -> &FxHashSet<PathBuf> {
402        &self.new_files
403    }
404
405    /// Get deleted files
406    pub const fn deleted_files(&self) -> &FxHashSet<PathBuf> {
407        &self.deleted_files
408    }
409
410    /// Check if any files have changed
411    pub fn has_changes(&self) -> bool {
412        !self.changed_files.is_empty()
413            || !self.new_files.is_empty()
414            || !self.deleted_files.is_empty()
415    }
416
417    /// Get total number of affected files
418    pub fn affected_count(&self) -> usize {
419        self.affected_files.len()
420    }
421}
422
423/// Compute a version string for a file (content hash)
424pub fn compute_file_version(path: &Path) -> Result<String> {
425    use std::collections::hash_map::DefaultHasher;
426    use std::hash::{Hash, Hasher};
427
428    let content =
429        std::fs::read(path).with_context(|| format!("failed to read file: {}", path.display()))?;
430
431    let mut hasher = DefaultHasher::new();
432    content.hash(&mut hasher);
433    let hash = hasher.finish();
434
435    Ok(format!("{hash:016x}"))
436}
437
438/// Compute a signature for a file's exports (for dependency tracking)
439pub fn compute_export_signature(exports: &[String]) -> String {
440    use std::collections::hash_map::DefaultHasher;
441    use std::hash::{Hash, Hasher};
442
443    let mut hasher = DefaultHasher::new();
444    for export in exports {
445        export.hash(&mut hasher);
446    }
447
448    format!("{:016x}", hasher.finish())
449}
450
451/// Builder for creating build info incrementally
452pub struct BuildInfoBuilder {
453    build_info: BuildInfo,
454    base_dir: PathBuf,
455}
456
457impl BuildInfoBuilder {
458    /// Create a new builder
459    pub fn new(base_dir: PathBuf) -> Self {
460        Self {
461            build_info: BuildInfo::new(),
462            base_dir,
463        }
464    }
465
466    /// Create a builder from existing build info
467    pub const fn from_existing(build_info: BuildInfo, base_dir: PathBuf) -> Self {
468        Self {
469            build_info,
470            base_dir,
471        }
472    }
473
474    /// Set root files
475    pub fn set_root_files(&mut self, files: Vec<String>) -> &mut Self {
476        self.build_info.root_files = files;
477        self
478    }
479
480    /// Add a file to the build info
481    pub fn add_file(&mut self, path: &Path, exports: &[String]) -> Result<&mut Self> {
482        let relative_path = self.relative_path(path);
483        let version = compute_file_version(path)?;
484        let signature = if exports.is_empty() {
485            None
486        } else {
487            Some(compute_export_signature(exports))
488        };
489
490        self.build_info.set_file_info(
491            &relative_path,
492            FileInfo {
493                version,
494                signature,
495                affected_files_pending_emit: false,
496                implied_format: None,
497            },
498        );
499
500        Ok(self)
501    }
502
503    /// Set dependencies for a file
504    pub fn set_file_dependencies(&mut self, path: &Path, deps: Vec<PathBuf>) -> &mut Self {
505        let relative_path = self.relative_path(path);
506        let relative_deps: Vec<String> = deps.iter().map(|d| self.relative_path(d)).collect();
507
508        self.build_info
509            .set_dependencies(&relative_path, relative_deps);
510        self
511    }
512
513    /// Set emit signature for a file
514    pub fn set_file_emit(
515        &mut self,
516        path: &Path,
517        js_hash: Option<&str>,
518        dts_hash: Option<&str>,
519    ) -> &mut Self {
520        let relative_path = self.relative_path(path);
521        self.build_info.set_emit_signature(
522            &relative_path,
523            EmitSignature {
524                js: js_hash.map(String::from),
525                dts: dts_hash.map(String::from),
526                map: None,
527            },
528        );
529        self
530    }
531
532    /// Set compiler options
533    pub fn set_options(&mut self, options: BuildInfoOptions) -> &mut Self {
534        self.build_info.options = options;
535        self
536    }
537
538    /// Build the final build info
539    pub fn build(mut self) -> BuildInfo {
540        self.build_info.build_time = SystemTime::now()
541            .duration_since(SystemTime::UNIX_EPOCH)
542            .map(|d| d.as_secs())
543            .unwrap_or(0);
544        self.build_info
545    }
546
547    /// Get a relative path from the base directory
548    fn relative_path(&self, path: &Path) -> String {
549        path.strip_prefix(&self.base_dir)
550            .unwrap_or(path)
551            .to_string_lossy()
552            .replace('\\', "/")
553    }
554}
555
556/// Determine the default .tsbuildinfo path based on configuration
557pub fn default_build_info_path(config_path: &Path, out_dir: Option<&Path>) -> PathBuf {
558    let config_name = config_path
559        .file_stem()
560        .and_then(|s| s.to_str())
561        .unwrap_or("tsconfig");
562
563    let build_info_name = format!("{config_name}.tsbuildinfo");
564
565    if let Some(out) = out_dir {
566        out.join(&build_info_name)
567    } else {
568        config_path
569            .parent()
570            .unwrap_or_else(|| Path::new("."))
571            .join(&build_info_name)
572    }
573}
574
575#[cfg(test)]
576#[path = "incremental_tests.rs"]
577mod tests;