Skip to content

Commit a1cae90

Browse files
committed
feat: preserve Built stage and FullCompile intent across project
rebuilds Extract FileBuiltState struct from CompilationStage::Built for reuse in hash-based promotion snapshots. When project_build reinitializes state, compare source/AST/CMI/CMT hashes (including interface hashes) to promote unchanged modules directly back to Built without recompilation. For modules whose hashes changed or that were at CompileError(FullCompile), persist intent in a full_compile_intent set on PendingState, drained as step 4 inside build_batch on subsequent flushes with file saves. Filter errored files from post-build recheck to prevent buffer typechecks from overwriting compile error diagnostics. Extract record_error_count helper shared across file_build and project_build.
1 parent 8c024e8 commit a1cae90

11 files changed

Lines changed: 871 additions & 150 deletions

File tree

LSP.md

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ rewatch/src/
8181
├── notifications.rs # Custom rescript/buildFinished notification
8282
├── queue.rs # Unified debounced queue: types, public API, consumer, merge, flush orchestration
8383
├── queue/
84-
│ ├── file_build.rs # Per-file incremental build (dependency + dependent closure)
84+
│ ├── file_build.rs # Per-file incremental build (dependency + dependent closure + intent drain)
8585
│ ├── file_typecheck.rs # Per-file typecheck (parallel, wave-based, staleness-aware)
86-
│ └── project_build.rs # Per-project full rebuild + artifact cleanup on file creation/deletion
86+
│ └── project_build.rs # Per-project full rebuild + artifact cleanup + hash-based Built promotion
8787
├── references.rs # Find references via analysis binary subprocess
8888
├── rename.rs # Rename via analysis binary subprocess
8989
├── semantic_tokens.rs # Semantic tokens via analysis binary subprocess
@@ -252,21 +252,23 @@ All file events flow into a single unified queue (`lsp/queue.rs`). The queue acc
252252
- **FileCreated / FileDeleted** (from `didChangeWatchedFiles` Created/Deleted) — structural change requiring full project rebuild
253253
- **ConfigChanged** (from `didSave` / `didChangeWatchedFiles` for `rescript.json`) — config change requiring full project rebuild
254254

255-
A single background consumer task collects events into a `PendingState` with three action-oriented fields:
255+
A single background consumer task collects events into a `PendingState` with these fields:
256256

257257
- `typechecks` — files needing typecheck (unsaved buffer content)
258258
- `compile_files` — files needing incremental build (saved to disk)
259259
- `build_projects` — a `PendingProjectBuilds` struct containing:
260260
- `created_files` — file paths whose creation requires a full project rebuild
261261
- `deleted_files` — file paths whose deletion requires a full project rebuild
262262
- `config_changed``rescript.json` paths requiring full project re-initialization
263+
- `full_compile_intent` — module names (keyed by project root) that need `FullCompile` (JS emission) but couldn't be compiled yet. Populated by project rebuilds when hash-based promotion fails; drained by incremental builds when modules reach `TypeChecked`
264+
- `recently_deleted` — file paths deleted in the previous flush, used for cross-flush delete+create detection
263265

264266
The merge function applies promotion rules to consolidate per-file state. When a `FileCreated` or `FileDeleted` event arrives, any pending per-file typecheck or build for that same file is removed since the full rebuild will cover it. A `ConfigChanged` event clears pending typechecks for the same project (since the rebuild will produce fresh type information) but keeps pending compile_files. After 100ms of silence the batch is flushed sequentially:
265267

