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 let _previous_set: FxHashSet<_> = build_info.file_infos.keys().map(PathBuf::from).collect();
272
273 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 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 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, ¤t_version) {
300 self.changed_files.insert(file.clone());
301 self.affected_files.insert(file.clone());
302 }
303 }
304
305 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 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 pub fn compute_changes_with_base(
334 &mut self,
335 build_info: &BuildInfo,
336 current_files: &[PathBuf],
337 base_dir: &Path,
338 ) -> Result<()> {
339 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 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 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 = ¤t_files[i];
358 self.new_files.insert(abs_path.clone());
359 self.affected_files.insert(abs_path.clone());
360 }
361 }
362
363 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 for (i, file_rel) in current_files_relative.iter().enumerate() {
373 let abs_path = ¤t_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, ¤t_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 pub const fn changed_files(&self) -> &FxHashSet<PathBuf> {
392 &self.changed_files
393 }
394
395 pub const fn affected_files(&self) -> &FxHashSet<PathBuf> {
397 &self.affected_files
398 }
399
400 pub const fn new_files(&self) -> &FxHashSet<PathBuf> {
402 &self.new_files
403 }
404
405 pub const fn deleted_files(&self) -> &FxHashSet<PathBuf> {
407 &self.deleted_files
408 }
409
410 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 pub fn affected_count(&self) -> usize {
419 self.affected_files.len()
420 }
421}
422
423pub 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
438pub 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
451pub struct BuildInfoBuilder {
453 build_info: BuildInfo,
454 base_dir: PathBuf,
455}
456
457impl BuildInfoBuilder {
458 pub fn new(base_dir: PathBuf) -> Self {
460 Self {
461 build_info: BuildInfo::new(),
462 base_dir,
463 }
464 }
465
466 pub const fn from_existing(build_info: BuildInfo, base_dir: PathBuf) -> Self {
468 Self {
469 build_info,
470 base_dir,
471 }
472 }
473
474 pub fn set_root_files(&mut self, files: Vec<String>) -> &mut Self {
476 self.build_info.root_files = files;
477 self
478 }
479
480 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 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 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 pub fn set_options(&mut self, options: BuildInfoOptions) -> &mut Self {
534 self.build_info.options = options;
535 self
536 }
537
538 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 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
556pub 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;