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:
parent
81288187e0
commit
c2f6905b08
|
|
@ -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) => {
|
||||
|
|
|
|||
71
src/diag.rs
71
src/diag.rs
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue