lattice_compilation/mode.rs
1//! CM.1: `compilation-mode` — major mode for the read-only
2//! synthetic `*compilation*` buffer.
3//!
4//! Mirrors `lattice_agent::log::modes::AiLogMode` /
5//! `lattice_mode::modes::MessagesMode`: a `ReadOnly + NoFile`
6//! major activated on the buffer *by id* — `start_compilation`
7//! provisions the buffer through the mode-owned creation seam
8//! `ModeActivator::ensure_named_document`, which activates this
9//! mode. `on_activate` then subscribes to [`CompilationOutputPushed`]
10//! and spawns a drain task that applies each streamed chunk to the
11//! buffer through the actor handle. The returned `Subscription`
12//! guard unsubscribes on drop.
13
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::{Arc, OnceLock};
16
17use lattice_cells::{HeaderlineProvider, VirtualRowProvider};
18use lattice_grammar::{AppEffect, CommandRegistryHandle, Effect};
19use lattice_mode::inbound::InboundBus;
20use lattice_mode::{
21 ActionContext, ActionHandler, ActionHandlerRegistration, ActionHandlerRegistryHandle,
22 BufferStoreHandle, CapabilitySet, CompilationSeverityData, DecorationCtx, GutterDecoration,
23 Keymap, KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
24 PendingSyntheticHighlights, PendingSyntheticHighlightsHandle, Subscription,
25 VirtualRowRegistrar, keymap_entry,
26};
27use lattice_protocol::edit::Edit;
28use lattice_protocol::error_list::ErrorSeverity;
29use lattice_protocol::position::{Position, Range};
30use lattice_runtime::Document;
31// T.7 (2026-06-18): mode-owned theme elements for the
32// compilation-mode highlighting.
33use lattice_theme::{
34 Color, ColorRef, ElementId, ElementName, ElementOwner, StyleSpec, ThemeRegistryHandle,
35};
36
37use crate::events::{CompilationOutputPushed, OutputChunk};
38use crate::headerline::{
39 COMPILATION_HEADERLINE_PROVIDER_ID, CompilationHeaderline, CompilationHeadlineState,
40};
41use crate::{
42 CompilationGutterBusHandle, CompilationLocationBusHandle, CompilationThemeColorsBusHandle,
43 scan_location_lines, scan_severities,
44};
45
46/// Count the `\n`s in `text` — how many buffer lines it advances the
47/// running line counter (CM.3c drain line-tracking).
48fn count_newlines(text: &str) -> u32 {
49 text.matches('\n').count() as u32
50}
51
52/// Major mode for the `*compilation*` buffer.
53pub struct CompilationMode;
54
55impl CompilationMode {
56 pub fn mode_id() -> ModeId {
57 ModeId::new("compilation-mode")
58 }
59}
60
61/// CM.3a/CM.3b: static keymap catalog for `compilation-mode`.
62///
63/// - `gr` recompiles (reuses the last command), mirroring
64/// project-search's `gr` refresh. `action:compilation-recompile`
65/// (registered by `lattice-host::actions::populate`) emits
66/// `AppEffect::CompileRun { cmdline: None }`.
67/// - `<CR>` jumps to the source location on the cursor line
68/// (CM.3b). `action:compilation-jump` is registered as a dead
69/// marker; the mode's per-buffer `ActionHandlerRegistry` closure
70/// (see `on_activate`) intercepts, parses the line, and emits
71/// `AppEffect::CompileJumpToLocation`.
72///
73/// The host's `translate_mode_keymaps` pass auto-pushes these as a
74/// `MajorMode(compilation-mode)` layer; K.1.c scopes them to
75/// `*compilation*` buffers.
76fn compilation_keymap_entries() -> &'static [KeymapEntry] {
77 static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
78 ENTRIES.get_or_init(|| {
79 // RV.2 (2026-08-10): `gr` is NOT declared here. It lives once on
80 // `refreshable-view-mode`; this mode names its refresh target
81 // via `Mode::refresh_action()` below, and the shared minor
82 // arrives through the implies cascade. The handler body
83 // (`action:compilation-recompile`) is unchanged.
84 vec![
85 keymap_entry! {
86 mode: Normal, chord: "<C-c>",
87 doc: "Kill the running compilation",
88 cmd: "compilation-kill"
89 },
90 keymap_entry! {
91 mode: Normal, chord: "<CR>",
92 doc: "Jump to the source location on the cursor line (syncs the error list index)",
93 cmd: "action:compilation-jump"
94 },
95 ]
96 })
97}
98
99/// Pure model of applying one chunk to the current buffer text.
100///
101/// The live drain produces the equivalent `Edit`s against the
102/// actor handle; this function is the host-free unit-test seam for
103/// the chunk semantics (`Reset` replaces everything; `Append` /
104/// `Finished` concatenate at the end).
105pub fn apply_chunk(current: &str, chunk: &OutputChunk) -> String {
106 match chunk {
107 OutputChunk::Reset { header } => header.clone(),
108 // `apply_chunk` answers "what is the buffer's text after this
109 // chunk"; spans are a parallel concern the drain applies.
110 OutputChunk::Append { text, .. } => {
111 let mut s = String::with_capacity(current.len() + text.len());
112 s.push_str(current);
113 s.push_str(text);
114 s
115 }
116 OutputChunk::Finished { summary } => {
117 let mut s = String::with_capacity(current.len() + summary.len());
118 s.push_str(current);
119 s.push_str(summary);
120 s
121 }
122 }
123}
124
125/// Append `text` at the very end of the buffer as one edit.
126async fn append_at_end(handle: &Arc<dyn Document>, text: String) {
127 if text.is_empty() {
128 return;
129 }
130 let snap = handle.snapshot();
131 // CV.3: ROPE space — this addresses the very end of the buffer
132 // (append point / full-extent replace), which lives past the
133 // terminating newline.
134 let last = snap.buffer.rope_line_count().saturating_sub(1);
135 let last_line = snap.buffer.line(last).unwrap_or_default();
136 let pos = Position::new(last, last_line.len() as u32);
137 let _ = handle.apply_edit_batch(vec![Edit::insert(pos, text)]).await;
138}
139
140/// CM.5: splice one flush's worth of ANSI spans onto the buffer's
141/// highlight list, or bank them as debt when there is nothing to show.
142///
143/// `spans` holds the spans the reader produced (one entry per coloured
144/// line it saw); `text_lines` is how many lines the flush actually
145/// appends. The two differ whenever the batch mixed reader output with
146/// editor-generated text (a run summary), so `spans` is padded to
147/// `text_lines` before publishing — a span list shorter than the text
148/// would leave every later line splicing one row too high.
149///
150/// When the whole flush is uncoloured, nothing is published and the
151/// lines are added to `debt` instead. `debt` is then paid as leading
152/// empty rows by the next flush that does carry colour, which keeps
153/// the span list index-aligned with the buffer without waking the
154/// renderer for output that has nothing to paint.
155fn publish_spans(
156 highlights: Option<&PendingSyntheticHighlights>,
157 buffer_id: lattice_core::BufferId,
158 start_line: u32,
159 mut spans: Vec<Vec<lattice_cells::StyledSpan>>,
160 text_lines: usize,
161 debt: &mut usize,
162) {
163 let Some(highlights) = highlights else {
164 return;
165 };
166 if text_lines == 0 {
167 return;
168 }
169 spans.truncate(text_lines);
170 spans.resize(text_lines, Vec::new());
171 if spans.iter().all(|line| line.is_empty()) {
172 *debt = debt.saturating_add(text_lines);
173 return;
174 }
175 let owed = std::mem::take(debt);
176 if owed > 0 {
177 let mut padded = vec![Vec::new(); owed];
178 padded.append(&mut spans);
179 spans = padded;
180 }
181 highlights.insert_at_and_wake(buffer_id, start_line.saturating_sub(owed as u32), spans);
182}
183
184/// CM.5: drop the prior run's spans when a `Reset` replaces the buffer.
185///
186/// `header_lines` is how many lines the replacement header occupies —
187/// it is editor-generated and never coloured, so the list is reset to
188/// exactly that many empty rows rather than emptied, keeping it
189/// aligned with the text the reset just wrote.
190fn clear_spans(
191 highlights: Option<&PendingSyntheticHighlights>,
192 buffer_id: lattice_core::BufferId,
193 header_lines: usize,
194) {
195 if let Some(highlights) = highlights {
196 highlights.store_and_wake(buffer_id, vec![Vec::new(); header_lines]);
197 }
198}
199
200/// Replace the whole buffer with `header` as one edit.
201async fn reset_to(handle: &Arc<dyn Document>, header: &str) {
202 let snap = handle.snapshot();
203 // CV.3: ROPE space — this addresses the very end of the buffer
204 // (append point / full-extent replace), which lives past the
205 // terminating newline.
206 let last = snap.buffer.rope_line_count().saturating_sub(1);
207 let last_line = snap.buffer.line(last).unwrap_or_default();
208 let end = Position::new(last, last_line.len() as u32);
209 let edit = Edit::replace(Range::new(Position::ZERO, end), header.to_string());
210 let _ = handle.apply_edit_batch(vec![edit]).await;
211}
212
213/// CM.3b: RAII guard returned by [`CompilationMode::on_activate`].
214///
215/// Holds BOTH the streaming-output subscription (whose `Drop`
216/// unsubscribes the drain) AND the per-buffer action-handler
217/// registrations (whose `Drop` unregisters the `<CR>` jump handler
218/// from the `ActionHandlerRegistry`). Fields drop in declaration
219/// order; no custom `Drop` is needed — each field cleans up itself.
220/// Mirrors `ProjectSearchModeGuard`. Both fields default
221/// to empty so the early-return (missing service / runtime) paths
222/// hand back an inert guard.
223#[derive(Default)]
224pub struct CompilationModeGuard {
225 _output_sub: Option<Subscription>,
226 _action_handler_registrations: Vec<ActionHandlerRegistration>,
227}
228
229impl Mode for CompilationMode {
230 type Guard = CompilationModeGuard;
231
232 fn id(&self) -> ModeId {
233 Self::mode_id()
234 }
235
236 fn kind(&self) -> ModeKind {
237 ModeKind::Major
238 }
239
240 /// MG.RO: `read-only-mode` is where the operator gate actually is.
241 ///
242 /// `ReadOnly = true` above stops Insert-mode TYPING and nothing else — it
243 /// is read by `read_only_edit_rejected`, which guards the char path, while
244 /// a `Document`'s grammar dispatch applies its own edits and hands the host
245 /// an already-applied `Effect::Edits`. So `x` deleted a character out of
246 /// this buffer while it reported itself read-only. Verified, not inferred.
247 ///
248 /// `read-only-mode` carries the option AND the `invocation_runner`
249 /// (`Editor::run_read_only_motion`): motions move, `:` and `/` fall
250 /// through, mutating operators echo instead of silently editing. Declared
251 /// on the MAJOR because an implied mode is followed from the mode being
252 /// activated.
253 fn implies(&self) -> &[lattice_mode::ModeId] {
254 static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
255 IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
256 }
257
258 fn options(&self) -> OptionOverrideSet {
259 // User keystrokes can't mutate `*compilation*` — the
260 // compilation service owns the content; owner writes route
261 // through `apply_edit_batch` which bypasses the
262 // dispatcher's read-only gate. `NoFile = true`: it is a
263 // transcript, not an on-disk file (`:q` never warns; `:w`
264 // is a no-op).
265 lattice_config::overrides! {
266 lattice_config::ReadOnly = true,
267 lattice_config::NoFile = true,
268 }
269 }
270
271 fn required_capabilities(&self) -> CapabilitySet {
272 CapabilitySet::empty()
273 }
274
275 /// `<C-c>` kill + `<CR>` jump-to-location. Resolved at host
276 /// translation time via `CommandRegistry` against the names
277 /// registered by `lattice-host::actions::populate`.
278 ///
279 /// RV.2: `gr` is deliberately absent — see
280 /// [`Self::refresh_action`].
281 fn keymap(&self) -> Keymap {
282 Keymap::from_entries(compilation_keymap_entries())
283 }
284
285 /// RV.2 (2026-08-10): recompile is this mode's refresh.
286 ///
287 /// The chord (`gr`) lives once on `refreshable-view-mode`, which the
288 /// implies cascade activates because this returns `Some`. The
289 /// handler body is unchanged — `action:compilation-recompile` is the
290 /// same command the mode's own `gr` entry used to name directly.
291 /// See `docs/dev/architecture/mode-architecture.md` §5.5.
292 fn refresh_action(&self) -> Option<&'static str> {
293 Some("action:compilation-recompile")
294 }
295
296 /// OA.4b: this view folds by blocks, so `<Tab>` / `<S-Tab>` come from the
297 /// shared `foldable-view-mode`. Nothing special to do on a block, so it
298 /// names the generic body.
299 fn fold_toggle_action(&self) -> Option<&'static str> {
300 Some(lattice_mode::FOLD_TOGGLE_DEFAULT_ACTION)
301 }
302
303 /// CM.3c: severity gutter marks for the `*compilation*` buffer. Reads
304 /// the per-buffer severity index the renderer injected as
305 /// [`CompilationSeverityData`] — produced off-thread by the drain,
306 /// delivered through `render_state` (never scanned at paint time) —
307 /// and maps each `(line, level)` to a leftmost-gutter
308 /// [`GutterDecoration::Severity`], the SAME column LSP diagnostics
309 /// paint (so no renderer edit is needed). Graceful empty when the
310 /// service is absent (no marks produced yet, or a stripped render
311 /// path). O(entries), no allocation proportional to buffer size.
312 fn gutter_decorations(&self, ctx: &DecorationCtx<'_>) -> Vec<GutterDecoration> {
313 let Some(data) = ctx.service::<CompilationSeverityData>() else {
314 return Vec::new();
315 };
316 // SG.4b: the SAME built-in signs LSP diagnostics use, so a compilation
317 // error and a language-server error are one mark in one column with
318 // no host code that knows the difference.
319 let Some(ids) = ctx.service::<lattice_mode::BuiltinSignIds>() else {
320 return Vec::new();
321 };
322 data.entries
323 .iter()
324 .map(|(line, level)| GutterDecoration::Sign {
325 line: *line,
326 sign: ids.for_severity(*level),
327 })
328 .collect()
329 }
330
331 fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
332 Box::pin(async move {
333 let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
334 let Some(store) = ctx.service::<BufferStoreHandle>() else {
335 return Ok(CompilationModeGuard::default());
336 };
337 let Some(handle) = store.handle_for(buffer_id) else {
338 return Ok(CompilationModeGuard::default());
339 };
340 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
341 return Ok(CompilationModeGuard::default());
342 };
343
344 // T.7 (2026-07-22): the mode OWNS its compilation theme
345 // elements. Register them here (idempotent by name) AND
346 // resolve the actual colours from the theme so the
347 // headerline doesn't use hardcoded RGB.
348 let mut hl_colors: Option<(u32, u32, u32, u32, u32)> = None; // cmd, in_progress, success, failure, dim
349 if let Some(theme) = ctx
350 .service::<ThemeRegistryHandle>()
351 .map(|outer| (*outer).clone())
352 {
353 // CM.5: intern the ANSI elements and hand them to the
354 // service. Idempotent, so a re-activation is free.
355 if let Some(slot) = ctx.service::<crate::CompilationAnsiSlot>() {
356 let _ = slot.set(crate::AnsiPalette::register(&*theme));
357 }
358
359 let owner = ElementOwner::Mode(Self::mode_id().as_str().to_string().into());
360 let loc_id = theme.register(
361 ElementName::from_static("compilation.location"),
362 owner.clone(),
363 StyleSpec::new().bg(ColorRef::Palette("surface2".into())),
364 "Navigable file-location line in the *compilation* buffer (background tint).",
365 );
366
367 let cmd_id = theme.register(
368 ElementName::from_static("compilation.headerline.command"),
369 owner.clone(),
370 StyleSpec::new().fg(ColorRef::Literal(Color::Rgb(0xf9, 0xe2, 0xaf))),
371 "Compilation headerline: compile-command emphasis (warm yellow).",
372 );
373 let in_progress_id = theme.register(
374 ElementName::from_static("compilation.headerline.in_progress"),
375 owner.clone(),
376 StyleSpec::new().fg(ColorRef::Palette("subtext".into())),
377 "Compilation headerline: running (grey).",
378 );
379 let success_id = theme.register(
380 ElementName::from_static("compilation.headerline.success"),
381 owner.clone(),
382 StyleSpec::new().fg(ColorRef::Palette("green".into())),
383 "Compilation headerline: no errors (green).",
384 );
385 let failure_id = theme.register(
386 ElementName::from_static("compilation.headerline.failure"),
387 owner.clone(),
388 StyleSpec::new().fg(ColorRef::Palette("red".into())),
389 "Compilation headerline: errors present (red).",
390 );
391 let dim_id = theme.register(
392 ElementName::from_static("compilation.headerline.dim"),
393 owner,
394 StyleSpec::new().fg(ColorRef::Palette("muted".into())),
395 "Compilation headerline: warning counts / status text (muted).",
396 );
397
398 let resolved = theme.resolved();
399 let resolve_fg = |id: ElementId, fallback: u32| -> u32 {
400 resolved
401 .get(id)
402 .fg
403 .map(|c| c.to_rgb_u32(0))
404 .unwrap_or(fallback)
405 };
406 hl_colors = Some((
407 resolve_fg(cmd_id, 0xf9e2af),
408 resolve_fg(in_progress_id, 0x999999),
409 resolve_fg(success_id, 0x44cc88),
410 resolve_fg(failure_id, 0xff4444),
411 resolve_fg(dim_id, 0x888888),
412 ));
413 // Ship resolved compilation.location colours to the
414 // renderer so TUI/GPUI read from the theme instead of
415 // hardcoding RGB.
416 if let Some(bus) = ctx
417 .service::<CompilationThemeColorsBusHandle>()
418 .map(|h| (**h).clone())
419 {
420 let loc_bg = resolved
421 .get(loc_id)
422 .bg
423 .map(|c| c.to_rgb_u32(0))
424 .unwrap_or(0x45475a);
425 let loc_fg = resolved
426 .get(loc_id)
427 .fg
428 .map(|c| c.to_rgb_u32(0))
429 .unwrap_or(0x89b4fa);
430 let _ = bus.send((loc_bg, loc_fg));
431 }
432 }
433
434 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<CompilationOutputPushed>();
435 let sub_id = ctx.events().subscribe_typed::<CompilationOutputPushed>(tx);
436 let bus_handle = ctx.events_handle();
437
438 // CM.3c: the off-thread severity-gutter producer. Optional —
439 // absent in a stripped test harness without full boot wiring; the
440 // drain then just streams text and never publishes marks.
441 let gutter_bus: Option<
442 InboundBus<(lattice_core::BufferId, Vec<(u32, ErrorSeverity)>)>,
443 > = ctx
444 .service::<CompilationGutterBusHandle>()
445 .map(|h| (**h).clone());
446
447 // CM.3c (2026-07-22): the off-thread location-line index
448 // producer for theme-based highlighting. Twin of gutter_bus.
449 let location_bus: Option<InboundBus<(lattice_core::BufferId, Vec<(u32, u32, u32)>)>> =
450 ctx.service::<CompilationLocationBusHandle>()
451 .map(|h| (**h).clone());
452
453 // CM.3d (2026-07-22): create + register the
454 // compilation headerline — a sticky virtual row the
455 // drain updates live. Mirrors the project-search
456 // pattern: the drain sets running/command on Reset and
457 // finished counts on Finished.
458 let hl_state = Arc::new(std::sync::RwLock::new(CompilationHeadlineState::default()));
459 let hl_version = Arc::new(AtomicU64::new(1));
460 let _hl_registration = ctx
461 .service::<Arc<dyn VirtualRowRegistrar>>()
462 .map(|registrar| {
463 let registrar: Arc<dyn VirtualRowRegistrar> = (*registrar).clone();
464 let (cmd_fg, in_progress_fg, success_fg, failure_fg, dim_fg) =
465 hl_colors.unwrap_or((0xf9e2af, 0x999999, 0x44cc88, 0xff4444, 0x888888));
466 let headerline = CompilationHeaderline::new(
467 hl_state.clone(),
468 hl_version.clone(),
469 cmd_fg,
470 in_progress_fg,
471 success_fg,
472 failure_fg,
473 dim_fg,
474 );
475 let provider = Arc::new(HeaderlineProvider::new(
476 COMPILATION_HEADERLINE_PROVIDER_ID,
477 Arc::new(headerline),
478 ));
479 registrar.unregister(buffer_id, COMPILATION_HEADERLINE_PROVIDER_ID);
480 registrar.register(buffer_id, provider as Arc<dyn VirtualRowProvider>);
481 (registrar, buffer_id)
482 });
483
484 // Drain: coalesce every chunk available this wake into
485 // as few actor round-trips as the ordering allows —
486 // consecutive `Append`/`Finished` collapse into one
487 // insert; a `Reset` flushes the pending append then
488 // replaces the buffer.
489 //
490 // CM.3c: alongside the text writes the drain maintains the
491 // buffer's severity index AND location-line index.
492 // `next_line` is the 0-based buffer line the next appended
493 // char lands on, `severities` is the buffer's full `(line,
494 // severity)` list, and `location_lines` is the set of
495 // absolute line numbers carrying a file path + line:col.
496 // All three persist across batches and mirror the writes
497 // (a `Reset` clears + rebases, an `Append`/`Finished` scans
498 // at `next_line` then advances by its newline count). The
499 // FULL index is shipped through `gutter_bus` / `location_bus`
500 // whenever it changes (the send bakes in the editor wake).
501 // CM.5: the off-thread span publisher. Absent in a stripped
502 // test harness, in which case output still streams (and is
503 // still stripped) — just uncoloured.
504 let highlights: Option<PendingSyntheticHighlightsHandle> = ctx
505 .service::<PendingSyntheticHighlightsHandle>()
506 .map(|outer| (*outer).clone());
507
508 let drain_state = hl_state.clone();
509 let drain_version = hl_version.clone();
510 runtime.spawn(async move {
511 let mut next_line: u32 = 0;
512 // CM.5: lines appended since the last span publish.
513 //
514 // Uncoloured output — every build that has not had
515 // colour forced on, which is nearly all of them —
516 // publishes nothing at all, and this counter is what
517 // makes that safe. The span list must stay the same
518 // length as the buffer or a later coloured chunk
519 // splices over the wrong rows, so the skipped lines
520 // are carried here and paid as empty padding by the
521 // first publish that actually has colour to show.
522 let mut span_debt: usize = 0;
523 let mut severities: Vec<(u32, ErrorSeverity)> = Vec::new();
524 let mut location_lines: Vec<(u32, u32, u32)> = Vec::new();
525 while let Some(first) = rx.recv().await {
526 let mut batch = vec![first];
527 while let Ok(more) = rx.try_recv() {
528 batch.push(more);
529 }
530 let mut pending = String::new();
531 // CM.5: the spans for `pending`, and the buffer line
532 // its first line lands on. Tracked together with the
533 // text so a flush splices one aligned pair — the same
534 // one-thing-being-spliced rule the highlight drain
535 // states for diff signs.
536 let mut pending_spans: Vec<Vec<lattice_cells::StyledSpan>> = Vec::new();
537 let mut pending_start = next_line;
538 let mut dirty = false;
539 for event in batch {
540 match event.chunk {
541 OutputChunk::Reset { ref header } => {
542 let flush = std::mem::take(&mut pending);
543 // The pending spans are deliberately
544 // dropped rather than published: the
545 // reset below replaces the buffer, and
546 // `clear_spans` re-seeds the list to
547 // match. Publishing them first would
548 // be a wake for content about to be
549 // overwritten.
550 pending_spans.clear();
551 append_at_end(&handle, flush).await;
552 reset_to(&handle, header).await;
553 // CM.3d: update headerline state — extract
554 // the command from the first line of the
555 // header ("$ cargo build" → "cargo build").
556 if let Some(cmd_line) = header.lines().next() {
557 let cmd = cmd_line.strip_prefix("$ ").unwrap_or(cmd_line);
558 if let Ok(mut s) = drain_state.write() {
559 s.command = cmd.to_string();
560 s.running = true;
561 s.last_counts = None;
562 s.killed = false;
563 }
564 }
565 drain_version.fetch_add(1, Ordering::Release);
566 // The reset replaces the whole buffer: drop the
567 // prior index, rebase the counter to the header,
568 // and scan the header itself (rare, but keeps the
569 // index consistent with the buffer content).
570 severities.clear();
571 severities.extend(scan_severities(0, header));
572 location_lines.clear();
573 location_lines.extend(scan_location_lines(0, header));
574 next_line = count_newlines(header);
575 // The reset replaced the buffer, so the
576 // prior run's spans are gone with it.
577 // The header is editor-generated and
578 // never carries escapes, hence empty.
579 clear_spans(highlights.as_deref(), buffer_id, next_line as usize);
580 span_debt = 0;
581 pending_start = next_line;
582 dirty = true;
583 }
584 OutputChunk::Append { text, spans } => {
585 severities.extend(scan_severities(next_line, &text));
586 location_lines.extend(scan_location_lines(next_line, &text));
587 next_line = next_line.saturating_add(count_newlines(&text));
588 if pending.is_empty() {
589 pending_start = next_line.saturating_sub(count_newlines(&text));
590 }
591 pending.push_str(&text);
592 pending_spans.extend(spans);
593 dirty = true;
594 }
595 OutputChunk::Finished { summary } => {
596 severities.extend(scan_severities(next_line, &summary));
597 location_lines.extend(scan_location_lines(next_line, &summary));
598 if pending.is_empty() {
599 pending_start = next_line;
600 }
601 next_line = next_line.saturating_add(count_newlines(&summary));
602 pending.push_str(&summary);
603 // The summary is editor-generated and
604 // carries no escapes; its lines are
605 // padded at flush like any other
606 // uncoloured text.
607 // CM.3d: update headerline with final counts.
608 // errors = count of Error severity; warnings = count of Warning.
609 let errors = severities
610 .iter()
611 .filter(|(_, s)| *s == ErrorSeverity::Error)
612 .count();
613 let warnings = severities
614 .iter()
615 .filter(|(_, s)| *s == ErrorSeverity::Warning)
616 .count();
617 if let Ok(mut s) = drain_state.write() {
618 s.running = false;
619 s.last_counts = Some((errors, warnings));
620 s.killed = summary.contains("Compilation terminated");
621 }
622 drain_version.fetch_add(1, Ordering::Release);
623 dirty = true;
624 }
625 }
626 }
627 // Text first, spans second: a span list is indexed
628 // by buffer line, so publishing it ahead of the
629 // edit would briefly describe rows that do not
630 // exist yet.
631 let flushed_lines = count_newlines(&pending) as usize;
632 append_at_end(&handle, pending).await;
633 publish_spans(
634 highlights.as_deref(),
635 buffer_id,
636 pending_start,
637 std::mem::take(&mut pending_spans),
638 flushed_lines,
639 &mut span_debt,
640 );
641 // Ship the full per-buffer index off-keystroke. Best-effort:
642 // a stopped subsystem (dropped drain) just drops the send.
643 if dirty {
644 if let Some(bus) = &gutter_bus {
645 let _ = bus.send((buffer_id, severities.clone()));
646 }
647 if let Some(bus) = &location_bus {
648 let _ = bus.send((buffer_id, location_lines.clone()));
649 }
650 }
651 }
652 });
653
654 // CM.3b: register the `<CR>` jump-to-source handler on the
655 // per-buffer `ActionHandlerRegistry`. The closure reads the
656 // cursor line's text off the `*compilation*` buffer and
657 // parses a source location out of it (interleaving-proof:
658 // stdout/stderr mingle in the buffer, so a line→entry map
659 // isn't reliable — the parser is the single source of
660 // truth). On a hit it emits
661 // `AppEffect::CompileJumpToLocation`, whose host arm jumps
662 // + syncs the error list index; on a miss it returns `None`
663 // so `<CR>` is a harmless no-op (the dispatcher treats a
664 // registered-handler `None` as consumed, per M.10.1.b).
665 //
666 // Per `feedback_mode_owns_its_surface`: the mode owns the
667 // chord choice (`keymap()`) AND the handler body (this
668 // registration). Tolerates missing services (test harness
669 // without full boot wiring) via `?`.
670 let mut action_registrations: Vec<ActionHandlerRegistration> = Vec::new();
671 if let (Some(cmd_registry_arc), Some(action_handlers_arc)) = (
672 ctx.service::<CommandRegistryHandle>(),
673 ctx.service::<ActionHandlerRegistryHandle>(),
674 ) {
675 let cmd_registry_snapshot = cmd_registry_arc.load();
676 if let Some(jump_command_id) =
677 cmd_registry_snapshot.id_by_name("action:compilation-jump")
678 {
679 let action_handlers: ActionHandlerRegistryHandle =
680 (*action_handlers_arc).clone();
681 let store_for_handler: BufferStoreHandle = (*store).clone();
682 let buffer_id_for_handler = buffer_id;
683 let handler: ActionHandler =
684 Arc::new(move |ctx: &ActionContext<'_>| -> Option<Effect> {
685 let handle = store_for_handler.handle_for(buffer_id_for_handler)?;
686 let snap = handle.snapshot();
687 let text = snap.buffer.line(ctx.cursor.line)?;
688 let loc = crate::parser::parse_location_line(&text)?;
689 Some(Effect::AppAction(AppEffect::CompileJumpToLocation {
690 path: loc.path,
691 line: loc.line,
692 col: loc.col,
693 }))
694 });
695 action_registrations.push(action_handlers.register(jump_command_id, handler));
696 }
697 }
698
699 Ok(CompilationModeGuard {
700 _output_sub: Some(Subscription::new(bus_handle, sub_id)),
701 _action_handler_registrations: action_registrations,
702 })
703 })
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710
711 // ── CM.5: span/text alignment ───────────────────────────────
712 //
713 // The property under test is that the published span list stays
714 // the same length as the buffer's text. It is worth pinning
715 // directly because the failure is silent and delayed: nothing
716 // looks wrong until some *later* coloured line appears, and then
717 // it is painted over the wrong row.
718
719 use lattice_cells::{Style, StyledSpan};
720 use lattice_theme::ElementId;
721
722 fn span(start: usize, end: usize) -> StyledSpan {
723 StyledSpan {
724 start,
725 end,
726 style: Style::Element(ElementId(1)),
727 }
728 }
729
730 /// The spans currently stored for `id`, as line lengths.
731 fn stored(h: &PendingSyntheticHighlights, id: lattice_core::BufferId) -> Option<Vec<usize>> {
732 let map = h.map.lock().ok()?;
733 let update = map.get(&id)?;
734 match &update.op {
735 lattice_mode::pending_synthetic_highlights::HighlightsOp::InsertAt {
736 spans, ..
737 } => Some(spans.iter().map(|l| l.len()).collect()),
738 lattice_mode::pending_synthetic_highlights::HighlightsOp::Replace(spans) => {
739 Some(spans.iter().map(|l| l.len()).collect())
740 }
741 _ => None,
742 }
743 }
744
745 fn start_line_of(h: &PendingSyntheticHighlights, id: lattice_core::BufferId) -> Option<u32> {
746 let map = h.map.lock().ok()?;
747 match &map.get(&id)?.op {
748 lattice_mode::pending_synthetic_highlights::HighlightsOp::InsertAt {
749 start_line,
750 ..
751 } => Some(*start_line),
752 _ => None,
753 }
754 }
755
756 #[test]
757 fn uncoloured_flush_publishes_nothing_and_banks_debt() {
758 let h = PendingSyntheticHighlights::new();
759 let id = lattice_core::BufferId(1);
760 let mut debt = 0;
761 publish_spans(Some(&h), id, 0, vec![Vec::new(); 3], 3, &mut debt);
762 assert_eq!(debt, 3, "three uncoloured lines are owed");
763 assert!(
764 stored(&h, id).is_none(),
765 "nothing to paint means nothing published — and no renderer wake"
766 );
767 }
768
769 #[test]
770 fn a_coloured_flush_pays_the_banked_debt_as_empty_rows() {
771 let h = PendingSyntheticHighlights::new();
772 let id = lattice_core::BufferId(1);
773 let mut debt = 0;
774 // Five uncoloured lines, then one coloured line at line 5.
775 publish_spans(Some(&h), id, 0, vec![Vec::new(); 5], 5, &mut debt);
776 publish_spans(Some(&h), id, 5, vec![vec![span(0, 4)]], 1, &mut debt);
777
778 assert_eq!(debt, 0, "the debt was paid");
779 assert_eq!(
780 stored(&h, id),
781 Some(vec![0, 0, 0, 0, 0, 1]),
782 "five empty rows precede the coloured one so the list \
783 length matches the six lines of text"
784 );
785 assert_eq!(
786 start_line_of(&h, id),
787 Some(0),
788 "the splice anchors where the skipped lines began, not where the colour did"
789 );
790 }
791
792 #[test]
793 fn spans_are_padded_when_the_flush_has_more_text_than_spans() {
794 // The batch mixed reader output (one coloured line) with an
795 // editor-generated summary (two more lines, no spans).
796 let h = PendingSyntheticHighlights::new();
797 let id = lattice_core::BufferId(1);
798 let mut debt = 0;
799 publish_spans(Some(&h), id, 0, vec![vec![span(0, 2)]], 3, &mut debt);
800 assert_eq!(
801 stored(&h, id),
802 Some(vec![1, 0, 0]),
803 "the summary's lines get empty span rows"
804 );
805 }
806
807 #[test]
808 fn spans_are_truncated_when_they_outrun_the_text() {
809 let h = PendingSyntheticHighlights::new();
810 let id = lattice_core::BufferId(1);
811 let mut debt = 0;
812 publish_spans(
813 Some(&h),
814 id,
815 0,
816 vec![vec![span(0, 2)], vec![span(0, 2)], vec![span(0, 2)]],
817 2,
818 &mut debt,
819 );
820 assert_eq!(stored(&h, id), Some(vec![1, 1]));
821 }
822
823 #[test]
824 fn an_empty_flush_publishes_nothing_and_owes_nothing() {
825 let h = PendingSyntheticHighlights::new();
826 let id = lattice_core::BufferId(1);
827 let mut debt = 0;
828 publish_spans(Some(&h), id, 0, Vec::new(), 0, &mut debt);
829 assert_eq!(debt, 0);
830 assert!(stored(&h, id).is_none());
831 }
832
833 #[test]
834 fn without_a_highlights_service_publishing_is_a_no_op() {
835 let mut debt = 0;
836 publish_spans(None, lattice_core::BufferId(1), 0, Vec::new(), 4, &mut debt);
837 assert_eq!(
838 debt, 0,
839 "no consumer means no debt to track — the counter must not grow unboundedly"
840 );
841 }
842
843 #[test]
844 fn reset_replaces_the_list_with_the_headers_empty_rows() {
845 let h = PendingSyntheticHighlights::new();
846 let id = lattice_core::BufferId(1);
847 let mut debt = 0;
848 publish_spans(Some(&h), id, 0, vec![vec![span(0, 3)]], 1, &mut debt);
849 clear_spans(Some(&h), id, 2);
850 assert_eq!(
851 stored(&h, id),
852 Some(vec![0, 0]),
853 "a reset drops the prior run's spans and re-seeds one empty \
854 row per header line"
855 );
856 }
857
858 #[test]
859 fn mode_id_is_compilation_mode() {
860 assert_eq!(CompilationMode::mode_id(), ModeId::new("compilation-mode"));
861 assert_eq!(CompilationMode::mode_id().as_str(), "compilation-mode");
862 }
863
864 #[test]
865 fn kind_is_major_with_no_capability_requirements() {
866 assert_eq!(CompilationMode.kind(), ModeKind::Major);
867 assert_eq!(
868 CompilationMode.required_capabilities(),
869 CapabilitySet::empty()
870 );
871 }
872
873 #[test]
874 fn options_are_read_only_and_no_file() {
875 let overrides = CompilationMode.options();
876 let has_true = |type_id: std::any::TypeId| {
877 overrides.iter().any(|ov| {
878 ov.option_type_id == type_id && ov.downcast_value::<bool>() == Some(&true)
879 })
880 };
881 assert!(
882 has_true(std::any::TypeId::of::<lattice_config::ReadOnly>()),
883 "expected ReadOnly = true override"
884 );
885 assert!(
886 has_true(std::any::TypeId::of::<lattice_config::NoFile>()),
887 "expected NoFile = true override"
888 );
889 assert_eq!(overrides.iter().count(), 2, "exactly ReadOnly + NoFile");
890 }
891
892 #[test]
893 fn count_newlines_counts_line_advances() {
894 assert_eq!(count_newlines(""), 0);
895 assert_eq!(count_newlines("no newline"), 0);
896 assert_eq!(count_newlines("a\n"), 1);
897 assert_eq!(count_newlines("a\nb\n"), 2);
898 assert_eq!(count_newlines("a\nb"), 1);
899 }
900
901 #[test]
902 fn gutter_decorations_maps_severity_index_to_decorations() {
903 use lattice_mode::{
904 CompilationSeverityData, DecorationCtx, GutterSeverityLevel, ServiceRegistry,
905 };
906 let mut services = ServiceRegistry::new();
907 services.register(CompilationSeverityData {
908 entries: std::sync::Arc::new(vec![
909 (2, GutterSeverityLevel::Error),
910 (5, GutterSeverityLevel::Warning),
911 ]),
912 });
913 // SG.4b: a severity mark is a SIGN now — and the SAME built-in sign
914 // an LSP diagnostic uses, so a compilation error and a language-server
915 // error are one mark in one column with no host code that knows the
916 // difference.
917 let mut registry = lattice_mode::SignRegistry::new();
918 let ids = lattice_mode::register_builtin_signs(
919 &mut registry,
920 lattice_mode::DiagnosticGlyphs::default(),
921 );
922 services.register(ids);
923 let ctx = DecorationCtx::new(lattice_core::BufferId(7), &services);
924 let decos = CompilationMode.gutter_decorations(&ctx);
925 assert_eq!(
926 decos,
927 vec![
928 GutterDecoration::Sign {
929 line: 2,
930 sign: ids.diagnostic_error
931 },
932 GutterDecoration::Sign {
933 line: 5,
934 sign: ids.diagnostic_warning
935 },
936 ]
937 );
938 }
939
940 /// SG.4b: without the interned built-in ids (a stripped harness), the
941 /// producer contributes nothing rather than placements resolving to
942 /// whatever sits at id 0.
943 #[test]
944 fn gutter_decorations_empty_without_builtin_sign_ids() {
945 use lattice_mode::{
946 CompilationSeverityData, DecorationCtx, GutterSeverityLevel, ServiceRegistry,
947 };
948 let mut services = ServiceRegistry::new();
949 services.register(CompilationSeverityData {
950 entries: std::sync::Arc::new(vec![(2, GutterSeverityLevel::Error)]),
951 });
952 let ctx = DecorationCtx::new(lattice_core::BufferId(7), &services);
953 assert!(CompilationMode.gutter_decorations(&ctx).is_empty());
954 }
955
956 #[test]
957 fn gutter_decorations_empty_without_service() {
958 // No `CompilationSeverityData` registered (stripped render path) →
959 // graceful empty, never a panic.
960 use lattice_mode::{DecorationCtx, ServiceRegistry};
961 let services = ServiceRegistry::new();
962 let ctx = DecorationCtx::new(lattice_core::BufferId(1), &services);
963 assert!(CompilationMode.gutter_decorations(&ctx).is_empty());
964 }
965
966 #[test]
967 fn apply_chunk_reset_replaces_all() {
968 let out = apply_chunk(
969 "stale content\n",
970 &OutputChunk::Reset {
971 header: "hdr\n".into(),
972 },
973 );
974 assert_eq!(out, "hdr\n");
975 }
976
977 #[test]
978 fn apply_chunk_append_and_finished_concatenate() {
979 let a = apply_chunk("hdr\n", &OutputChunk::append("line1\n"));
980 assert_eq!(a, "hdr\nline1\n");
981 let b = apply_chunk(
982 &a,
983 &OutputChunk::Finished {
984 summary: "done\n".into(),
985 },
986 );
987 assert_eq!(b, "hdr\nline1\ndone\n");
988 }
989
990 #[test]
991 fn keymap_entries_resolve_against_command_registry() {
992 use lattice_grammar::CommandRegistry;
993 let mut registry = CommandRegistry::new();
994 crate::register_compilation_ex_commands(&mut registry);
995 // Also register the action commands the keymap references
996 registry.register_action(
997 "action:compilation-recompile",
998 "doc",
999 lattice_grammar::ActionSpec {
1000 args_schema: vec![],
1001 apply: std::sync::Arc::new(|_| Ok(lattice_grammar::Effect::None)),
1002 },
1003 );
1004 registry.register_action(
1005 "action:compilation-jump",
1006 "doc",
1007 lattice_grammar::ActionSpec {
1008 args_schema: vec![],
1009 apply: std::sync::Arc::new(|_| Ok(lattice_grammar::Effect::None)),
1010 },
1011 );
1012
1013 let km = CompilationMode.keymap();
1014 for entry in &km.entries {
1015 if let Some(cmd_name) = entry.command {
1016 assert!(
1017 registry.id_by_name(cmd_name).is_some(),
1018 "keymap entry `{}` references command `{cmd_name}` which is not registered",
1019 entry.chord,
1020 );
1021 }
1022 }
1023 }
1024}