mode line: per-buffer diagnostic counts (E:n W:n)

M4.6 follow-up piece 1: the mode line's right segment now shows
error/warning counts for the window's buffer, computed from the
shared diag store at paint time. Counts are suppressed while the
URI's diagnostics are stale (mid-edit, pre-publish) so the readout
never describes text that no longer exists. Info/hint severities
stay off the mode line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-10 17:14:59 -04:00
parent 28e8d4ed80
commit a0bd4d7f6c
1 changed files with 128 additions and 2 deletions

View File

@ -1207,6 +1207,8 @@ pub fn paint_frame(
// Render every window.
let registry = core.registry.clone();
let reg = registry.borrow();
let diag_store = state.lsp_manager.borrow().diag_store();
let diag_guard = diag_store.lock().expect("diag store mutex poisoned");
for (id, window) in &mut core.windows {
let Some(rect) = placements.get(id).copied() else {
continue;
@ -1247,6 +1249,7 @@ pub fn paint_frame(
window.text_view.line_count(),
coord.row as usize,
);
let diags = diag_mode_line_summary(&diag_guard, buf);
paint_mode_line(
grid,
&rect,
@ -1256,8 +1259,10 @@ pub fn paint_frame(
coord.row,
coord.col,
&scroll,
&diags,
);
}
drop(diag_guard);
drop(reg);
paint_status_line(grid, core, &state.lua_host, &state.dispatcher, term_size);
@ -1398,9 +1403,43 @@ fn paint_local_selection(
}
}
/// Format the mode-line diagnostic readout for a buffer: `"E:2 W:5"`
/// with only the nonzero severities (errors, then warnings; info and
/// hints stay off the mode line). Empty when the buffer has no file
/// path, no diagnostics, or the stored diagnostics are stale — the
/// document was edited since the last `publishDiagnostics`, so the
/// counts would describe text that no longer exists (T M4.6).
fn diag_mode_line_summary(
store: &crate::diag::DiagnosticStore,
buf: &crate::buffer::Buffer,
) -> String {
let Some(path) = buf.file_path() else {
return String::new();
};
let uri = crate::lsp::path_to_file_uri(path);
if store.is_stale(&uri) {
return String::new();
}
let mut errors = 0usize;
let mut warnings = 0usize;
for d in store.for_uri(&uri) {
match d.severity {
crate::diag::DiagnosticSeverity::Error => errors += 1,
crate::diag::DiagnosticSeverity::Warning => warnings += 1,
_ => {}
}
}
match (errors, warnings) {
(0, 0) => String::new(),
(e, 0) => format!("E:{e}"),
(0, w) => format!("W:{w}"),
(e, w) => format!("E:{e} W:{w}"),
}
}
#[allow(
clippy::too_many_arguments,
reason = "the mode line packs eight unrelated facts; bundling them into a struct just adds ceremony"
reason = "the mode line packs nine unrelated facts; bundling them into a struct just adds ceremony"
)]
fn paint_mode_line(
grid: &mut crate::cell::CellGrid<'_>,
@ -1411,6 +1450,7 @@ fn paint_mode_line(
cursor_row: u32,
cursor_col: u32,
scroll: &str,
diags: &str,
) {
if rect.size.rows == 0 || rect.size.cols == 0 {
return;
@ -1419,7 +1459,11 @@ fn paint_mode_line(
let marker = if modified { '*' } else { ' ' };
let active_marker = if is_active { '+' } else { '-' };
let left = format!(" {active_marker}{marker} {name} ");
let right = format!(" L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1);
let right = if diags.is_empty() {
format!(" L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1)
} else {
format!(" {diags} L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1)
};
// Fill the row with reverse-video spaces.
let mode_style = crate::cell::Style {
@ -5089,6 +5133,88 @@ mod tests {
}
}
/// Give the active buffer a file path and return its `file://`
/// URI, so diag-store entries can be keyed to it.
fn set_active_buffer_path(s: &EditorState, path: &str) -> String {
let core = s.core.borrow();
let registry = core.registry.clone();
let mut reg = registry.borrow_mut();
let buf = reg.get_mut(core.active_buffer_id()).unwrap();
buf.set_file_path(Some(std::path::PathBuf::from(path)));
crate::lsp::path_to_file_uri(buf.file_path().unwrap())
}
fn diag_with_severity(severity: crate::diag::DiagnosticSeverity) -> crate::diag::Diagnostic {
crate::diag::Diagnostic {
start_line: 0,
start_col: 0,
end_line: 0,
end_col: 1,
severity,
message: "boom".into(),
source: None,
code: None,
}
}
#[test]
fn render_mode_line_shows_diagnostic_counts() {
use crate::diag::DiagnosticSeverity::{Error, Hint, Warning};
let s = fresh_with(b"hello\n");
let uri = set_active_buffer_path(&s, "/tmp/modeline_diag.rs");
let store = s.lsp_manager.borrow().diag_store();
store.lock().unwrap().set(
uri,
vec![
diag_with_severity(Error),
diag_with_severity(Error),
diag_with_severity(Warning),
diag_with_severity(Hint), // hints stay off the mode line
],
);
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let mode_text = row_text(&cells, stride, 22, 80);
assert!(
mode_text.contains("E:2 W:1"),
"mode line missing diagnostic counts: {mode_text:?}"
);
assert!(
!mode_text.contains("H:"),
"hints should not appear on the mode line: {mode_text:?}"
);
}
#[test]
fn render_mode_line_hides_diagnostic_counts_while_stale() {
use crate::diag::DiagnosticSeverity::Error;
let s = fresh_with(b"hello\n");
let uri = set_active_buffer_path(&s, "/tmp/modeline_stale.rs");
let store = s.lsp_manager.borrow().diag_store();
{
let mut guard = store.lock().unwrap();
guard.set(uri.clone(), vec![diag_with_severity(Error)]);
guard.mark_stale(uri);
}
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let mode_text = row_text(&cells, stride, 22, 80);
assert!(
!mode_text.contains("E:"),
"stale diagnostics must not reach the mode line: {mode_text:?}"
);
}
#[test]
fn render_mode_line_omits_diagnostic_counts_when_clean() {
let s = fresh_with(b"hello\n");
let _uri = set_active_buffer_path(&s, "/tmp/modeline_clean.rs");
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let mode_text = row_text(&cells, stride, 22, 80);
assert!(
!mode_text.contains("E:") && !mode_text.contains("W:"),
"clean buffer must not show diagnostic counts: {mode_text:?}"
);
}
#[test]
fn render_places_cursor_on_active_window() {
let mut s = fresh_with(b"abc\n");