minimap diagnostic marks: severity color rides FileStyleSummary

The GPU's gutter-sign equivalent (framing Q#D3). The producer folds
diagnostics into the per-line summary: each touched line's dominant
style gets the severity's canonical underline_color (most severe
wins), skipped while the URI's store entry is stale — same
discipline as the decorations producer. The minimap stroke prefers
underline_color over the syntax fg, so error/warning lines read at
a glance.

Diagnostics publish without a CRDT generation bump, so the
summary's generation-keyed cache gains a second key: a new per-URI
epoch on DiagnosticStore (bumped on set/clear, not mark_stale). A
republish re-emits the summary; everything else stays suppressed
(framing bet #3 — the gate widens precisely, not naively).

DiagnosticSeverity::underline_color() becomes the canonical palette
(TUI squiggles, col-0 markers, and minimap marks all share it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-12 10:16:45 -04:00
parent 81288187e0
commit c2f6905b08
3 changed files with 199 additions and 20 deletions

View File

@ -3071,7 +3071,15 @@ fn advance_minimap_col(col: usize, ch: char) -> usize {
}
fn minimap_style_color(style: CellStyle) -> [f32; 4] {
match style.fg {
// A set underline_color is the producer's diagnostic mark for the
// line (protocol v6, T M4.6 parity) — the minimap's gutter sign.
// It outranks the syntax-dominant fg so error/warning lines read
// at a glance.
let color = match style.underline_color {
CellColor::Default => style.fg,
marked => marked,
};
match color {
CellColor::Default => MINIMAP_DEFAULT_LINE,
CellColor::Rgb(r, g, b) => rgb_to_minimap_color(r, g, b),
CellColor::Indexed(idx) => {

View File

@ -81,6 +81,21 @@ impl DiagnosticSeverity {
}
}
/// Canonical severity color (T M4.6 / protocol v6): the
/// `underline_color` of this severity's squiggle, the column-0
/// marker background, and the minimap mark all share it. Indexed
/// 1/3/6/8 (red / yellow / cyan / gray) for 8/16-color terminal
/// portability.
#[must_use]
pub fn underline_color(self) -> Color {
match self {
Self::Error => Color::Indexed(1),
Self::Warning => Color::Indexed(3),
Self::Information => Color::Indexed(6),
Self::Hint => Color::Indexed(8),
}
}
fn from_lsp_value(v: Option<&Value>) -> Self {
match v.and_then(Value::as_i64) {
Some(1) => Self::Error,
@ -187,6 +202,12 @@ pub struct DiagnosticStore {
/// here on the assumption that a fresh `publishDiagnostics`
/// corresponds to the latest sent version.
stale_uris: std::collections::HashSet<String>,
/// Per-URI change counter, bumped on every [`Self::set`] /
/// [`Self::clear`]. Diagnostics arrive without a CRDT generation
/// bump, so generation-keyed caches (the `FileStyleSummary`
/// producer's) additionally key on this to know a republish
/// happened (T M4.6 GPU parity).
epochs: HashMap<String, u64>,
}
impl DiagnosticStore {
@ -207,6 +228,7 @@ impl DiagnosticStore {
diags.sort_by(Diagnostic::compare_by_position);
let uri = uri.into();
self.stale_uris.remove(&uri);
*self.epochs.entry(uri.clone()).or_insert(0) += 1;
if diags.is_empty() {
self.by_uri.remove(&uri);
} else {
@ -219,6 +241,16 @@ impl DiagnosticStore {
pub fn clear(&mut self, uri: &str) {
self.by_uri.remove(uri);
self.stale_uris.remove(uri);
*self.epochs.entry(uri.to_owned()).or_insert(0) += 1;
}
/// Monotonic per-URI change counter: how many times `set` /
/// `clear` ran for this URI. `0` for a URI never written.
/// Consumers cache against this to detect republishes that no
/// CRDT generation bump announces.
#[must_use]
pub fn epoch_for(&self, uri: &str) -> u64 {
self.epochs.get(uri).copied().unwrap_or(0)
}
/// Mark `uri`'s stored diagnostics as stale (T M11.8). Called
@ -338,8 +370,7 @@ const TAB_WIDTH: u32 = 8;
fn error_style() -> Style {
Style {
underline: UnderlineStyle::Curly,
// Indexed 1 (red) is portable across 8/16-color terminals.
underline_color: Color::Indexed(1),
underline_color: DiagnosticSeverity::Error.underline_color(),
..Style::default()
}
}
@ -348,8 +379,7 @@ fn error_style() -> Style {
fn warning_style() -> Style {
Style {
underline: UnderlineStyle::Curly,
// Indexed 3: yellow.
underline_color: Color::Indexed(3),
underline_color: DiagnosticSeverity::Warning.underline_color(),
..Style::default()
}
}
@ -358,8 +388,7 @@ fn warning_style() -> Style {
fn info_style() -> Style {
Style {
underline: UnderlineStyle::Single,
// Indexed 6: cyan.
underline_color: Color::Indexed(6),
underline_color: DiagnosticSeverity::Information.underline_color(),
..Style::default()
}
}
@ -368,9 +397,7 @@ fn info_style() -> Style {
fn hint_style() -> Style {
Style {
underline: UnderlineStyle::Dotted,
// Indexed 8: bright black ("gray") — present on 16-color
// terminals, subtle by design for hints.
underline_color: Color::Indexed(8),
underline_color: DiagnosticSeverity::Hint.underline_color(),
..Style::default()
}
}
@ -391,14 +418,8 @@ fn style_for(severity: DiagnosticSeverity) -> Style {
/// style-only), and zero-width diagnostics — invisible to the
/// underline pass — still get a visible artifact.
fn marker_style_for(severity: DiagnosticSeverity) -> Style {
let bg = match severity {
DiagnosticSeverity::Error => Color::Indexed(1),
DiagnosticSeverity::Warning => Color::Indexed(3),
DiagnosticSeverity::Information => Color::Indexed(6),
DiagnosticSeverity::Hint => Color::Indexed(8),
};
Style {
bg,
bg: severity.underline_color(),
..Style::default()
}
}
@ -881,6 +902,24 @@ mod tests {
}
}
#[test]
fn epoch_bumps_on_set_and_clear_per_uri() {
let mut store = DiagnosticStore::new();
assert_eq!(store.epoch_for("file:///a"), 0);
store.set("file:///a", vec![diag(0, DiagnosticSeverity::Error, "x")]);
assert_eq!(store.epoch_for("file:///a"), 1);
// An empty set (server reports clean) still counts — the
// consumer must refresh to drop its marks.
store.set("file:///a", vec![]);
assert_eq!(store.epoch_for("file:///a"), 2);
store.clear("file:///a");
assert_eq!(store.epoch_for("file:///a"), 3);
// mark_stale is not a content change; other URIs are isolated.
store.mark_stale("file:///a");
assert_eq!(store.epoch_for("file:///a"), 3);
assert_eq!(store.epoch_for("file:///b"), 0);
}
#[test]
fn view_advertises_diagnostic_kind() {
// `pmacs.window._overlay_kinds()` introspection (task #23 wire-up,

View File

@ -113,7 +113,11 @@ pub struct SemanticRenderState {
/// buffer at the same generation re-uses what the frontend
/// already has and emits nothing. First emission happens on the
/// first frame for a buffer; further emissions only after edits.
last_summary: HashMap<BufferId, u64>,
/// `(crdt_generation, diag_epoch)` the last emitted summary was
/// computed against. Diagnostics arrive without a generation
/// bump, so the epoch half catches republishes (minimap marks,
/// T M4.6 GPU parity).
last_summary: HashMap<BufferId, (u64, u64)>,
/// `StyleSpans` recompute gate (perf). `scoped_style_spans` runs
/// the tree-sitter highlights query over the *whole declared
/// viewport* (which the GPU frontend sets to the entire buffer)
@ -464,11 +468,17 @@ impl SemanticRenderState {
if grammar_style_parse_not_ready(state, buffer_id) {
return None;
}
if self.last_summary.get(&buffer_id).copied() == Some(generation) {
// Diagnostics fold into the summary (minimap marks) but
// publish without a generation bump — key the cache on the
// diag store's per-URI epoch as well, so a republish
// refreshes the marks and anything else stays suppressed.
let diag_epoch = diagnostics_epoch(state, buffer_id);
if self.last_summary.get(&buffer_id).copied() == Some((generation, diag_epoch)) {
return None;
}
let lines = scoped_file_summary(state, buffer_id);
self.last_summary.insert(buffer_id, generation);
self.last_summary
.insert(buffer_id, (generation, diag_epoch));
Some(InstanceMessage::FileStyleSummary {
buffer_id,
generation,
@ -838,6 +848,19 @@ fn diagnostics_store_stale(state: &EditorState, buffer_id: BufferId) -> bool {
guard.is_stale(&uri)
}
/// The diag store's per-URI change epoch for `buffer_id`'s file, `0`
/// for buffers with no file URI or no diagnostics history. Keys the
/// `FileStyleSummary` cache (see [`SemanticRenderState::last_summary`]).
fn diagnostics_epoch(state: &EditorState, buffer_id: BufferId) -> u64 {
let core = state.core.borrow();
let Some(uri) = buffer_file_uri(&core, buffer_id) else {
return 0;
};
let store = state.lsp_manager.borrow().diag_store();
let guard = store.lock().expect("diag store mutex poisoned");
guard.epoch_for(&uri)
}
/// Style-family staleness for the LSP-token authority. True only for
/// a buffer with **no** tree-sitter view (policy A routes those
/// through `lsp_scoped_style_spans`) whose semantic-token store entry
@ -1182,6 +1205,9 @@ fn scoped_file_summary(state: &EditorState, buffer_id: BufferId) -> Vec<Style> {
};
let spans = scoped_style_spans(state, &vp_all);
if spans.is_empty() {
// No styled runs — but diagnostic marks are independent of
// syntax styling (a plain-text buffer can still have lints).
overlay_diagnostic_marks(state, buffer_id, &mut out);
return out;
}
@ -1214,9 +1240,49 @@ fn scoped_file_summary(state: &EditorState, buffer_id: BufferId) -> Vec<Style> {
*line_dominant = winner.0;
}
}
overlay_diagnostic_marks(state, buffer_id, &mut out);
out
}
/// Fold diagnostics into the file summary: each line a diagnostic
/// touches gets the most severe severity's canonical color in
/// `underline_color` (protocol v6) — the minimap's line marks, the
/// GPU's equivalent of the TUI's column-0 gutter signs (T M4.6).
/// Skipped while the URI's store entry is stale: the positions
/// describe pre-edit text, same discipline as the decorations
/// producer (the marks return on republish, which bumps the diag
/// epoch and recomputes this summary).
fn overlay_diagnostic_marks(state: &EditorState, buffer_id: BufferId, lines: &mut [Style]) {
let uri = {
let core = state.core.borrow();
let Some(uri) = buffer_file_uri(&core, buffer_id) else {
return;
};
uri
};
let store = state.lsp_manager.borrow().diag_store();
let guard = store.lock().expect("diag store mutex poisoned");
if guard.is_stale(&uri) {
return;
}
// Most severe per line wins; LSP numbering makes that the
// minimum severity value.
let mut best: Vec<Option<crate::diag::DiagnosticSeverity>> = vec![None; lines.len()];
for d in guard.for_uri(&uri) {
for li in d.start_line..=d.end_line {
let Some(slot) = best.get_mut(li as usize) else {
break;
};
*slot = Some(slot.map_or(d.severity, |s| s.min(d.severity)));
}
}
for (line, severity) in lines.iter_mut().zip(best) {
if let Some(s) = severity {
line.underline_color = s.underline_color();
}
}
}
/// The buffer's CRDT version projected to a monotonic scalar — the
/// `generation` anchor for the semantic frame. `0` when the buffer is
/// absent or not CRDT-backed (a `semantic_render` session always
@ -2705,4 +2771,70 @@ mod tests {
assert_eq!(lines[2], kw_style, "line 2 dominated by the LSP token");
assert_eq!(lines[3], Style::default(), "trailing empty line → default");
}
#[test]
fn file_style_summary_marks_diagnostic_lines_and_refreshes_on_republish() {
use crate::cell::Color;
use crate::diag::DiagnosticSeverity;
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
// "abc\nde" with a Warning on line 1 (and a file path so the
// buffer has a URI).
seed_diagnostic(&state, bid);
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
let first = s.render_frame(&state);
let (_, lines) = summary_of(&first).expect("first frame ships a summary");
assert_eq!(lines.len(), 2);
assert_eq!(
lines[0].underline_color,
Color::Default,
"clean line carries no mark"
);
assert_eq!(
lines[1].underline_color,
DiagnosticSeverity::Warning.underline_color(),
"diagnostic line carries the severity mark"
);
// Unchanged generation + diag epoch → suppressed.
assert!(summary_of(&s.render_frame(&state)).is_none());
// A republish moves the diagnostic to line 0 and escalates it.
// No CRDT edit happened — the diag epoch alone must re-emit
// the summary with refreshed marks.
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m114.rs"));
state
.lsp_manager
.borrow()
.diag_store()
.lock()
.expect("diag store")
.set(
&uri,
vec![crate::diag::Diagnostic {
start_line: 0,
start_col: 0,
end_line: 0,
end_col: 3,
severity: DiagnosticSeverity::Error,
message: "boom".into(),
source: None,
code: None,
}],
);
let refreshed = s.render_frame(&state);
let (_, lines) = summary_of(&refreshed).expect("diag republish re-emits the summary");
assert_eq!(
lines[0].underline_color,
DiagnosticSeverity::Error.underline_color()
);
assert_eq!(
lines[1].underline_color,
Color::Default,
"the old warning mark is gone after the republish"
);
}
}