diff --git a/rewatch/src/build.rs b/rewatch/src/build.rs index 1a93b2bf36..43fcdcdf23 100644 --- a/rewatch/src/build.rs +++ b/rewatch/src/build.rs @@ -1,6 +1,7 @@ pub mod build_types; pub mod clean; pub mod compile; +pub mod compiler_info; pub mod deps; pub mod logs; pub mod namespaces; @@ -10,6 +11,7 @@ pub mod read_compile_state; use self::parse::parser_args; use crate::build::compile::{mark_modules_with_deleted_deps_dirty, mark_modules_with_expired_deps_dirty}; +use crate::build::compiler_info::{CompilerCheckResult, verify_compiler_info, write_compiler_info}; use crate::helpers::emojis::*; use crate::helpers::{self}; use crate::project_context::ProjectContext; @@ -109,6 +111,20 @@ pub fn get_compiler_args(rescript_file_path: &Path) -> Result { Ok(result) } +pub fn get_compiler_info(project_context: &ProjectContext) -> Result { + let bsc_path = helpers::get_bsc(); + let bsc_hash = helpers::compute_file_hash(&bsc_path).ok_or(anyhow!( + "Failed to compute bsc hash for {}", + bsc_path.to_string_lossy() + ))?; + let runtime_path = compile::get_runtime_path(&project_context.current_config, project_context)?; + Ok(CompilerInfo { + bsc_path, + bsc_hash, + runtime_path, + }) +} + pub fn initialize_build( default_timing: Option, filter: &Option, @@ -117,8 +133,8 @@ pub fn initialize_build( build_dev_deps: bool, snapshot_output: bool, ) -> Result { - let bsc_path = helpers::get_bsc(); let project_context = ProjectContext::new(path)?; + let compiler = get_compiler_info(&project_context)?; if !snapshot_output && show_progress { print!("{} {}Building package tree...", style("[1/7]").bold().dim(), TREE); @@ -129,6 +145,8 @@ pub fn initialize_build( let packages = packages::make(filter, &project_context, show_progress, build_dev_deps)?; let timing_package_tree_elapsed = timing_package_tree.elapsed(); + let compiler_check = verify_compiler_info(&packages, &compiler); + if !snapshot_output && show_progress { println!( "{}{} {}Built package tree in {:.2}s", @@ -139,6 +157,14 @@ pub fn initialize_build( .unwrap_or(timing_package_tree_elapsed) .as_secs_f64() ); + if let CompilerCheckResult::CleanedPackagesDueToCompiler = compiler_check { + println!( + "{}{} {}Cleaned previous build due to compiler update", + LINE_CLEAR, + style("[1/7]").bold().dim(), + SWEEP + ); + } } if !packages::validate_packages_dependencies(&packages) { @@ -156,7 +182,7 @@ pub fn initialize_build( let _ = stdout().flush(); } - let mut build_state = BuildState::new(project_context, packages, bsc_path); + let mut build_state = BuildState::new(project_context, packages, compiler); packages::parse_packages(&mut build_state); let timing_source_files_elapsed = timing_source_files.elapsed(); @@ -448,6 +474,9 @@ pub fn incremental_build( log_deprecations(build_state); } + // Write per-package compiler metadata to `lib/bs/compiler-info.json` (idempotent) + write_compiler_info(build_state); + Ok(()) } } diff --git a/rewatch/src/build/build_types.rs b/rewatch/src/build/build_types.rs index 76035ab27f..64a8895da4 100644 --- a/rewatch/src/build/build_types.rs +++ b/rewatch/src/build/build_types.rs @@ -2,6 +2,7 @@ use crate::build::packages::{Namespace, Package}; use crate::config::Config; use crate::project_context::ProjectContext; use ahash::{AHashMap, AHashSet}; +use blake3::Hash; use std::{fmt::Display, path::PathBuf, time::SystemTime}; #[derive(Debug, Clone, PartialEq)] @@ -96,10 +97,17 @@ pub struct BuildState { pub packages: AHashMap, pub module_names: AHashSet, pub deleted_modules: AHashSet, - pub bsc_path: PathBuf, + pub compiler_info: CompilerInfo, pub deps_initialized: bool, } +#[derive(Debug, Clone)] +pub struct CompilerInfo { + pub bsc_path: PathBuf, + pub bsc_hash: Hash, + pub runtime_path: PathBuf, +} + impl BuildState { pub fn get_package(&self, package_name: &str) -> Option<&Package> { self.packages.get(package_name) @@ -111,7 +119,7 @@ impl BuildState { pub fn new( project_context: ProjectContext, packages: AHashMap, - bsc_path: PathBuf, + compiler: CompilerInfo, ) -> Self { Self { project_context, @@ -119,7 +127,7 @@ impl BuildState { modules: AHashMap::new(), packages, deleted_modules: AHashSet::new(), - bsc_path, + compiler_info: compiler, deps_initialized: false, } } diff --git a/rewatch/src/build/clean.rs b/rewatch/src/build/clean.rs index b5f4e79a55..34a698fcb4 100644 --- a/rewatch/src/build/clean.rs +++ b/rewatch/src/build/clean.rs @@ -1,5 +1,6 @@ use super::build_types::*; use super::packages; +use crate::build; use crate::build::packages::Package; use crate::config::Config; use crate::helpers; @@ -332,9 +333,8 @@ pub fn cleanup_after_build(build_state: &BuildState) { pub fn clean(path: &Path, show_progress: bool, snapshot_output: bool, clean_dev_deps: bool) -> Result<()> { let project_context = ProjectContext::new(path)?; - + let compiler_info = build::get_compiler_info(&project_context)?; let packages = packages::make(&None, &project_context, show_progress, clean_dev_deps)?; - let bsc_path = helpers::get_bsc(); let timing_clean_compiler_assets = Instant::now(); if !snapshot_output && show_progress { @@ -364,7 +364,7 @@ pub fn clean(path: &Path, show_progress: bool, snapshot_output: bool, clean_dev_ } let timing_clean_mjs = Instant::now(); - let mut build_state = BuildState::new(project_context, packages, bsc_path); + let mut build_state = BuildState::new(project_context, packages, compiler_info); packages::parse_packages(&mut build_state); let root_config = build_state.get_root_config(); let suffix_for_print = if snapshot_output || !show_progress { @@ -418,7 +418,7 @@ pub fn clean(path: &Path, show_progress: bool, snapshot_output: bool, clean_dev_ Ok(()) } -fn clean_package(show_progress: bool, snapshot_output: bool, package: &Package) { +pub fn clean_package(show_progress: bool, snapshot_output: bool, package: &Package) { if show_progress { if snapshot_output { println!("Cleaning {}", package.name) @@ -441,4 +441,7 @@ fn clean_package(show_progress: bool, snapshot_output: bool, package: &Package) let path_str = package.get_ocaml_build_path(); let path = std::path::Path::new(&path_str); let _ = std::fs::remove_dir_all(path); + + // remove the per-package compiler metadata file so that a subsequent build writes fresh metadata + let _ = std::fs::remove_file(package.get_compiler_info_path()); } diff --git a/rewatch/src/build/compile.rs b/rewatch/src/build/compile.rs index 67074c9ddd..7db67b653d 100644 --- a/rewatch/src/build/compile.rs +++ b/rewatch/src/build/compile.rs @@ -16,7 +16,9 @@ use console::style; use log::{debug, trace}; use rayon::prelude::*; use std::path::Path; +use std::path::PathBuf; use std::process::Command; +use std::sync::OnceLock; use std::time::SystemTime; pub fn compile( @@ -336,22 +338,36 @@ pub fn compile( Ok((compile_errors, compile_warnings, num_compiled_modules)) } -pub fn get_runtime_path_args( - package_config: &Config, - project_context: &ProjectContext, -) -> Result> { - match std::env::var("RESCRIPT_RUNTIME") { - Ok(runtime_path) => Ok(vec!["-runtime-path".to_string(), runtime_path]), +static RUNTIME_PATH_MEMO: OnceLock = OnceLock::new(); + +pub fn get_runtime_path(package_config: &Config, project_context: &ProjectContext) -> Result { + if let Some(p) = RUNTIME_PATH_MEMO.get() { + return Ok(p.clone()); + } + + let resolved = match std::env::var("RESCRIPT_RUNTIME") { + Ok(runtime_path) => Ok(PathBuf::from(runtime_path)), Err(_) => match helpers::try_package_path(package_config, project_context, "@rescript/runtime") { - Ok(runtime_path) => Ok(vec![ - "-runtime-path".to_string(), - runtime_path.to_string_lossy().to_string(), - ]), + Ok(runtime_path) => Ok(runtime_path), Err(err) => Err(anyhow!( "The rescript runtime package could not be found.\nPlease set RESCRIPT_RUNTIME environment variable or make sure the runtime package is installed.\nError: {err}" )), }, - } + }?; + + let _ = RUNTIME_PATH_MEMO.set(resolved.clone()); + Ok(resolved) +} + +pub fn get_runtime_path_args( + package_config: &Config, + project_context: &ProjectContext, +) -> Result> { + let runtime_path = get_runtime_path(package_config, project_context)?; + Ok(vec![ + "-runtime-path".to_string(), + runtime_path.to_string_lossy().to_string(), + ]) } pub fn compiler_args( @@ -581,7 +597,7 @@ fn compile_file( let BuildState { packages, project_context, - bsc_path, + compiler_info, .. } = build_state; let root_config = build_state.get_root_config(); @@ -612,7 +628,7 @@ fn compile_file( package.is_local_dep, )?; - let to_mjs = Command::new(bsc_path) + let to_mjs = Command::new(&compiler_info.bsc_path) .current_dir( build_path_abs .canonicalize() diff --git a/rewatch/src/build/compiler_info.rs b/rewatch/src/build/compiler_info.rs new file mode 100644 index 0000000000..d3576d531d --- /dev/null +++ b/rewatch/src/build/compiler_info.rs @@ -0,0 +1,206 @@ +use crate::helpers; + +use super::build_types::{BuildState, CompilerInfo}; +use super::clean; +use super::packages; +use ahash::AHashMap; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; +use std::fs::File; +use std::io::Write; + +// In order to have a loose coupling with the compiler, we don't want to have a hard dependency on the compiler's structs +// We can use this struct to parse the compiler-info.json file +// If something is not there, that is fine, we will treat it as a mismatch +#[derive(Serialize, Deserialize)] +struct CompilerInfoFile { + version: String, + bsc_path: String, + bsc_hash: String, + rescript_config_hash: String, + runtime_path: String, + generated_at: String, +} + +pub enum CompilerCheckResult { + SameCompilerAsLastRun, + CleanedPackagesDueToCompiler, +} + +fn get_rescript_config_hash(package: &packages::Package) -> Option { + helpers::compute_file_hash(&package.config.path).map(|hash| hash.to_hex().to_string()) +} + +pub fn verify_compiler_info( + packages: &AHashMap, + compiler: &CompilerInfo, +) -> CompilerCheckResult { + let mismatched_packages = packages + .values() + .filter(|package| { + let info_path = package.get_compiler_info_path(); + let Ok(contents) = std::fs::read_to_string(&info_path) else { + // Can't read file → treat as mismatch so we clean and rewrite + return true; + }; + + let parsed: Result = serde_json::from_str(&contents); + let parsed = match parsed { + Ok(p) => p, + Err(_) => return true, // unknown or invalid format -> treat as mismatch + }; + + let current_bsc_path_str = compiler.bsc_path.to_string_lossy(); + let current_bsc_hash_hex = compiler.bsc_hash.to_hex().to_string(); + let current_runtime_path_str = compiler.runtime_path.to_string_lossy(); + let current_rescript_config_hash = match get_rescript_config_hash(package) { + Some(hash) => hash, + None => return true, // can't compute hash -> treat as mismatch + }; + + let mut mismatch = false; + if parsed.bsc_path != current_bsc_path_str { + log::debug!( + "compiler-info mismatch for {}: bsc_path changed (stored='{}', current='{}')", + package.name, + parsed.bsc_path, + current_bsc_path_str + ); + mismatch = true; + } + if parsed.bsc_hash != current_bsc_hash_hex { + log::debug!( + "compiler-info mismatch for {}: bsc_hash changed (stored='{}', current='{}')", + package.name, + parsed.bsc_hash, + current_bsc_hash_hex + ); + mismatch = true; + } + if parsed.runtime_path != current_runtime_path_str { + log::debug!( + "compiler-info mismatch for {}: runtime_path changed (stored='{}', current='{}')", + package.name, + parsed.runtime_path, + current_runtime_path_str + ); + mismatch = true; + } + if parsed.rescript_config_hash != current_rescript_config_hash { + log::debug!( + "compiler-info mismatch for {}: rescript_config_hash changed (stored='{}', current='{}')", + package.name, + parsed.rescript_config_hash, + current_rescript_config_hash + ); + mismatch = true; + } + + mismatch + }) + .collect::>(); + + let cleaned_count = mismatched_packages.len(); + mismatched_packages.par_iter().for_each(|package| { + // suppress progress printing during init to avoid breaking step output + clean::clean_package(false, true, package); + }); + if cleaned_count == 0 { + CompilerCheckResult::SameCompilerAsLastRun + } else { + CompilerCheckResult::CleanedPackagesDueToCompiler + } +} + +pub fn write_compiler_info(build_state: &BuildState) { + let bsc_path = build_state.compiler_info.bsc_path.to_string_lossy().to_string(); + let bsc_hash = build_state.compiler_info.bsc_hash.to_hex().to_string(); + let runtime_path = build_state + .compiler_info + .runtime_path + .to_string_lossy() + .to_string(); + // derive version from the crate version + let version = env!("CARGO_PKG_VERSION").to_string(); + let generated_at = crate::helpers::get_system_time().to_string(); + + // Borrowing serializer to avoid cloning the constant fields for every package + #[derive(Serialize)] + struct CompilerInfoFileRef<'a> { + version: &'a str, + bsc_path: &'a str, + bsc_hash: &'a str, + rescript_config_hash: String, + runtime_path: &'a str, + generated_at: &'a str, + } + + build_state.packages.values().par_bridge().for_each(|package| { + if let Some(rescript_config_hash) = helpers::compute_file_hash(&package.config.path) { + let out = CompilerInfoFileRef { + version: &version, + bsc_path: &bsc_path, + bsc_hash: &bsc_hash, + rescript_config_hash: rescript_config_hash.to_hex().to_string(), + runtime_path: &runtime_path, + generated_at: &generated_at, + }; + let contents = match serde_json::to_string_pretty(&out) { + Ok(s) => s, + Err(err) => { + log::error!( + "Failed to serialize compiler-info for package {}: {}. Skipping write.", + package.name, + err + ); + return; + } + }; + let info_path = package.get_compiler_info_path(); + let should_write = match std::fs::read_to_string(&info_path) { + Ok(existing) => existing != contents, + Err(_) => true, + }; + + if should_write { + if let Some(parent) = info_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + // We write atomically to avoid leaving a partially written JSON file + // (e.g. process interruption) that would be read on the next init as an + // invalid/mismatched compiler-info, causing unnecessary cleans. The + // rename within the same directory is atomic on common platforms. + let tmp = info_path.with_extension("json.tmp"); + if let Ok(mut f) = File::create(&tmp) { + if let Err(err) = f.write_all(contents.as_bytes()) { + log::error!( + "Failed to write compiler-info for package {} to temporary file {}: {}. Skipping rename.", + package.name, + tmp.display(), + err + ); + let _ = std::fs::remove_file(&tmp); + return; + } + if let Err(err) = f.sync_all() { + log::error!( + "Failed to flush compiler-info for package {}: {}. Skipping rename.", + package.name, + err + ); + let _ = std::fs::remove_file(&tmp); + return; + } + if let Err(err) = std::fs::rename(&tmp, &info_path) { + log::error!( + "Failed to atomically replace compiler-info for package {}: {}.", + package.name, + err + ); + let _ = std::fs::remove_file(&tmp); + } + } + } + } + }); +} diff --git a/rewatch/src/build/packages.rs b/rewatch/src/build/packages.rs index 8aabaa8936..cc0436650d 100644 --- a/rewatch/src/build/packages.rs +++ b/rewatch/src/build/packages.rs @@ -91,6 +91,10 @@ impl Package { get_build_path(&self.path) } + pub fn get_compiler_info_path(&self) -> PathBuf { + self.get_build_path().join("compiler-info.json") + } + pub fn get_js_path(&self) -> PathBuf { get_js_path(&self.path) } diff --git a/rewatch/src/build/parse.rs b/rewatch/src/build/parse.rs index f1a8583ffe..ff6dad0958 100644 --- a/rewatch/src/build/parse.rs +++ b/rewatch/src/build/parse.rs @@ -196,7 +196,7 @@ pub fn generate_asts( &build_state.project_context, package, module_name, - &build_state.bsc_path, + &build_state.compiler_info.bsc_path, ) { has_failure = true; stderr.push_str(&format!("{}\n", err)); @@ -310,7 +310,7 @@ fn generate_ast( /* Create .ast */ let result = match Some( - Command::new(&build_state.bsc_path) + Command::new(&build_state.compiler_info.bsc_path) .current_dir(&build_path_abs) .args(parser_args) .output()