1use 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
23pub const BUILD_INFO_VERSION: &str = "0.1.0";
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct BuildInfo {
30 pub version: String,
32 pub compiler_version: String,
34 pub root_files: Vec<String>,
36 pub file_infos: BTreeMap<String, FileInfo>,
38 pub dependencies: BTreeMap<String, Vec<String>>,
40 #[serde(default)]
42 pub semantic_diagnostics_per_file: BTreeMap<String, Vec<CachedDiagnostic>>,
43 pub emit_signatures: BTreeMap<String, EmitSignature>,
45 #[serde(
48 rename = "latestChangedDtsFile",
49 skip_serializing_if = "Option::is_none"
50 )]
51 pub latest_changed_dts_file: Option<String>,
52 #[serde(default)]
54 pub options: BuildInfoOptions,
55 pub build_time: u64,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub struct FileInfo {
63 pub version: String,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub signature: Option<String>,
68 #[serde(default)]
70 pub affected_files_pending_emit: bool,
71 #[serde(default)]
73 pub implied_format: Option<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct EmitSignature {
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub js: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub dts: Option<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub map: Option<String>,
89}
90
91#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct BuildInfoOptions {
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub target: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub module: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub declaration: Option<bool>,
104 #[serde(skip_serializing_if = "Option::is_none")]
106 pub strict: Option<bool>,
107}
108
109#[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#[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 pub fn new() -> Self {
158 Self::default()
159 }
160
161 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 if build_info.version != BUILD_INFO_VERSION {
173 return Ok(None);
174 }
175
176 if build_info.compiler_version != env!("CARGO_PKG_VERSION") {
179 return Ok(None);
180 }
181
182 Ok(Some(build_info))
183 }
184
185 pub fn save(&self, path: &Path) -> Result<()> {
187 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 pub fn set_file_info(&mut self, path: &str, info: FileInfo) {
204 self.file_infos.insert(path.to_string(), info);
205 }
206
207 pub fn get_file_info(&self, path: &str) -> Option<&FileInfo> {
209 self.file_infos.get(path)
210 }
211
212 pub fn set_dependencies(&mut self, path: &str, deps: Vec<String>) {
214 self.dependencies.insert(path.to_string(), deps);
215 }
216
217 pub fn get_dependencies(&self, path: &str) -> Option<&[String]> {
219 self.dependencies.get(path).map(std::vec::Vec::as_slice)
220 }
221
222 pub fn set_emit_signature(&mut self, path: &str, signature: EmitSignature) {
224 self.emit_signatures.insert(path.to_string(), signature);
225 }
226
227 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, }
233 }
234
235 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#[derive(Debug, Default)]
247pub struct ChangeTracker {
248 changed_files: FxHashSet<PathBuf>,
250 affected_files: FxHashSet<PathBuf>,
252 new_files: FxHashSet<PathBuf>,
254 deleted_files: FxHashSet<PathBuf>,
256}
257
258impl ChangeTracker {
259 pub fn new() -> Self {
261 Self::default()
262 }
263
264 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 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 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 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, ¤t_version) {
299 self.changed_files.insert(file.clone());
300 self.affected_files.insert(file.clone());
301 }
302 }
303
304 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 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 pub fn compute_changes_with_base(
333 &mut self,
334 build_info: &BuildInfo,
335 current_files: &[PathBuf],
336 base_dir: &Path,
337 ) -> Result<()> {
338 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 let current_set: FxHashSet<_> = current_files_relative.iter().collect();
350
351 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 = ¤t_files[i];
356 self.new_files.insert(abs_path.clone());
357 self.affected_files.insert(abs_path.clone());
358 }
359 }
360
361 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 for (i, file_rel) in current_files_relative.iter().enumerate() {
371 let abs_path = ¤t_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, ¤t_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 pub const fn changed_files(&self) -> &FxHashSet<PathBuf> {
390 &self.changed_files
391 }
392
393 pub const fn affected_files(&self) -> &FxHashSet<PathBuf> {
395 &self.affected_files
396 }
397
398 pub const fn new_files(&self) -> &FxHashSet<PathBuf> {
400 &self.new_files
401 }
402
403 pub const fn deleted_files(&self) -> &FxHashSet<PathBuf> {
405 &self.deleted_files
406 }
407
408 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 pub fn affected_count(&self) -> usize {
417 self.affected_files.len()
418 }
419}
420
421pub 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
436pub 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
449pub struct BuildInfoBuilder {
451 build_info: BuildInfo,
452 base_dir: PathBuf,
453}
454
455impl BuildInfoBuilder {
456 pub fn new(base_dir: PathBuf) -> Self {
458 Self {
459 build_info: BuildInfo::new(),
460 base_dir,
461 }
462 }
463
464 pub const fn from_existing(build_info: BuildInfo, base_dir: PathBuf) -> Self {
466 Self {
467 build_info,
468 base_dir,
469 }
470 }
471
472 pub fn set_root_files(&mut self, files: Vec<String>) -> &mut Self {
474 self.build_info.root_files = files;
475 self
476 }
477
478 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 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 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 pub fn set_options(&mut self, options: BuildInfoOptions) -> &mut Self {
532 self.build_info.options = options;
533 self
534 }
535
536 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 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
554pub 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;