266-
1. **Full builds first** — re-initialize affected projects from scratch (same flow as the initial build), replacing the old `BuildCommandState`. Clears stale diagnostics for files that no longer exist.
267-
2. **Incremental builds second** — saved files get a full incremental build (compile dependencies + typecheck dependents). Uses a take-build-replace pattern: each project's `BuildCommandState` is removed from the `ProjectMap` under a brief lock, built without holding the mutex, then inserted back. This keeps LSP handlers (hover, completions, etc.) unblocked during compilation.
268+
1. **Full builds first** — re-initialize affected projects from scratch (same flow as the initial build), replacing the old `BuildCommandState`. Before replacing the state, snapshots `Built` modules (as `FileBuiltState`) and `CompileError(FullCompile)` module names. After the new state is created, applies hash-based promotion: modules whose implementation/interface/cmi/cmt hashes match the snapshot are promoted directly back to `Built` (no bsc invocation needed). Modules that can't be promoted are added to `full_compile_intent` for deferred JS emission. Clears stale diagnostics for files that no longer exist.
269+
2. **Incremental builds second** — saved files get a full incremental build (compile dependencies + typecheck dependents + compile resolved errors + drain intent). Uses a take-build-replace pattern: each project's `BuildCommandState` is removed from the `ProjectMap` under a brief lock, built without holding the mutex, then inserted back. This keeps LSP handlers (hover, completions, etc.) unblocked during compilation. Also drains `full_compile_intent` as step 4: modules already compiled in steps 1–3 are skipped (already at `Built`), remaining `TypeChecked` modules get `FullCompile`, and modules not yet ready (e.g. still at `CompileError`) are kept for future flushes.
268270
3. **Typechecks third** — unsaved edits get a lightweight typecheck via `bsc -bs-read-stdin`, with brief lock for arg extraction only.
269-
4. **Post-build recheck** — if a saved file also had unsaved buffer content (didChange + didSave in the same debounce window), a typecheck pass runs from the buffer so diagnostics match the editor.
271+
4. **Post-build recheck** — if a saved file also had unsaved buffer content (didChange + didSave in the same debounce window), a typecheck pass runs from the buffer so diagnostics match the editor. Files whose build produced errors are skipped to avoid overwriting compile error diagnostics with clean buffer typecheck results.
270272
5. **`buildFinished` notification** — sent when full builds or incremental builds ran.
271273

