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}