Merge pull request #65 from levineuwirth/session-gpu-diagnostic-parity
GPU diagnostic parity: quad squiggle bars + minimap marks
This commit is contained in:
commit
6853a0feac
|
|
@ -0,0 +1,87 @@
|
|||
# pmacs-gpu diagnostic parity — framing pass
|
||||
|
||||
Date: 2026-06-12. The TUI's M4.6 surface (PR #64) gives diagnostics
|
||||
severity-colored squiggles, column-0 line markers, and mode-line
|
||||
counts. The GPU frontend renders none of that: diagnostics arrive as
|
||||
`Decorations` with per-severity kinds and are consumed by
|
||||
**recoloring the text foreground** (`decoration_kind_to_color`,
|
||||
main.rs:4041 → `source_color_at` :3933 → chunk `Attrs::color`) — the
|
||||
exact syntax-color-clobbering approach the TUI just abandoned, and
|
||||
the reason `underline_color` exists in protocol v6.
|
||||
|
||||
## Verified facts (survey 2026-06-12)
|
||||
|
||||
- glyphon/cosmic-text 0.18 draw **no underlines**; `line_from_chunks`
|
||||
sets only family + color. Squiggles cannot come from text attrs.
|
||||
- The quad pipeline draws arbitrary pixel rects:
|
||||
`push_glyph_extent_rects` (:2696) walks `layout_runs()` and maps a
|
||||
byte range to per-visual-line glyph x-extents; `MinimapRect {x, y,
|
||||
w, h, color}` → 6 vertices (:3716–:3728). Selection / CurrentLine
|
||||
backgrounds already ship through it; all four diagnostic kinds
|
||||
return `None` from `decoration_kind_to_bg_color` today by design.
|
||||
- `FileStyleSummary` (one dominant `Style` per line, minimap-wide) is
|
||||
computed in `scoped_file_summary` (semantic_render.rs:1156) from
|
||||
**style spans only** — diagnostics never reach the minimap. The
|
||||
minimap consumer reads only `Style.fg` (`minimap_style_color`,
|
||||
:3063).
|
||||
- Decorations are viewport-scoped and dirty-merged
|
||||
(`current_decorations`, :299), translated through unconfirmed edits
|
||||
(M11.4) — the squiggle source data is already maintained correctly.
|
||||
|
||||
## Q#D1 — squiggle mechanism
|
||||
|
||||
**Stance: quad-pipeline underline bars.** A 2px severity-colored bar
|
||||
at the bottom of each glyph extent the decoration covers, emitted
|
||||
next to the existing background quads in
|
||||
`decoration_background_vertex_bytes`. Straight bars first; wavy needs
|
||||
a shader or texture and buys nothing until the straight bar is
|
||||
proven. Geometry reuses `push_glyph_extent_rects` with a height/y
|
||||
override rather than a parallel walk.
|
||||
|
||||
## Q#D2 — retire the fg recolor
|
||||
|
||||
**Stance: yes, squiggles replace text recoloring for all four
|
||||
severities.** Parity with the TUI rationale: the error is *under* the
|
||||
text; the text keeps its syntax color. `decoration_kind_to_color`
|
||||
returns `None` for diagnostic kinds; its RGB constants move to the
|
||||
new severity→bar-color map so the palette is unchanged.
|
||||
|
||||
## Q#D3 — minimap diagnostic marks (the GPU's gutter signs)
|
||||
|
||||
**Stance: producer-side.** `scoped_file_summary` additionally sets
|
||||
`underline_color` (severity-max, same indexed palette as the TUI) on
|
||||
the dominant style of any line a diagnostic touches, honoring the
|
||||
existing hold-while-stale discipline; the minimap consumer draws its
|
||||
line stroke in `underline_color` when set, else `fg`. This rides the
|
||||
v6 field end-to-end and costs no new message. Whole-file diagnostic
|
||||
positions are available instance-side where the summary is computed —
|
||||
the viewport-scoping of `Decorations` is irrelevant here.
|
||||
|
||||
## Q#D4 — counts surface
|
||||
|
||||
**Stance: defer.** The GPU has no status band, and viewport-scoped
|
||||
decorations cannot produce whole-file counts frontend-side. A status
|
||||
band is its own session (layout, font sizing, what else lives there);
|
||||
the minimap marks from Q#D3 carry the at-a-glance signal until then.
|
||||
|
||||
## Predicted findings (categorical bets)
|
||||
|
||||
1. **Bar placement tuning**: the first y-position for the 2px bar
|
||||
will collide with descenders or the next line's ascenders on some
|
||||
line-height; expect one round of "squiggle looks off" feedback.
|
||||
2. **A test pins the fg recolor**: at least one pmacs-gpu test
|
||||
asserts diagnostic text color; it fails and gets rewritten to
|
||||
assert bar quads instead.
|
||||
3. **Summary churn**: folding diagnostics into `FileStyleSummary`
|
||||
makes it recompute on diagnostic publish, not just generation
|
||||
advance — if the producer's "recompute when generation advances"
|
||||
gate isn't widened, minimap marks lag one edit behind; if it is
|
||||
widened naively, summary traffic grows on every publish.
|
||||
|
||||
## Session plan
|
||||
|
||||
Single session, three commits: (1) quad squiggles + fg-recolor
|
||||
retirement, (2) producer `underline_color` in `FileStyleSummary` +
|
||||
minimap stroke color, (3) tests/polish. Manual validation: rust file
|
||||
with error+warning+hint, scroll the viewport, edit near a squiggle
|
||||
(translation), check minimap marks track publishes.
|
||||
|
|
@ -1626,26 +1626,20 @@ impl State {
|
|||
// Same staleness translation as the StyleSpans arm.
|
||||
self.prune_unconfirmed_edits(generation);
|
||||
let segments = translate_decoration_segments(segments, &self.unconfirmed_edits);
|
||||
// Only diagnostic decorations affect the *rich text*
|
||||
// (they override glyph fg in `projected_rich_chunks`);
|
||||
// background kinds (Selection / CurrentLine / Search)
|
||||
// are quads rebuilt cheaply in `render()`. A full
|
||||
// `reshape()` (set_rich_text + shape_until_scroll) on
|
||||
// every decoration change made cursor motion crawl —
|
||||
// B1's own `CurrentLine` changes on every up/down move.
|
||||
// Reshape only when the fg-affecting set changed; else
|
||||
// just repaint the quads.
|
||||
let fg_before = fg_decoration_fingerprint(&self.current_decorations);
|
||||
if full {
|
||||
self.replace_decorations(segments);
|
||||
} else {
|
||||
self.merge_decorations(segments);
|
||||
}
|
||||
if fg_before == fg_decoration_fingerprint(&self.current_decorations) {
|
||||
self.window.request_redraw();
|
||||
} else {
|
||||
self.refresh_changed_lines();
|
||||
}
|
||||
// Every decoration kind is now a quad (backgrounds
|
||||
// for Selection/CurrentLine, underline bars for the
|
||||
// diagnostics — the fg-recolor path retired with T
|
||||
// M4.6 parity), and quads rebuild cheaply per frame
|
||||
// in `render()`. No decoration change needs a
|
||||
// reshape, so none triggers one — diagnostic
|
||||
// publishes no longer pay set_rich_text +
|
||||
// shape_until_scroll.
|
||||
self.window.request_redraw();
|
||||
None
|
||||
}
|
||||
InstanceMessage::InlineAdornments { buffer_id, items } => {
|
||||
|
|
@ -1853,7 +1847,6 @@ impl State {
|
|||
let rich = clipped_chunks_for_range(
|
||||
&self.current_text,
|
||||
&self.current_spans,
|
||||
&self.current_decorations,
|
||||
&self.current_adornments,
|
||||
vstart,
|
||||
vend,
|
||||
|
|
@ -1976,7 +1969,6 @@ impl State {
|
|||
clipped_chunks_for_range(
|
||||
&self.current_text,
|
||||
&self.current_spans,
|
||||
&self.current_decorations,
|
||||
&self.current_adornments,
|
||||
line_start,
|
||||
content_end,
|
||||
|
|
@ -2581,10 +2573,21 @@ impl State {
|
|||
if d.kind == DecorationKind::CurrentLine {
|
||||
continue;
|
||||
}
|
||||
if let Some(color) = decoration_kind_to_bg_color(d.kind)
|
||||
&& let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend)
|
||||
{
|
||||
self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color);
|
||||
let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(color) = decoration_kind_to_bg_color(d.kind) {
|
||||
self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color, None);
|
||||
}
|
||||
if let Some(color) = decoration_kind_to_underline_color(d.kind) {
|
||||
self.push_glyph_extent_rects(
|
||||
rects,
|
||||
line_offsets,
|
||||
lo,
|
||||
hi,
|
||||
color,
|
||||
Some(DIAG_UNDERLINE_PX),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2608,7 +2611,7 @@ impl State {
|
|||
if let Some(color) = decoration_kind_to_bg_color(DecorationKind::CurrentLine) {
|
||||
let (lo, hi) = source_line_range(&self.current_text, presence.cursor);
|
||||
if let Some((lo, hi)) = clip_rebase_range(lo, hi, vstart, vend) {
|
||||
self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color);
|
||||
self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color, None);
|
||||
}
|
||||
}
|
||||
if let Some(sel) = presence.selection
|
||||
|
|
@ -2617,7 +2620,7 @@ impl State {
|
|||
let lo = sel.anchor.min(sel.active).min(text_len);
|
||||
let hi = sel.anchor.max(sel.active).min(text_len);
|
||||
if let Some((lo, hi)) = clip_rebase_range(lo, hi, vstart, vend) {
|
||||
self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color);
|
||||
self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2700,6 +2703,7 @@ impl State {
|
|||
lo: u64,
|
||||
hi: u64,
|
||||
color: [f32; 4],
|
||||
bar_px: Option<f32>,
|
||||
) {
|
||||
if hi <= lo {
|
||||
return;
|
||||
|
|
@ -2722,11 +2726,18 @@ impl State {
|
|||
if let (Some(x0), Some(x1)) = (min_x, max_x)
|
||||
&& x1 > x0
|
||||
{
|
||||
// `bar_px`: an underline bar hugging the bottom of the
|
||||
// line box instead of a full-height wash — the GPU's
|
||||
// diagnostic squiggle (T M4.6 parity, straight-bar v1).
|
||||
let (y, h) = match bar_px {
|
||||
Some(bar) => (TEXT_TOP + run.line_top + run.line_height - bar, bar),
|
||||
None => (TEXT_TOP + run.line_top, run.line_height),
|
||||
};
|
||||
rects.push(MinimapRect {
|
||||
x: TEXT_LEFT + x0,
|
||||
y: TEXT_TOP + run.line_top,
|
||||
y,
|
||||
w: x1 - x0,
|
||||
h: run.line_height,
|
||||
h,
|
||||
color,
|
||||
});
|
||||
}
|
||||
|
|
@ -3060,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) => {
|
||||
|
|
@ -3792,7 +3811,6 @@ fn line_from_chunks(chunks: &[RichChunk]) -> glyphon::cosmic_text::BufferLine {
|
|||
fn clipped_chunks_for_range(
|
||||
text: &str,
|
||||
spans: &[StyleSpan],
|
||||
decorations: &[Decoration],
|
||||
adornments: &[InlineAdornment],
|
||||
start: u64,
|
||||
end: u64,
|
||||
|
|
@ -3807,15 +3825,6 @@ fn clipped_chunks_for_range(
|
|||
})
|
||||
})
|
||||
.collect();
|
||||
let decorations: Vec<Decoration> = decorations
|
||||
.iter()
|
||||
.filter_map(|d| {
|
||||
clip_rebase_range(d.range.start, d.range.end, start, end).map(|(s, e)| Decoration {
|
||||
range: ByteRange { start: s, end: e },
|
||||
kind: d.kind,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let adornments: Vec<InlineAdornment> = adornments
|
||||
.iter()
|
||||
.filter(|a| a.at >= start && a.at <= end)
|
||||
|
|
@ -3825,13 +3834,12 @@ fn clipped_chunks_for_range(
|
|||
a
|
||||
})
|
||||
.collect();
|
||||
projected_rich_chunks(range_text, &spans, &decorations, &adornments)
|
||||
projected_rich_chunks(range_text, &spans, &adornments)
|
||||
}
|
||||
|
||||
fn projected_rich_chunks(
|
||||
text: &str,
|
||||
spans: &[StyleSpan],
|
||||
decorations: &[Decoration],
|
||||
adornments: &[InlineAdornment],
|
||||
) -> Vec<RichChunk> {
|
||||
let text_len = text.len() as u64;
|
||||
|
|
@ -3848,10 +3856,6 @@ fn projected_rich_chunks(
|
|||
boundaries.push(snap(sp.range.start));
|
||||
boundaries.push(snap(sp.range.end));
|
||||
}
|
||||
for d in decorations {
|
||||
boundaries.push(snap(d.range.start));
|
||||
boundaries.push(snap(d.range.end));
|
||||
}
|
||||
let mut renderable_adornments: Vec<(usize, u64, &InlineAdornment)> = adornments
|
||||
.iter()
|
||||
.enumerate()
|
||||
|
|
@ -3872,7 +3876,7 @@ fn projected_rich_chunks(
|
|||
if a < b {
|
||||
chunks.push(RichChunk {
|
||||
text: text[a as usize..b as usize].to_owned(),
|
||||
color: source_color_at(a, spans, decorations),
|
||||
color: source_color_at(a, spans),
|
||||
source: ChunkSource::Source { start: a },
|
||||
});
|
||||
}
|
||||
|
|
@ -3930,19 +3934,7 @@ fn adornment_text_color(fg: CellColor) -> glyphon::Color {
|
|||
cell_color_to_glyphon(fg).unwrap_or_else(|| glyphon::Color::rgb(130, 130, 140))
|
||||
}
|
||||
|
||||
fn source_color_at(
|
||||
byte: u64,
|
||||
spans: &[StyleSpan],
|
||||
decorations: &[Decoration],
|
||||
) -> Option<glyphon::Color> {
|
||||
for d in decorations {
|
||||
if d.range.start <= byte
|
||||
&& byte < d.range.end
|
||||
&& let Some(c) = decoration_kind_to_color(d.kind)
|
||||
{
|
||||
return Some(c);
|
||||
}
|
||||
}
|
||||
fn source_color_at(byte: u64, spans: &[StyleSpan]) -> Option<glyphon::Color> {
|
||||
for sp in spans {
|
||||
if sp.range.start <= byte && byte < sp.range.end {
|
||||
return cell_color_to_glyphon(sp.style.fg);
|
||||
|
|
@ -4010,46 +4002,34 @@ fn indexed_to_glyphon(idx: u8) -> glyphon::Color {
|
|||
glyphon::Color::rgb(level, level, level)
|
||||
}
|
||||
|
||||
/// The decorations that affect the *rich text* (a glyph fg override in
|
||||
/// `projected_rich_chunks`), as an ordered `(range, kind)` set. Only
|
||||
/// kinds with a foreground color qualify — i.e. the diagnostic
|
||||
/// severities; background kinds (`Selection` / `CurrentLine` / search)
|
||||
/// are quads. Equal fingerprints across a `Decorations` update mean the
|
||||
/// shaped text is unaffected and a `reshape()` can be skipped (the perf
|
||||
/// fix for cursor-motion-driven `CurrentLine` churn).
|
||||
fn fg_decoration_fingerprint(decos: &[Decoration]) -> Vec<(ByteRange, DecorationKind)> {
|
||||
decos
|
||||
.iter()
|
||||
.filter(|d| decoration_kind_to_color(d.kind).is_some())
|
||||
.map(|d| (d.range, d.kind))
|
||||
.collect()
|
||||
}
|
||||
/// Height of the diagnostic underline bar, in pixels. Straight-bar
|
||||
/// v1; a wavy squiggle needs shader/texture work and waits until the
|
||||
/// straight bar is proven (framing Q#D1).
|
||||
const DIAG_UNDERLINE_PX: f32 = 2.0;
|
||||
|
||||
/// Map a [`DecorationKind`] to a foreground color override, or `None`
|
||||
/// for kinds whose visual is a background and can't be expressed in
|
||||
/// the current `Attrs`-only rendering pipeline.
|
||||
/// Map a [`DecorationKind`] to an underline-bar color, or `None` for
|
||||
/// kinds that don't underline.
|
||||
///
|
||||
/// Session 5 ships **fg-only** decoration rendering. The four
|
||||
/// background-needing kinds (`Selection`, `SearchMatch`,
|
||||
/// `SearchMatchActive`, `CurrentLine`) return `None` here because the
|
||||
/// glyph-color path can only render foregrounds; they route through
|
||||
/// [`decoration_kind_to_bg_color`] and the quad pipeline instead.
|
||||
///
|
||||
/// Color choices match the conventional editor palette (red errors,
|
||||
/// yellow warnings, light blue info, dim hints) so the GPU window's
|
||||
/// visual matches what the pmacs TUI paints via terminal color codes.
|
||||
fn decoration_kind_to_color(kind: DecorationKind) -> Option<glyphon::Color> {
|
||||
/// Session 5 originally rendered diagnostics by *recoloring the text
|
||||
/// foreground*, which clobbered the syntax color of the very token
|
||||
/// the diagnostic points at — the same flaw the TUI fixed with
|
||||
/// protocol v6's `underline_color` (T M4.6). The GPU's equivalent is
|
||||
/// a [`DIAG_UNDERLINE_PX`]-tall quad hugging the bottom of the glyph
|
||||
/// extent; the text keeps its syntax color. Same RGB palette the fg
|
||||
/// path used (red / yellow / light blue / dim gray), so the window's
|
||||
/// severity language is unchanged.
|
||||
fn decoration_kind_to_underline_color(kind: DecorationKind) -> Option<[f32; 4]> {
|
||||
match kind {
|
||||
// ANSI bright red — matches TUI diagnostic-error palette.
|
||||
DecorationKind::DiagnosticError => Some(glyphon::Color::rgb(241, 76, 76)),
|
||||
DecorationKind::DiagnosticError => Some([0.945, 0.298, 0.298, 1.0]),
|
||||
// ANSI bright yellow.
|
||||
DecorationKind::DiagnosticWarning => Some(glyphon::Color::rgb(245, 245, 67)),
|
||||
DecorationKind::DiagnosticWarning => Some([0.961, 0.961, 0.263, 1.0]),
|
||||
// ANSI bright blue.
|
||||
DecorationKind::DiagnosticInfo => Some(glyphon::Color::rgb(59, 142, 234)),
|
||||
DecorationKind::DiagnosticInfo => Some([0.231, 0.557, 0.918, 1.0]),
|
||||
// ANSI bright black (dim gray — hints should be visible but
|
||||
// visually quietest of the diagnostic four).
|
||||
DecorationKind::DiagnosticHint => Some(glyphon::Color::rgb(102, 102, 102)),
|
||||
// Background-needing kinds route through the quad pipeline.
|
||||
DecorationKind::DiagnosticHint => Some([0.4, 0.4, 0.4, 1.0]),
|
||||
// Background kinds wash the full line box instead.
|
||||
DecorationKind::Selection
|
||||
| DecorationKind::SearchMatch
|
||||
| DecorationKind::SearchMatchActive
|
||||
|
|
@ -4057,9 +4037,10 @@ fn decoration_kind_to_color(kind: DecorationKind) -> Option<glyphon::Color> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Background-bearing companion to [`decoration_kind_to_color`]: maps
|
||||
/// each background-needing `DecorationKind` to its quad-pipeline color
|
||||
/// as an RGBA tuple in 0..=1 space. Returns `None` for foreground-only
|
||||
/// Background-bearing companion to
|
||||
/// [`decoration_kind_to_underline_color`]: maps each
|
||||
/// background-needing `DecorationKind` to its quad-pipeline color as
|
||||
/// an RGBA tuple in 0..=1 space. Returns `None` for underline-only
|
||||
/// kinds (the four diagnostic severities) so the two helpers form a
|
||||
/// total cover with no overlap.
|
||||
///
|
||||
|
|
@ -4085,7 +4066,8 @@ fn decoration_kind_to_bg_color(kind: DecorationKind) -> Option<[f32; 4]> {
|
|||
DecorationKind::CurrentLine => Some([0.55, 0.60, 0.75, 0.22]),
|
||||
// Deferred to the search-feature arc.
|
||||
DecorationKind::SearchMatch | DecorationKind::SearchMatchActive => None,
|
||||
// Foreground-only — handled by [`decoration_kind_to_color`].
|
||||
// Underline-only — handled by
|
||||
// [`decoration_kind_to_underline_color`].
|
||||
DecorationKind::DiagnosticError
|
||||
| DecorationKind::DiagnosticWarning
|
||||
| DecorationKind::DiagnosticInfo
|
||||
|
|
@ -4256,40 +4238,6 @@ mod tests {
|
|||
assert!(!m.contains(Modifiers::SHIFT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fg_fingerprint_ignores_background_decoration_changes() {
|
||||
let deco = |start, end, kind| Decoration {
|
||||
range: ByteRange { start, end },
|
||||
kind,
|
||||
};
|
||||
// A diagnostic (fg) decoration + a CurrentLine (bg) decoration.
|
||||
let before = vec![
|
||||
deco(10, 14, DecorationKind::DiagnosticError),
|
||||
deco(0, 20, DecorationKind::CurrentLine),
|
||||
];
|
||||
// The cursor moved: CurrentLine now spans a different line, the
|
||||
// diagnostic is unchanged.
|
||||
let after = vec![
|
||||
deco(10, 14, DecorationKind::DiagnosticError),
|
||||
deco(40, 60, DecorationKind::CurrentLine),
|
||||
];
|
||||
assert_eq!(
|
||||
fg_decoration_fingerprint(&before),
|
||||
fg_decoration_fingerprint(&after),
|
||||
"a CurrentLine-only change must not change the fg fingerprint (no reshape)"
|
||||
);
|
||||
|
||||
// A diagnostic change DOES alter the fingerprint (reshape needed).
|
||||
let after_diag = vec![
|
||||
deco(10, 18, DecorationKind::DiagnosticError),
|
||||
deco(0, 20, DecorationKind::CurrentLine),
|
||||
];
|
||||
assert_ne!(
|
||||
fg_decoration_fingerprint(&before),
|
||||
fg_decoration_fingerprint(&after_diag)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_byte_offsets_indexes_each_logical_line() {
|
||||
// "abc\nde\nfgh": lines start at bytes 0, 4, 7. Indexed by
|
||||
|
|
@ -4518,10 +4466,6 @@ mod tests {
|
|||
..CellStyle::default()
|
||||
},
|
||||
}];
|
||||
let decorations = vec![Decoration {
|
||||
range: ByteRange { start: 0, end: 5 },
|
||||
kind: DecorationKind::DiagnosticWarning,
|
||||
}];
|
||||
let hint = |at: u64, label: &str| InlineAdornment {
|
||||
at,
|
||||
placement: AdornmentPlacement::AtOffset,
|
||||
|
|
@ -4535,7 +4479,6 @@ mod tests {
|
|||
let full = flat(&clipped_chunks_for_range(
|
||||
text,
|
||||
&spans,
|
||||
&decorations,
|
||||
&adornments,
|
||||
0,
|
||||
text.len() as u64,
|
||||
|
|
@ -4549,7 +4492,6 @@ mod tests {
|
|||
per_line.extend(flat(&clipped_chunks_for_range(
|
||||
text,
|
||||
&spans,
|
||||
&decorations,
|
||||
&adornments,
|
||||
start,
|
||||
content_end,
|
||||
|
|
@ -4564,12 +4506,12 @@ mod tests {
|
|||
|
||||
// The boundary hint landed on line 0 (before its newline), not
|
||||
// line 1.
|
||||
let line0 = clipped_chunks_for_range(text, &spans, &decorations, &adornments, 0, 10);
|
||||
let line0 = clipped_chunks_for_range(text, &spans, &adornments, 0, 10);
|
||||
assert!(
|
||||
line0.iter().any(|c| c.text == "<eol>"),
|
||||
"newline-anchored hint belongs to the line it terminates"
|
||||
);
|
||||
let line1 = clipped_chunks_for_range(text, &spans, &decorations, &adornments, 11, 22);
|
||||
let line1 = clipped_chunks_for_range(text, &spans, &adornments, 11, 22);
|
||||
assert!(
|
||||
line1.iter().all(|c| c.text != "<eol>"),
|
||||
"newline-anchored hint must not duplicate onto the next line"
|
||||
|
|
@ -4784,7 +4726,8 @@ mod tests {
|
|||
assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatch).is_none());
|
||||
assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatchActive).is_none());
|
||||
|
||||
// Foreground-only kinds belong to the fg helper.
|
||||
// Underline-only kinds belong to the underline helper (T M4.6
|
||||
// parity: squiggle bars, not text recoloring).
|
||||
for kind in [
|
||||
DecorationKind::DiagnosticError,
|
||||
DecorationKind::DiagnosticWarning,
|
||||
|
|
@ -4792,12 +4735,12 @@ mod tests {
|
|||
DecorationKind::DiagnosticHint,
|
||||
] {
|
||||
assert!(decoration_kind_to_bg_color(kind).is_none());
|
||||
assert!(decoration_kind_to_color(kind).is_some());
|
||||
assert!(decoration_kind_to_underline_color(kind).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fg_and_bg_helpers_are_disjoint_total_cover() {
|
||||
fn underline_and_bg_helpers_are_disjoint_total_cover() {
|
||||
// Every DecorationKind is renderable by exactly one helper.
|
||||
// Adding a new kind without updating one of the helpers should
|
||||
// fail this assertion.
|
||||
|
|
@ -4811,19 +4754,18 @@ mod tests {
|
|||
DecorationKind::DiagnosticInfo,
|
||||
DecorationKind::DiagnosticHint,
|
||||
] {
|
||||
let fg = decoration_kind_to_color(kind).is_some();
|
||||
let ul = decoration_kind_to_underline_color(kind).is_some();
|
||||
let bg = decoration_kind_to_bg_color(kind).is_some();
|
||||
// Background helper returns None for the search pair —
|
||||
// deferred to the search-feature arc. For both of those,
|
||||
// decoration_kind_to_color is also None. That is the
|
||||
// "neither yet" state — the exclusive-or test exempts it.
|
||||
// Both helpers return None for the search pair — deferred
|
||||
// to the search-feature arc. That is the "neither yet"
|
||||
// state — the exclusive-or test exempts it.
|
||||
let deferred = matches!(
|
||||
kind,
|
||||
DecorationKind::SearchMatch | DecorationKind::SearchMatchActive
|
||||
);
|
||||
assert!(
|
||||
deferred || (fg ^ bg),
|
||||
"{kind:?}: fg={fg} bg={bg} — should be exactly one (unless deferred)"
|
||||
deferred || (ul ^ bg),
|
||||
"{kind:?}: underline={ul} bg={bg} — should be exactly one (unless deferred)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4837,11 +4779,10 @@ mod tests {
|
|||
let text = "ab→cd";
|
||||
let chunks = projected_rich_chunks(
|
||||
text,
|
||||
&[span(0, 3, CellColor::Indexed(1))],
|
||||
&[Decoration {
|
||||
range: ByteRange { start: 4, end: 9 },
|
||||
kind: DecorationKind::DiagnosticError,
|
||||
}],
|
||||
&[
|
||||
span(0, 3, CellColor::Indexed(1)),
|
||||
span(4, 9, CellColor::Indexed(2)),
|
||||
],
|
||||
&[],
|
||||
);
|
||||
let rendered: String = chunks.iter().map(|chunk| chunk.text.as_str()).collect();
|
||||
|
|
@ -4878,7 +4819,6 @@ mod tests {
|
|||
let chunks = projected_rich_chunks(
|
||||
"abcd",
|
||||
&[],
|
||||
&[],
|
||||
&[adornment(2, AdornmentPlacement::AtOffset, "X")],
|
||||
);
|
||||
|
||||
|
|
@ -4892,7 +4832,6 @@ mod tests {
|
|||
let chunks = projected_rich_chunks(
|
||||
"abcd",
|
||||
&[span(2, 4, CellColor::Indexed(1))],
|
||||
&[],
|
||||
&[adornment(2, AdornmentPlacement::AtOffset, "X")],
|
||||
);
|
||||
|
||||
|
|
@ -4908,36 +4847,11 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_adornment_does_not_shift_source_decoration_ranges() {
|
||||
let chunks = projected_rich_chunks(
|
||||
"abcd",
|
||||
&[],
|
||||
&[Decoration {
|
||||
range: ByteRange { start: 2, end: 4 },
|
||||
kind: DecorationKind::DiagnosticError,
|
||||
}],
|
||||
&[adornment(2, AdornmentPlacement::AtOffset, "X")],
|
||||
);
|
||||
|
||||
assert_eq!(chunk_texts(&chunks), vec!["ab", "X", "cd"]);
|
||||
assert!(chunks[0].color.is_none());
|
||||
assert!(
|
||||
chunks[1].color.is_some(),
|
||||
"default-styled virtual text should render as muted adornment text"
|
||||
);
|
||||
assert!(
|
||||
chunks[2].color.is_some(),
|
||||
"diagnostic fg override must still begin at source byte 2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_adornment_placements_are_ignored_for_session_6() {
|
||||
let chunks = projected_rich_chunks(
|
||||
"abcd",
|
||||
&[],
|
||||
&[],
|
||||
&[
|
||||
adornment(0, AdornmentPlacement::BeforeLine, "before"),
|
||||
adornment(4, AdornmentPlacement::EndOfLine, "end"),
|
||||
|
|
@ -4953,7 +4867,6 @@ mod tests {
|
|||
let chunks = projected_rich_chunks(
|
||||
"abcd",
|
||||
&[],
|
||||
&[],
|
||||
&[adornment(99, AdornmentPlacement::AtOffset, "X")],
|
||||
);
|
||||
|
||||
|
|
|
|||
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,
|
||||
|
|
@ -628,6 +638,8 @@ impl SemanticRenderState {
|
|||
for d in &diags {
|
||||
let lo = line_col_to_byte(line_starts, source_len, d.start_line, d.start_col);
|
||||
let hi = line_col_to_byte(line_starts, source_len, d.end_line, d.end_col);
|
||||
let (lo, hi) =
|
||||
widen_zero_width_diag(lo, hi, d.start_line, line_starts, source_len);
|
||||
if let Some(range) = clip_to_viewport(lo, hi, vp) {
|
||||
out.push(Decoration {
|
||||
range,
|
||||
|
|
@ -723,6 +735,38 @@ fn scoped_inline_adornments(state: &EditorState, vp: &DeclaredViewport) -> Vec<I
|
|||
/// the source length is the caller's concern for styling; for
|
||||
/// decorations we clamp against the viewport only). `None` when the
|
||||
/// intersection is empty or degenerate.
|
||||
/// Widen a zero-width diagnostic range to one byte so it survives
|
||||
/// the wire and overlaps a glyph at the frontend (a zero-width range
|
||||
/// clips to nothing and underlines nothing). Parsers anchor
|
||||
/// "expected COMMA"-style errors one past the last token —
|
||||
/// rust-analyzer reports the missing comma as `col N → col N` at end
|
||||
/// of line — the same shape the TUI's `DiagnosticView` special-cases
|
||||
/// at its anchor cell (T M4.6). Mid-line anchors widen forward; an
|
||||
/// anchor at/past the line's content end widens backward instead,
|
||||
/// because forward would cover only the `\n`, which shapes no glyph.
|
||||
/// Non-empty ranges pass through untouched.
|
||||
fn widen_zero_width_diag(
|
||||
lo: u64,
|
||||
hi: u64,
|
||||
start_line: u32,
|
||||
line_starts: &[u64],
|
||||
source_len: u64,
|
||||
) -> (u64, u64) {
|
||||
if hi > lo {
|
||||
return (lo, hi);
|
||||
}
|
||||
// Content end excludes the trailing newline, same semantics as
|
||||
// the summary's per-line ranges.
|
||||
let content_end = line_starts
|
||||
.get(start_line as usize + 1)
|
||||
.map_or(source_len, |&next| next.saturating_sub(1));
|
||||
if lo >= content_end {
|
||||
(lo.saturating_sub(1), lo)
|
||||
} else {
|
||||
(lo, (lo + 1).min(source_len))
|
||||
}
|
||||
}
|
||||
|
||||
fn clip_to_viewport(lo: u64, hi: u64, vp: &DeclaredViewport) -> Option<ByteRange> {
|
||||
let start = lo.max(vp.visible.start);
|
||||
let end = hi.min(vp.visible.end);
|
||||
|
|
@ -838,6 +882,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 +1239,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 +1274,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 +2805,86 @@ 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 zero_width_diagnostics_widen_to_a_visible_byte() {
|
||||
// "abc\nde" — line starts [0, 4], source_len 6; line 0
|
||||
// content is bytes [0, 3) (newline excluded).
|
||||
let ls = vec![0u64, 4];
|
||||
// Mid-line anchor: widen forward.
|
||||
assert_eq!(widen_zero_width_diag(1, 1, 0, &ls, 6), (1, 2));
|
||||
// End-of-line anchor (the rust-analyzer "expected COMMA"
|
||||
// shape): widen backward — forward would cover only the \n.
|
||||
assert_eq!(widen_zero_width_diag(3, 3, 0, &ls, 6), (2, 3));
|
||||
// End-of-file anchor on the last line.
|
||||
assert_eq!(widen_zero_width_diag(6, 6, 1, &ls, 6), (5, 6));
|
||||
// Non-empty ranges pass through untouched.
|
||||
assert_eq!(widen_zero_width_diag(1, 3, 0, &ls, 6), (1, 3));
|
||||
}
|
||||
|
||||
#[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