Skip to main content

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}