use anyhow::{Context, Result};
use rustc_hash::FxHashSet;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
pub const BUILD_INFO_VERSION: &str = "0.1.0";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildInfo {
pub version: String,
pub compiler_version: String,
pub root_files: Vec<String>,
pub file_infos: BTreeMap<String, FileInfo>,
pub dependencies: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub semantic_diagnostics_per_file: BTreeMap<String, Vec<CachedDiagnostic>>,
pub emit_signatures: BTreeMap<String, EmitSignature>,
#[serde(
rename = "latestChangedDtsFile",
skip_serializing_if = "Option::is_none"
)]
pub latest_changed_dts_file: Option<String>,
#[serde(default)]
pub options: BuildInfoOptions,
pub build_time: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileInfo {
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(default)]
pub affected_files_pending_emit: bool,
#[serde(default)]
pub implied_format: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmitSignature {
#[serde(skip_serializing_if = "Option::is_none")]
pub js: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dts: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub map: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildInfoOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub module: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub declaration: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CachedDiagnostic {
pub file: String,
pub start: u32,
pub length: u32,
pub message_text: String,
pub category: u8,
pub code: u32,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub related_information: Vec<CachedRelatedInformation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CachedRelatedInformation {
pub file: String,
pub start: u32,
pub length: u32,
pub message_text: String,
pub category: u8,
pub code: u32,
}
impl Default for BuildInfo {
fn default() -> Self {
Self {
version: BUILD_INFO_VERSION.to_string(),
compiler_version: env!("CARGO_PKG_VERSION").to_string(),
root_files: Vec::new(),
file_infos: BTreeMap::new(),
dependencies: BTreeMap::new(),
semantic_diagnostics_per_file: BTreeMap::new(),
emit_signatures: BTreeMap::new(),
latest_changed_dts_file: None,
options: BuildInfoOptions::default(),
build_time: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
}
}
}
impl BuildInfo {
pub fn new() -> Self {
Self::default()
}
pub fn load(path: &Path) -> Result<Option<Self>> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read build info: {}", path.display()))?;
let build_info: Self = serde_json::from_str(&content)
.with_context(|| format!("failed to parse build info: {}", path.display()))?;
if build_info.version != BUILD_INFO_VERSION {
return Ok(None);
}
if build_info.compiler_version != env!("CARGO_PKG_VERSION") {
return Ok(None);
}
Ok(Some(build_info))
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory: {}", parent.display()))?;
}
let content =
serde_json::to_string_pretty(self).context("failed to serialize build info")?;
std::fs::write(path, content)
.with_context(|| format!("failed to write build info: {}", path.display()))?;
Ok(())
}
pub fn set_file_info(&mut self, path: &str, info: FileInfo) {
self.file_infos.insert(path.to_string(), info);
}
pub fn get_file_info(&self, path: &str) -> Option<&FileInfo> {
self.file_infos.get(path)
}
pub fn set_dependencies(&mut self, path: &str, deps: Vec<String>) {
self.dependencies.insert(path.to_string(), deps);
}
pub fn get_dependencies(&self, path: &str) -> Option<&[String]> {
self.dependencies.get(path).map(std::vec::Vec::as_slice)
}
pub fn set_emit_signature(&mut self, path: &str, signature: EmitSignature) {
self.emit_signatures.insert(path.to_string(), signature);
}
pub fn has_file_changed(&self, path: &str, current_version: &str) -> bool {
match self.file_infos.get(path) {
Some(info) => info.version != current_version,
None => true, }
}
pub fn get_dependents(&self, path: &str) -> Vec<String> {
self.dependencies
.iter()
.filter(|(_, deps)| deps.iter().any(|d| d == path))
.map(|(file, _)| file.clone())
.collect()
}
}
#[derive(Debug, Default)]
pub struct ChangeTracker {
changed_files: FxHashSet<PathBuf>,
affected_files: FxHashSet<PathBuf>,
new_files: FxHashSet<PathBuf>,
deleted_files: FxHashSet<PathBuf>,
}
impl ChangeTracker {
pub fn new() -> Self {
Self::default()
}
pub fn compute_changes(
&mut self,
build_info: &BuildInfo,
current_files: &[PathBuf],
) -> Result<()> {
let current_set: FxHashSet<_> = current_files.iter().collect();
for file in current_files {
let path_str = file.to_string_lossy();
if !build_info.file_infos.contains_key(path_str.as_ref()) {
self.new_files.insert(file.clone());
self.affected_files.insert(file.clone());
}
}
for path_str in build_info.file_infos.keys() {
let path = PathBuf::from(path_str);
if !current_set.contains(&path) {
self.deleted_files.insert(path);
}
}
for file in current_files {
if self.new_files.contains(file) {
continue;
}
let current_version = compute_file_version(file)?;
let path_str = file.to_string_lossy();
if build_info.has_file_changed(&path_str, ¤t_version) {
self.changed_files.insert(file.clone());
self.affected_files.insert(file.clone());
}
}
let mut dependents_to_add = Vec::new();
for changed in &self.changed_files {
let path_str = changed.to_string_lossy();
for dep in build_info.get_dependents(&path_str) {
dependents_to_add.push(PathBuf::from(dep));
}
}
for deleted in &self.deleted_files {
let path_str = deleted.to_string_lossy();
for dep in build_info.get_dependents(&path_str) {
dependents_to_add.push(PathBuf::from(dep));
}
}
for dep in dependents_to_add {
if current_set.contains(&dep) {
self.affected_files.insert(dep);
}
}
Ok(())
}
pub fn compute_changes_with_base(
&mut self,
build_info: &BuildInfo,
current_files: &[PathBuf],
base_dir: &Path,
) -> Result<()> {
let current_files_relative: Vec<PathBuf> = current_files
.iter()
.filter_map(|path| {
path.strip_prefix(base_dir)
.ok()
.map(std::path::Path::to_path_buf)
})
.collect();
let current_set: FxHashSet<_> = current_files_relative.iter().collect();
for (i, file_rel) in current_files_relative.iter().enumerate() {
let path_str = file_rel.to_string_lossy();
if !build_info.file_infos.contains_key(path_str.as_ref()) {
let abs_path = ¤t_files[i];
self.new_files.insert(abs_path.clone());
self.affected_files.insert(abs_path.clone());
}
}
for path_str in build_info.file_infos.keys() {
let path = PathBuf::from(path_str);
if !current_set.contains(&path) {
self.deleted_files.insert(path);
}
}
for (i, file_rel) in current_files_relative.iter().enumerate() {
let abs_path = ¤t_files[i];
if self.new_files.contains(abs_path) {
continue;
}
let current_version = compute_file_version(abs_path)?;
let path_str = file_rel.to_string_lossy();
if build_info.has_file_changed(&path_str, ¤t_version) {
self.changed_files.insert(abs_path.clone());
self.affected_files.insert(abs_path.clone());
}
}
Ok(())
}
pub const fn changed_files(&self) -> &FxHashSet<PathBuf> {
&self.changed_files
}
pub const fn affected_files(&self) -> &FxHashSet<PathBuf> {
&self.affected_files
}
pub const fn new_files(&self) -> &FxHashSet<PathBuf> {
&self.new_files
}
pub const fn deleted_files(&self) -> &FxHashSet<PathBuf> {
&self.deleted_files
}
pub fn has_changes(&self) -> bool {
!self.changed_files.is_empty()
|| !self.new_files.is_empty()
|| !self.deleted_files.is_empty()
}
pub fn affected_count(&self) -> usize {
self.affected_files.len()
}
}
pub fn compute_file_version(path: &Path) -> Result<String> {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let content =
std::fs::read(path).with_context(|| format!("failed to read file: {}", path.display()))?;
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
let hash = hasher.finish();
Ok(format!("{hash:016x}"))
}
pub fn compute_export_signature(exports: &[String]) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
for export in exports {
export.hash(&mut hasher);
}
format!("{:016x}", hasher.finish())
}
pub struct BuildInfoBuilder {
build_info: BuildInfo,
base_dir: PathBuf,
}
impl BuildInfoBuilder {
pub fn new(base_dir: PathBuf) -> Self {
Self {
build_info: BuildInfo::new(),
base_dir,
}
}
pub const fn from_existing(build_info: BuildInfo, base_dir: PathBuf) -> Self {
Self {
build_info,
base_dir,
}
}
pub fn set_root_files(&mut self, files: Vec<String>) -> &mut Self {
self.build_info.root_files = files;
self
}
pub fn add_file(&mut self, path: &Path, exports: &[String]) -> Result<&mut Self> {
let relative_path = self.relative_path(path);
let version = compute_file_version(path)?;
let signature = if exports.is_empty() {
None
} else {
Some(compute_export_signature(exports))
};
self.build_info.set_file_info(
&relative_path,
FileInfo {
version,
signature,
affected_files_pending_emit: false,
implied_format: None,
},
);
Ok(self)
}
pub fn set_file_dependencies(&mut self, path: &Path, deps: Vec<PathBuf>) -> &mut Self {
let relative_path = self.relative_path(path);
let relative_deps: Vec<String> = deps.iter().map(|d| self.relative_path(d)).collect();
self.build_info
.set_dependencies(&relative_path, relative_deps);
self
}
pub fn set_file_emit(
&mut self,
path: &Path,
js_hash: Option<&str>,
dts_hash: Option<&str>,
) -> &mut Self {
let relative_path = self.relative_path(path);
self.build_info.set_emit_signature(
&relative_path,
EmitSignature {
js: js_hash.map(String::from),
dts: dts_hash.map(String::from),
map: None,
},
);
self
}
pub fn set_options(&mut self, options: BuildInfoOptions) -> &mut Self {
self.build_info.options = options;
self
}
pub fn build(mut self) -> BuildInfo {
self.build_info.build_time = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.build_info
}
fn relative_path(&self, path: &Path) -> String {
path.strip_prefix(&self.base_dir)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
}
pub fn default_build_info_path(config_path: &Path, out_dir: Option<&Path>) -> PathBuf {
let config_name = config_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("tsconfig");
let build_info_name = format!("{config_name}.tsbuildinfo");
if let Some(out) = out_dir {
out.join(&build_info_name)
} else {
config_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(&build_info_name)
}
}
#[cfg(test)]
#[path = "incremental_tests.rs"]
mod tests;