272274
Sequential execution within one consumer eliminates all races on `lib/lsp/` artifacts.
@@ -365,15 +367,19 @@ didSave / didChangeWatchedFiles notification
365367
Step 1: Full builds (per project root)
366368
├── Group build_projects paths by project root (resolved at flush time)
367369
├── Clean up associated files for deleted .res files (.resi + compiled JS)
368-
├── Re-initialize project (re-read packages, re-scan sources)
369-
├── Replace old BuildCommandState
370-
├── Invalidate uri_cache entries
370+
├── Snapshot Built modules (as FileBuiltState) and CompileError(FullCompile) names
371+
├── Re-initialize project (re-read packages, re-scan sources, TypecheckOnly)
372+
├── Hash-based promotion: compare old Built hashes against new TypeChecked hashes
373+
│ ├── Match → promote directly to Built (no bsc needed)
374+
│ └── Mismatch → add to full_compile_intent for deferred JS emission
375+
├── CompileError(FullCompile) modules → add to full_compile_intent unconditionally
376+
├── Replace old BuildCommandState, invalidate uri_cache entries
371377
├── Publish diagnostics from new build
372378
└── Clear diagnostics for files that no longer exist (old/new URI diff)
373379
374380
375381
Step 2: Incremental builds (per project)
376-
├── group files by project root
382+
├── group files by project root (also include projects with pending intents)
377383
├── mark_file_parse_dirty per file
378384
├── Step 2a: compile_dependencies
379385
│ ├── parse_and_resolve (parse dirty files, resolve deps)
@@ -386,10 +392,15 @@ didSave / didChangeWatchedFiles notification
386392
│ ├── Snapshot modules at CompileError(FullCompile) before step 2b
387393
│ ├── After step 2b, find modules now at TypeChecked (error resolved)
388394
│ └── FullCompile build for those modules (produces missing JS)
395+
├── Step 2d: drain_full_compile_intent
396+
│ ├── Skip modules already at Built (handled by steps 2a–2c or hash promotion)
397+
│ ├── FullCompile modules at TypeChecked (produces JS)
398+
│ └── Keep modules not yet ready (e.g. CompileError) for future flushes
389399
└── Publish combined diagnostics from all phases
390400
391401
392402
Post-build recheck (if any Build file had stashed buffer content)
403+
├── Skip files whose build produced errors (preserve compile error diagnostics)
393404
394405
395406
Send rescript/buildFinished notification
@@ -645,10 +656,16 @@ Parsed { source_hash, ast_hash, interface_source_hash?, interface_ast_hash
645656
CompileError { ...same hashes as Parsed..., compile_mode }
646657
DependencyDirty { ...same hashes as Parsed... } — dep changed, skip reparse
647658
TypeChecked { ...Parsed hashes + cmi_hash, cmt_hash, compiled_at }
648-
Built { ...TypeChecked hashes + cmj_hash }
659+
Built(FileBuiltState) { ...TypeChecked hashes + cmj_hash }
649660
```
650661

651-
Each stage from `Parsed` onward also carries optional parse/compile warnings and optional interface hashes. This data model enables several optimizations that are not yet implemented:
662+
Each stage from `Parsed` onward also carries optional parse/compile warnings and optional interface hashes. `Built` wraps a `FileBuiltState` struct that is also used for snapshots during hash-based promotion across project rebuilds.
663+
664+
**Already implemented:**
665+
666+
- **Hash-based promotion across project rebuilds**: When `project_build` reinitializes a project (e.g. due to atomic file writes from LLMs or git), it snapshots `FileBuiltState` for all `Built` modules before replacing the state. After the new `TypecheckOnly` build, modules whose implementation/interface/cmi/cmt hashes match the snapshot are promoted directly back to `Built` — no bsc invocation needed. Modules that can't be promoted (hash mismatch or were at `CompileError(FullCompile)`) are added to `full_compile_intent` for deferred JS emission on subsequent flushes.
667+
668+
**Not yet implemented:**
652669

653670
- **No-op save detection**: Compare the on-disk `source_hash` against the hash stored in the module's stage. If they match, the file hasn't actually changed (e.g., user hit save without editing). Parsing and compilation can be skipped entirely.
654671
- **CMI-based skip in `TypecheckDependents`**: After compiling a saved file, compare its new `cmi_hash` against the pre-save value from the stage. If the interface is unchanged, no dependents need re-typechecking — saving an entire typecheck pass over the dependent closure.

rewatch/src/build/build_types.rs

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,25 @@ pub enum CompilationStage {
8282
},
8383
/// Fully compiled (.cmi/.cmt/.cmj + JS produced).
8484
/// Accumulates all prior hashes + build artifact hashes.
85-
Built {
86-
implementation_source_hash: Hash,
87-
implementation_ast_hash: Hash,
88-
interface_source_hash: Option<Hash>,
89-
interface_ast_hash: Option<Hash>,
90-
cmi_hash: Hash,
91-
cmt_hash: Hash,
92-
cmj_hash: Hash,
93-
compiled_at: SystemTime,
94-
implementation_parse_warnings: Option<String>,
95-
interface_parse_warnings: Option<String>,
96-
compile_warnings: Option<String>,
97-
},
85+
Built(FileBuiltState),
86+
}
87+
88+
/// All hashes and metadata for a fully compiled module.
89+
/// Used both in `CompilationStage::Built` and for snapshots when
90+
/// preserving Built status across project rebuilds.
91+
#[derive(Debug, Clone, PartialEq, Eq)]
92+
pub struct FileBuiltState {
93+
pub implementation_source_hash: Hash,
94+
pub implementation_ast_hash: Hash,
95+
pub interface_source_hash: Option<Hash>,
96+
pub interface_ast_hash: Option<Hash>,
97+
pub cmi_hash: Hash,
98+
pub cmt_hash: Hash,
99+
pub cmj_hash: Hash,
100+
pub compiled_at: SystemTime,
101+
pub implementation_parse_warnings: Option<String>,
102+
pub interface_parse_warnings: Option<String>,
103+
pub compile_warnings: Option<String>,
98104
}
99105

100106
impl CompilationStage {
@@ -131,11 +137,8 @@ impl CompilationStage {
131137
| CompilationStage::TypeChecked {
132138
implementation_parse_warnings,
133139
..
134-
}
135-
| CompilationStage::Built {
136-
implementation_parse_warnings,
137-
..
138140
} => implementation_parse_warnings.as_deref(),
141+
CompilationStage::Built(b) => b.implementation_parse_warnings.as_deref(),
139142
_ => None,
140143
}
141144
}
@@ -158,29 +161,26 @@ impl CompilationStage {
158161
| CompilationStage::TypeChecked {
159162
interface_parse_warnings,
160163
..
161-
}
162-
| CompilationStage::Built {
163-
interface_parse_warnings,
164-
..
165164
} => interface_parse_warnings.as_deref(),
165+
CompilationStage::Built(b) => b.interface_parse_warnings.as_deref(),
166166
_ => None,
167167
}
168168
}
169169

170170
/// Whether compilation produced warnings.
171171
pub fn has_compile_warnings(&self) -> bool {
172172
match self {
173-
CompilationStage::TypeChecked { compile_warnings, .. }
174-
| CompilationStage::Built { compile_warnings, .. } => compile_warnings.is_some(),
173+
CompilationStage::TypeChecked { compile_warnings, .. } => compile_warnings.is_some(),
174+
CompilationStage::Built(b) => b.compile_warnings.is_some(),
175175
_ => false,
176176
}
177177
}
178178

179179
/// The compile warning text, if any.
180180
pub fn compile_warnings(&self) -> Option<&str> {
181181
match self {
182-
CompilationStage::TypeChecked { compile_warnings, .. }
183-
| CompilationStage::Built { compile_warnings, .. } => compile_warnings.as_deref(),
182+
CompilationStage::TypeChecked { compile_warnings, .. } => compile_warnings.as_deref(),
183+
CompilationStage::Built(b) => b.compile_warnings.as_deref(),
184184
_ => None,
185185
}
186186
}
@@ -235,15 +235,15 @@ impl CompilationStage {
235235
| (TypeChecked { .. }, TypeChecked { .. })
236236
// TypeChecked → Built: compile.rs — FullCompile after TypecheckOnly
237237
// clean.rs — restoring from artifacts
238-
| (TypeChecked { .. }, Built { .. })
238+
| (TypeChecked { .. }, Built(..))
239239
// TypeChecked → DependencyDirty: compile.rs — dependency interface changed
240240
| (TypeChecked { .. }, DependencyDirty { .. })
241241
// Built → CompileError: compile.rs — LSP save recompile, dependency broke it
242-
| (Built { .. }, CompileError { .. })
242+
| (Built(..), CompileError { .. })
243243
// Built → TypeChecked: compile.rs — LSP save recompile in TypecheckOnly mode
244-
| (Built { .. }, TypeChecked { .. })
244+
| (Built(..), TypeChecked { .. })
245245
// Built → DependencyDirty: compile.rs — dependency interface changed
246-
| (Built { .. }, DependencyDirty { .. })
246+
| (Built(..), DependencyDirty { .. })
247247
)
248248
}
249249

@@ -253,7 +253,7 @@ impl CompilationStage {
253253
/// (it needs parsing first), but is kept for safety.
254254
pub fn needs_compile_for_mode(&self, mode: CompileMode) -> bool {
255255
match (self, mode) {
256-
(CompilationStage::Built { .. }, _) => false,
256+
(CompilationStage::Built(..), _) => false,
257257
(CompilationStage::ParseError, _) => false,
258258
(CompilationStage::TypeChecked { .. }, CompileMode::FullCompile) => true,
259259
(CompilationStage::TypeChecked { .. }, CompileMode::TypecheckOnly) => false,
@@ -272,8 +272,8 @@ impl CompilationStage {
272272
/// more recently than a dependent, the dependent must be recompiled.
273273
pub fn compiled_at(&self) -> Option<SystemTime> {
274274
match self {
275-
CompilationStage::TypeChecked { compiled_at, .. }
276-
| CompilationStage::Built { compiled_at, .. } => Some(*compiled_at),
275+
CompilationStage::TypeChecked { compiled_at, .. } => Some(*compiled_at),
276+
CompilationStage::Built(b) => Some(b.compiled_at),
277277
_ => None,
278278
}
279279
}

rewatch/src/build/clean.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ pub fn cleanup_previous_build(
323323
compile_warnings: None,
324324
});
325325
if cmj_exists && let Some(cmj) = cmj_hash {
326-
sf_module.set_compilation_stage(CompilationStage::Built {
326+
sf_module.set_compilation_stage(CompilationStage::Built(FileBuiltState {
327327
implementation_source_hash: ish,
328328
implementation_ast_hash: iah,
329329
interface_source_hash,
@@ -335,7 +335,7 @@ pub fn cleanup_previous_build(
335335
implementation_parse_warnings: None,
336336
interface_parse_warnings: None,
337337
compile_warnings: None,
338-
});
338+
}));
339339
}
340340
}
341341
}

0 commit comments

Comments
 (0)