Skip to main content

lattice_multibuffer/providers/
problems.rs

1//! CM.4 (2026-07-22): the **`*problems*` multibuffer view** — the
2//! grouped, editable "problems" surface for the error list.
3//!
4//! Design: `docs/dev/architecture/compilation-mode.md` §4. Slice plan:
5//! `docs/dev/operations/slice-plans/compilation-mode.md` (CM.4).
6//!
7//! `:copen` groups the current error entries as anchored source
8//! excerpts (a few context lines around each error location), one
9//! excerpt per entry, grouped by file. The view is a regular
10//! `BufferKind::Multibuffer` — editable in place, edits propagate to
11//! the source via the standard M.3 pipeline — with `ProblemsMinorMode`
12//! as its identity marker. `:cclose` closes it.
13//!
14//! Like `providers::narrow` (and unlike feature-gated
15//! `providers::search`), problems is a **first-class built-in**: no
16//! cargo feature gate. It reuses the exact source-loading shape the
17//! search provider uses — read each referenced file into a fresh
18//! `RopeDocumentHandle` (`spawn_document`) and add it to the view's
19//! source map — the only difference being that the entry list is
20//! static (no async scan), so loading runs synchronously here rather
21//! than in a forwarder task.
22
23use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26
27use lattice_config::OptionOverrideSet;
28use lattice_core::{BufferFlags, BufferId, DocumentBuilder};
29use lattice_grammar::{CommandRegistry, CommandRegistryHandle};
30use lattice_mode::{
31    CapabilitySet, Keymap, LifecycleFuture, Mode, ModeActivator, ModeContext, ModeId, ModeKind,
32    ModeRegistry,
33};
34use lattice_protocol::error_list::{ErrorEntry, ErrorSeverity};
35use lattice_runtime::{Document, spawn_document};
36use lattice_syntax::LangRegistry;
37
38use crate::registry::MultibufferRegistryHandle;
39use crate::view::create_multibuffer_view;
40use crate::{Excerpt, ExcerptHeader, HeaderlineStatus};
41
42/// Context lines shown above and below each error location in the
43/// `*problems*` view. A small fixed window (±2), clamped to the
44/// file's line count; keeps each excerpt focused on the offending
45/// site while showing enough surrounding code to orient. (Search's
46/// `search.context_size` is a user option because search hits are
47/// open-ended; a problems excerpt is anchored on a known error, so a
48/// fixed window is the right default.)
49const CONTEXT: u32 = 2;
50
51// ─────────────────────────────────────────────────────────────────
52// ProblemsMinorMode — identity marker for `*problems*` views
53// ─────────────────────────────────────────────────────────────────
54
55/// `problems-minor-mode` — the provider-minor activated on a
56/// `*problems*` view. Pure identity marker (like [`NarrowMode`]):
57/// a multibuffer with this minor active IS a problems view, which the
58/// host's `:cclose` guard reads. Editable — no `ReadOnly` override, so
59/// edits propagate to the source. `on_activate` is a no-op so the
60/// marker is cheap; the `q`→close chord can land in a follow-up.
61///
62/// [`NarrowMode`]: crate::providers::narrow::NarrowMode
63pub struct ProblemsMinorMode;
64
65impl ProblemsMinorMode {
66    pub fn mode_id() -> ModeId {
67        ModeId::new("problems-minor-mode")
68    }
69}
70
71/// RAII guard for `ProblemsMinorMode`. Unit — no subscriptions /
72/// action handlers yet (mirrors `NarrowModeGuard`).
73pub struct ProblemsMinorModeGuard;
74
75impl Mode for ProblemsMinorMode {
76    type Guard = ProblemsMinorModeGuard;
77
78    fn id(&self) -> ModeId {
79        Self::mode_id()
80    }
81    fn kind(&self) -> ModeKind {
82        ModeKind::Minor
83    }
84    fn options(&self) -> OptionOverrideSet {
85        // Problems views are EDITABLE — edits propagate to the source
86        // via M.3. No ReadOnly override (matches narrow / search).
87        OptionOverrideSet::new()
88    }
89    fn required_capabilities(&self) -> CapabilitySet {
90        CapabilitySet::empty()
91    }
92    fn keymap(&self) -> Keymap {
93        // No contributed chords (a `q` → close binding can land in a
94        // follow-up once `action:problems-close` is registered).
95        //
96        // RV.3: `gr` is NOT declared here either — it lives once on
97        // `refreshable-view-mode`, reached via [`Self::refresh_action`].
98        Keymap::default()
99    }
100
101    /// RV.3 (2026-08-10): rebuild the view from the current error list.
102    ///
103    /// Before RV.1 this view had no `gr` at all and the key was
104    /// silently swallowed — one of the two gaps that motivated the
105    /// shared chord. The list it renders is a snapshot taken at
106    /// `:copen` time, so it goes stale the moment a compile re-runs or
107    /// (post-EP.3) the language server republishes; refresh is how the
108    /// user catches it up without closing and re-opening.
109    fn refresh_action(&self) -> Option<&'static str> {
110        Some("action:problems-refresh")
111    }
112
113    /// OA.4b: this view folds by blocks, so `<Tab>` / `<S-Tab>` come from the
114    /// shared `foldable-view-mode`. Nothing special to do on a block, so it
115    /// names the generic body.
116    fn fold_toggle_action(&self) -> Option<&'static str> {
117        Some(lattice_mode::FOLD_TOGGLE_DEFAULT_ACTION)
118    }
119
120    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
121        Box::pin(async move { Ok(ProblemsMinorModeGuard) })
122    }
123}
124
125// ─────────────────────────────────────────────────────────────────
126// create_problems_view — group error entries into excerpts
127// ─────────────────────────────────────────────────────────────────
128
129/// Human-readable severity label for an excerpt header.
130fn severity_label(severity: ErrorSeverity) -> &'static str {
131    match severity {
132        ErrorSeverity::Error => "error",
133        ErrorSeverity::Warning => "warning",
134        ErrorSeverity::Info => "info",
135        ErrorSeverity::Note => "note",
136    }
137}
138
139/// Build the [`ExcerptHeader`] for one error entry: `"<severity>:
140/// <message>"` as the title, with the source path attached so the rich
141/// header renderer shows the leading file-type icon + basename/dir
142/// split (the same shape the search provider's header uses).
143fn problems_excerpt_header(path: &Path, entry: &ErrorEntry) -> ExcerptHeader {
144    let mut header = ExcerptHeader::new(format!(
145        "{}: {}",
146        severity_label(entry.severity),
147        entry.message
148    ));
149    header.path = Some(path.to_path_buf());
150    header
151}
152
153/// Open a `*problems*` multibuffer view grouping `entries` as anchored
154/// source excerpts. Returns the new view's `BufferId`, or `None` when
155/// `entries` is empty (nothing to show) or every referenced file is
156/// unreadable (no excerpt could be built).
157///
158/// Grouping is stable: files appear in first-seen order; within a
159/// file, entries are ordered by line. Each entry becomes one excerpt
160/// of ±[`CONTEXT`] lines around its location, clamped to the file's
161/// line count.
162///
163/// Source loading mirrors `providers::search`: each unique file is
164/// read into a fresh `RopeDocumentHandle` via [`spawn_document`] and
165/// added to the view's source map. A file that fails to read is logged
166/// and skipped (its entries drop out); the view still opens for the
167/// readable ones. Consistency with search is deliberate — no novel
168/// file-reading path.
169pub fn create_problems_view(
170    activator: &mut dyn ModeActivator,
171    entries: &[ErrorEntry],
172    registry: CommandRegistryHandle,
173    lang_registry: Option<Arc<LangRegistry>>,
174) -> Option<BufferId> {
175    let (sources, excerpts, n_files) = build_problems_excerpts(entries)?;
176
177    let n_entries = excerpts.len();
178    let view_id = create_multibuffer_view(
179        activator,
180        sources,
181        excerpts,
182        Some("*problems*".to_string()),
183        BufferFlags::default(),
184        registry,
185        lang_registry,
186        crate::FoldGrouping::SourceFile,
187    );
188
189    set_problems_headerline(activator, view_id, n_entries, n_files);
190    activator.activate_minor_by_id(view_id, ProblemsMinorMode::mode_id());
191    Some(view_id)
192}
193
194/// RV.3 (2026-08-10): the source-loading + excerpt-grouping step,
195/// shared by [`create_problems_view`] and [`refresh_problems_view`].
196///
197/// Returns `(sources, excerpts, n_files)`, or `None` when `entries` is
198/// empty or every referenced file proved unreadable — the two cases
199/// where there is nothing to show. Extracted rather than duplicated so
200/// a refresh cannot drift from the grouping an open produces.
201fn build_problems_excerpts(
202    entries: &[ErrorEntry],
203) -> Option<(HashMap<BufferId, Arc<dyn Document>>, Vec<Excerpt>, usize)> {
204    if entries.is_empty() {
205        return None;
206    }
207
208    // Group entries by file, preserving first-seen file order.
209    let mut file_order: Vec<PathBuf> = Vec::new();
210    let mut by_file: HashMap<PathBuf, Vec<ErrorEntry>> = HashMap::new();
211    for entry in entries {
212        if !by_file.contains_key(&entry.path) {
213            file_order.push(entry.path.clone());
214        }
215        by_file
216            .entry(entry.path.clone())
217            .or_default()
218            .push(entry.clone());
219    }
220
221    let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
222    let mut excerpts: Vec<Excerpt> = Vec::new();
223    let mut n_files: usize = 0;
224
225    for path in &file_order {
226        // Mirror search's per-file loader: read the file, spawn a
227        // fresh RopeDocumentHandle, add it to the source map. Skip
228        // (log + continue) on a read error — never abort the whole
229        // view, never panic.
230        let text = match std::fs::read_to_string(path) {
231            Ok(t) => t,
232            Err(e) => {
233                tracing::warn!(
234                    path = %path.display(),
235                    error = %e,
236                    "problems: source file unreadable; skipping its entries",
237                );
238                continue;
239            }
240        };
241        let last_line = (text.lines().count() as u32).saturating_sub(1);
242
243        let source_id = BufferId::next();
244        let document = DocumentBuilder::default()
245            .with_text(&text)
246            .with_path(path.clone())
247            .build();
248        // Source docs get a fresh empty registry behind the `ArcSwap`
249        // handle `spawn_document` expects (search's exact shape).
250        let source_registry = Arc::new(arc_swap::ArcSwap::from_pointee(CommandRegistry::new()));
251        let handle = spawn_document(source_id, document, source_registry);
252        let dyn_handle: Arc<dyn Document> = Arc::new(handle);
253        sources.insert(source_id, dyn_handle);
254        n_files += 1;
255
256        // Entries for this file, ordered by line.
257        let mut file_entries = by_file.remove(path).unwrap_or_default();
258        file_entries.sort_by_key(|e| e.line);
259        for entry in &file_entries {
260            let line = entry.line.min(last_line);
261            let start = line.saturating_sub(CONTEXT);
262            let end = (line + CONTEXT).min(last_line);
263            let header = problems_excerpt_header(path, entry);
264            excerpts.push(Excerpt::new(source_id, start, end).with_header(header));
265        }
266    }
267
268    // Every referenced file was unreadable — nothing to show.
269    if excerpts.is_empty() {
270        return None;
271    }
272
273    Some((sources, excerpts, n_files))
274}
275
276/// Sticky headerline — the entry/file count. Problems composition is
277/// synchronous (no scan), so straight to Complete.
278fn set_problems_headerline(
279    activator: &mut dyn ModeActivator,
280    view_id: BufferId,
281    n_entries: usize,
282    n_files: usize,
283) {
284    if let Some(mb_reg) = activator.services().get::<MultibufferRegistryHandle>()
285        && let Some(view) = mb_reg.handle(view_id)
286    {
287        view.set_headerline(HeaderlineStatus::Complete {
288            summary: format!("[problems] {n_entries} in {n_files} files"),
289            emphasis: None,
290        });
291    }
292}
293
294/// RV.3 (2026-08-10): rebuild an existing `*problems*` view from the
295/// current error list, in place. Returns the new entry count, or `None`
296/// when the view is unknown to the multibuffer registry or the fresh
297/// entry set yields nothing to show (in which case the view is left
298/// exactly as it was — a refresh must never blank the buffer the user
299/// is reading).
300///
301/// In place is the whole point: [`create_problems_view`] mints a new
302/// `BufferId` every call, so "refresh" cannot be a re-open without
303/// stranding the old view and opening a second `*problems*`.
304/// [`crate::MultibufferDocumentHandle::replace_excerpts`] swaps the
305/// source map and excerpt list atomically and republishes, so the
306/// buffer the user is looking at simply becomes current.
307///
308/// Sources are re-read from disk, which is the point of a refresh here:
309/// the view's sources are freshly-spawned handles taken at open time,
310/// not the live editor buffers, so both the error list *and* the file
311/// contents may have moved on.
312pub fn refresh_problems_view(
313    activator: &mut dyn ModeActivator,
314    view_id: BufferId,
315    entries: &[ErrorEntry],
316) -> Option<usize> {
317    let (sources, excerpts, n_files) = build_problems_excerpts(entries)?;
318    let n_entries = excerpts.len();
319
320    let mb_reg = activator.services().get::<MultibufferRegistryHandle>()?;
321    let view = mb_reg.handle(view_id)?;
322    view.replace_excerpts(sources, excerpts);
323    drop(view);
324
325    set_problems_headerline(activator, view_id, n_entries, n_files);
326    Some(n_entries)
327}
328
329// ─────────────────────────────────────────────────────────────────
330// Boot integration
331// ─────────────────────────────────────────────────────────────────
332
333/// Boot helper — register the problems provider-minor mode. Called
334/// from [`crate::install`] alongside the other multibuffer modes.
335pub fn register_problems_mode(mode_registry: &mut ModeRegistry) {
336    mode_registry
337        .register(ProblemsMinorMode)
338        .expect("problems-minor-mode registers without conflict at boot");
339}
340
341/// Boot helper — register the `:copen` + `:cclose` ex-commands.
342///
343/// `:copen` emits [`AppEffect::ProblemsOpen`]; the host arm reads the
344/// core error list and calls [`create_problems_view`]. `:cclose`
345/// emits [`AppEffect::ProblemsClose`], which the host guards to the
346/// active problems view before closing.
347///
348/// [`AppEffect::ProblemsOpen`]: lattice_grammar::app_effect::AppEffect::ProblemsOpen
349/// [`AppEffect::ProblemsClose`]: lattice_grammar::app_effect::AppEffect::ProblemsClose
350pub fn register_problems_ex_commands(registry: &mut CommandRegistry) {
351    use lattice_grammar::app_effect::AppEffect;
352    use lattice_grammar::args::Args;
353    use lattice_grammar::command::LatencyClass;
354    use lattice_grammar::effect::Effect;
355    use lattice_grammar::registry::{ExCommandSpec, SurfaceForm};
356
357    // naming-2026-07-22: readable canonical `:problems` leads; the vim
358    // `:copen`/`:cclose` spellings are aliases in `lattice-host::excommand`.
359    registry.register_ex_command(
360        "problems",
361        "Open the `*problems*` view: the current error list grouped as \
362         editable source excerpts by file. Edits propagate to the source. \
363         `:problems-close` (vim `:cclose`) closes it. Vim alias: `:copen`.",
364        ExCommandSpec {
365            latency_class: LatencyClass::Reflex,
366            accepts_bang: false,
367            accepts_range: false,
368            parse_args: Arc::new(|_s: &str, _bang: bool| Ok(Args::None)),
369            apply: Arc::new(|_ctx| Ok(Effect::AppAction(AppEffect::ProblemsOpen))),
370            args_schema: vec![],
371            surface_form: SurfaceForm::Keyword,
372        },
373    );
374
375    registry.register_ex_command(
376        "problems-close",
377        "Close the active `*problems*` view, leaving the source buffers open (vim `:cclose`).",
378        ExCommandSpec {
379            latency_class: LatencyClass::Reflex,
380            accepts_bang: false,
381            accepts_range: false,
382            parse_args: Arc::new(|_s: &str, _bang: bool| Ok(Args::None)),
383            apply: Arc::new(|_ctx| Ok(Effect::AppAction(AppEffect::ProblemsClose))),
384            args_schema: vec![],
385            surface_form: SurfaceForm::Keyword,
386        },
387    );
388
389    // RV.3: the refresh target `ProblemsMinorMode::refresh_action`
390    // names. Registered as a plain action rather than an ex-command —
391    // it is reached through the shared `gr`, not typed at the `:` line,
392    // and `:problems` already re-opens for anyone who wants that.
393    //
394    // Its `apply` is LIVE (not the dead `Effect::None` of a
395    // handler-intercepted action): this provider registers no
396    // `ActionHandler`, so the Action gate is what satisfies it. That is
397    // exactly the shape the RV.2 dispatch fix exists to support.
398    registry.register_action(
399        "action:problems-refresh",
400        "problems-mode `gr`: rebuild the `*problems*` view from the current error list.",
401        lattice_grammar::registry::ActionSpec {
402            apply: Arc::new(|_ctx| Ok(Effect::AppAction(AppEffect::ProblemsRefresh))),
403            args_schema: vec![],
404        },
405    );
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn severity_labels_are_stable() {
414        assert_eq!(severity_label(ErrorSeverity::Error), "error");
415        assert_eq!(severity_label(ErrorSeverity::Warning), "warning");
416        assert_eq!(severity_label(ErrorSeverity::Info), "info");
417        assert_eq!(severity_label(ErrorSeverity::Note), "note");
418    }
419
420    #[test]
421    fn header_carries_severity_message_and_path() {
422        let entry = ErrorEntry {
423            path: PathBuf::from("/tmp/a.rs"),
424            line: 3,
425            col: 0,
426            severity: ErrorSeverity::Warning,
427            message: "unused variable".to_string(),
428        };
429        let header = problems_excerpt_header(&entry.path, &entry);
430        assert_eq!(header.title, "warning: unused variable");
431        assert_eq!(
432            header.path.as_deref(),
433            Some(std::path::Path::new("/tmp/a.rs"))
434        );
435    }
436
437    #[test]
438    fn register_problems_ex_commands_registers_problems_open_and_close() {
439        let mut registry = CommandRegistry::new();
440        register_problems_ex_commands(&mut registry);
441        assert!(
442            registry.id_by_name("problems").is_some(),
443            "`:problems` ex-command must register (vim alias `:copen`)"
444        );
445        assert!(
446            registry.id_by_name("problems-close").is_some(),
447            "`:problems-close` ex-command must register (vim alias `:cclose`)"
448        );
449    }
450}