Merge pull request #64 from levineuwirth/session-m46-diagnostic-surface
M4.6 TUI diagnostic surface: mode-line counts, colored squiggles (protocol v6), line markers
This commit is contained in:
commit
50709412d0
|
|
@ -125,6 +125,11 @@ pub struct Style {
|
|||
pub underline: UnderlineStyle,
|
||||
/// Reverse video.
|
||||
pub reverse: bool,
|
||||
/// Underline color (SGR 58/59). `Color::Default` means "follow
|
||||
/// the text color": the underline draws in `fg`. Diagnostics set
|
||||
/// this per severity so the squiggle color can differ from the
|
||||
/// syntax-colored text it underlines (T M4.6, protocol v6).
|
||||
pub underline_color: Color,
|
||||
}
|
||||
|
||||
/// A non-text attachment carried in a cell (TUI ignores this).
|
||||
|
|
|
|||
|
|
@ -1020,7 +1020,18 @@ pub enum ResourceBody {
|
|||
/// frontend sends `Pointer` only when the instance's
|
||||
/// `Hello.protocol_version >= 5`, because an older instance would
|
||||
/// hard-error decoding the unknown variant.
|
||||
pub const PROTOCOL_VERSION: u32 = 5;
|
||||
///
|
||||
/// T M4.6 (diagnostic surface): bumped from 5 to 6 for
|
||||
/// `Style::underline_color`. Unlike every previous bump, this one
|
||||
/// changes the *encoding of an existing struct* — `Style` rides
|
||||
/// inside `Cell` / `CellDelta` / `Snapshot` / `StyleSpans`, messages
|
||||
/// every session receives — so per-session send gating cannot
|
||||
/// preserve compatibility (postcard is not self-describing; a v5
|
||||
/// decoder mis-reads any v6 `Style`). v6 binaries therefore accept
|
||||
/// only v6 peers: a version-mismatched pair fails the handshake with
|
||||
/// [`GoodbyeReason::VersionMismatch`] instead of garbling cell
|
||||
/// traffic mid-session.
|
||||
pub const PROTOCOL_VERSION: u32 = 6;
|
||||
|
||||
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
|
||||
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
|
||||
|
|
@ -1044,7 +1055,15 @@ pub const PROTOCOL_VERSION: u32 = 5;
|
|||
/// Mouse framing Q#M1: extended to `[1, 2, 3, 4, 5]`. v5 peers may
|
||||
/// send `FrontendEvent::Pointer`; the frontend-side gate (see
|
||||
/// [`PROTOCOL_VERSION`]) keeps the variant off wires negotiated `< 5`.
|
||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[1, 2, 3, 4, 5];
|
||||
///
|
||||
/// T M4.6: narrowed to `[6]`. `Style::underline_color` changed the
|
||||
/// postcard encoding of every cell-carrying message, so v6 binaries
|
||||
/// cannot exchange cell traffic with any earlier wire. The v1–v5
|
||||
/// compat ladder (additive variants, per-session filtering) assumed
|
||||
/// shared-struct encodings never changed; this bump is the first
|
||||
/// that breaks that assumption, and slice membership is how the
|
||||
/// handshake communicates it.
|
||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6];
|
||||
|
||||
/// T M10.5: predicate for the handshake check. Returns `true` if
|
||||
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].
|
||||
|
|
|
|||
38
src/ansi.rs
38
src/ansi.rs
|
|
@ -931,6 +931,15 @@ impl AnsiParser {
|
|||
}
|
||||
}
|
||||
49 => self.current_style.bg = Color::Default,
|
||||
// 58/59 (underline color, kitty/mintty extension):
|
||||
// same extended-color grammar as 38/48 (T M4.6).
|
||||
58 => {
|
||||
if let Some((color, extra)) = parse_extended_color(p, ¶ms[i + 1..]) {
|
||||
self.current_style.underline_color = color;
|
||||
consumed_extra = extra;
|
||||
}
|
||||
}
|
||||
59 => self.current_style.underline_color = Color::Default,
|
||||
90..=97 => {
|
||||
self.current_style.fg =
|
||||
Color::Indexed(u8::try_from(p.main - 90 + 8).unwrap_or(0));
|
||||
|
|
@ -1266,6 +1275,35 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Underline color via SGR 58 (both colon-subparam and semicolon
|
||||
/// grammars, mirroring 38/48), reset via SGR 59 (T M4.6).
|
||||
#[test]
|
||||
fn m4_6_underline_color() {
|
||||
let cases: &[(&[u8], Color)] = &[
|
||||
(b"\x1b[58:5:1m", Color::Indexed(1)),
|
||||
(b"\x1b[58;5;124m", Color::Indexed(124)),
|
||||
(b"\x1b[58:2::255:0:0m", Color::Rgb(255, 0, 0)),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
let mut p = AnsiParser::new();
|
||||
let evs = p.feed(input);
|
||||
let styles = collect_styles(&evs);
|
||||
assert_eq!(
|
||||
styles.first().map(|s| s.underline_color),
|
||||
Some(*expected),
|
||||
"input {input:?} should produce underline color {expected:?}; got {evs:?}"
|
||||
);
|
||||
}
|
||||
// 59 resets to follow-text-color.
|
||||
let mut p = AnsiParser::new();
|
||||
let evs = p.feed(b"\x1b[58:5:1m\x1b[59m");
|
||||
let styles = collect_styles(&evs);
|
||||
assert_eq!(
|
||||
styles.last().map(|s| s.underline_color),
|
||||
Some(Color::Default)
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Intra-line motion and erase
|
||||
// -----------------------------------------------------------------
|
||||
|
|
|
|||
257
src/diag.rs
257
src/diag.rs
|
|
@ -332,14 +332,14 @@ pub fn make_shared_store() -> SharedDiagStore {
|
|||
const TAB_WIDTH: u32 = 8;
|
||||
|
||||
/// Style applied to bytes covered by an `Error` diagnostic. Wavy
|
||||
/// underline in red so it composes with whatever the syntax view
|
||||
/// painted on top.
|
||||
/// underline colored via `underline_color` (not `fg`) so the
|
||||
/// squiggle reads red while the syntax view's text color survives
|
||||
/// underneath (T M4.6, protocol v6).
|
||||
fn error_style() -> Style {
|
||||
Style {
|
||||
underline: UnderlineStyle::Curly,
|
||||
// Red. Indexed 1 is portable across 8/16-color terminals.
|
||||
fg: Color::Default,
|
||||
bg: Color::Default,
|
||||
// Indexed 1 (red) is portable across 8/16-color terminals.
|
||||
underline_color: Color::Indexed(1),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -348,6 +348,8 @@ fn error_style() -> Style {
|
|||
fn warning_style() -> Style {
|
||||
Style {
|
||||
underline: UnderlineStyle::Curly,
|
||||
// Indexed 3: yellow.
|
||||
underline_color: Color::Indexed(3),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -356,6 +358,8 @@ fn warning_style() -> Style {
|
|||
fn info_style() -> Style {
|
||||
Style {
|
||||
underline: UnderlineStyle::Single,
|
||||
// Indexed 6: cyan.
|
||||
underline_color: Color::Indexed(6),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -364,6 +368,9 @@ 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),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -377,6 +384,25 @@ fn style_for(severity: DiagnosticSeverity) -> Style {
|
|||
}
|
||||
}
|
||||
|
||||
/// Style of the column-0 line marker — the TUI's gutter sign
|
||||
/// (T M4.6). The TUI reserves no gutter column, so the sign is a
|
||||
/// severity-colored *background* on the line's first cell: the
|
||||
/// glyph and its syntax color survive (the view contract is
|
||||
/// 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,
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// View that consumes the shared diagnostic store and underlines
|
||||
/// affected ranges. Composes over [`crate::text_view::TextView`] per
|
||||
/// the M2 view-composition contract --- never writes glyphs, only
|
||||
|
|
@ -449,6 +475,12 @@ impl View for DiagnosticView {
|
|||
let max_cols = viewport.cell_size.cols;
|
||||
let cell_origin = viewport.cell_origin;
|
||||
|
||||
// Column-0 line markers (gutter signs, T M4.6): most severe
|
||||
// diagnostic per visible row wins. `Ord` on the severity enum
|
||||
// follows LSP numbering, so "most severe" is the minimum.
|
||||
let mut line_markers: std::collections::HashMap<u32, DiagnosticSeverity> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for diag in &diags {
|
||||
let style = style_for(diag.severity);
|
||||
// Apply to each line the diagnostic touches. LSP ranges
|
||||
|
|
@ -471,6 +503,13 @@ impl View for DiagnosticView {
|
|||
if row_offset >= max_rows {
|
||||
break;
|
||||
}
|
||||
// Record the line marker before any byte-range work:
|
||||
// zero-width ranges (`byte_end <= byte_start` below)
|
||||
// skip the underline but still mark the line.
|
||||
line_markers
|
||||
.entry(row_offset)
|
||||
.and_modify(|s| *s = (*s).min(diag.severity))
|
||||
.or_insert(diag.severity);
|
||||
let line_start = line_offsets[line as usize];
|
||||
let line_end = line_offsets
|
||||
.get(line as usize + 1)
|
||||
|
|
@ -497,16 +536,8 @@ impl View for DiagnosticView {
|
|||
} else {
|
||||
line_byte_len
|
||||
};
|
||||
if byte_end <= byte_start {
|
||||
// Empty range on a line --- still flag the
|
||||
// gutter character (the cell at column 0). For
|
||||
// a multi-line diagnostic with end_col=0, this
|
||||
// path is hit on the first / last line; the
|
||||
// visible column already moved on.
|
||||
continue;
|
||||
}
|
||||
let (start_col, end_col) =
|
||||
byte_range_to_display_cols(line_bytes, byte_start as usize, byte_end as usize);
|
||||
underline_cols_for_line(line_bytes, byte_start, byte_end);
|
||||
if end_col <= start_col {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -519,6 +550,19 @@ impl View for DiagnosticView {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Paint the column-0 markers last so a marker is visible even
|
||||
// when an underline span also touches column 0 (bg and
|
||||
// underline merge independently).
|
||||
if max_cols > 0 {
|
||||
for (row_offset, severity) in line_markers {
|
||||
let cell = cells.at(CellCoord::new(
|
||||
cell_origin.row + row_offset,
|
||||
cell_origin.col,
|
||||
));
|
||||
cell.style = merge_styles(cell.style, marker_style_for(severity));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -545,6 +589,25 @@ fn line_at_offset(line_offsets: &[u32], offset: u32) -> u32 {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve the display-column span to underline for one line of a
|
||||
/// diagnostic. Zero-width ranges — the shape parsers use for
|
||||
/// "expected COMMA"-style errors anchored one past the last token
|
||||
/// (rust-analyzer reports a missing comma as `col 12 → col 12` at
|
||||
/// end of line, and the caller's `.min(line_byte_len)` clamps
|
||||
/// collapse any past-EOL anchor the same way) — get a single-cell
|
||||
/// span at the anchor: one past EOL is a blank cell inside the
|
||||
/// window, and a squiggled space is exactly how other editors
|
||||
/// surface it.
|
||||
fn underline_cols_for_line(line_bytes: &[u8], byte_start: u32, byte_end: u32) -> (u32, u32) {
|
||||
if byte_end <= byte_start {
|
||||
let (anchor, _) =
|
||||
byte_range_to_display_cols(line_bytes, byte_start as usize, byte_start as usize);
|
||||
(anchor, anchor + 1)
|
||||
} else {
|
||||
byte_range_to_display_cols(line_bytes, byte_start as usize, byte_end as usize)
|
||||
}
|
||||
}
|
||||
|
||||
fn byte_range_to_display_cols(line_bytes: &[u8], byte_start: usize, byte_end: usize) -> (u32, u32) {
|
||||
let bs = byte_start.min(line_bytes.len());
|
||||
let be = byte_end.min(line_bytes.len());
|
||||
|
|
@ -791,6 +854,33 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severity_styles_color_the_underline_not_the_text() {
|
||||
// T M4.6: each severity gets a distinct underline color via
|
||||
// `underline_color` (SGR 58); `fg` stays Default so the
|
||||
// syntax view's text color survives the merge.
|
||||
let mut seen = Vec::new();
|
||||
for s in [
|
||||
DiagnosticSeverity::Error,
|
||||
DiagnosticSeverity::Warning,
|
||||
DiagnosticSeverity::Information,
|
||||
DiagnosticSeverity::Hint,
|
||||
] {
|
||||
let style = style_for(s);
|
||||
assert_eq!(style.fg, Color::Default, "{s:?} must not set fg");
|
||||
assert_ne!(
|
||||
style.underline_color,
|
||||
Color::Default,
|
||||
"{s:?} must color its underline"
|
||||
);
|
||||
assert!(
|
||||
!seen.contains(&style.underline_color),
|
||||
"{s:?} reuses another severity's underline color"
|
||||
);
|
||||
seen.push(style.underline_color);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_advertises_diagnostic_kind() {
|
||||
// `pmacs.window._overlay_kinds()` introspection (task #23 wire-up,
|
||||
|
|
@ -845,4 +935,143 @@ mod tests {
|
|||
"stale diagnostics must not underline shifted TUI bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_zero_marker_shows_most_severe_diagnostic_per_line() {
|
||||
use crate::cell::{Cell, CellSize, Glyph, UnderlineStyle};
|
||||
|
||||
let store = make_shared_store();
|
||||
{
|
||||
let mut guard = store.lock().expect("diag store");
|
||||
// Line 0: a Hint and an Error overlap — the marker must
|
||||
// show the Error. Line 1: a zero-width Warning (start ==
|
||||
// end), invisible to the underline pass but still marked.
|
||||
guard.set(
|
||||
"file:///a",
|
||||
vec![
|
||||
diag(0, DiagnosticSeverity::Hint, "h"),
|
||||
diag(0, DiagnosticSeverity::Error, "e"),
|
||||
Diagnostic {
|
||||
start_line: 1,
|
||||
start_col: 2,
|
||||
end_line: 1,
|
||||
end_col: 2,
|
||||
severity: DiagnosticSeverity::Warning,
|
||||
message: "w".to_owned(),
|
||||
source: None,
|
||||
code: None,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
let mut buf = Buffer::new(crate::buffer::BufferId::next(), "test.c");
|
||||
buf.apply_edit(crate::buffer::EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: b"hello\nworld\nclean\n",
|
||||
})
|
||||
.expect("seed buffer");
|
||||
|
||||
let mut view = DiagnosticView::new("file:///a", store);
|
||||
let mut backing = vec![Cell::default(); 30];
|
||||
// Pre-paint glyphs at column 0 to pin the style-only contract.
|
||||
backing[0].glyph = Glyph::Char('h');
|
||||
let mut grid = CellGrid {
|
||||
cells: &mut backing,
|
||||
stride: 10,
|
||||
size: CellSize::new(3, 10),
|
||||
};
|
||||
view.render(
|
||||
&buf,
|
||||
Viewport {
|
||||
buffer_start: 0,
|
||||
buffer_end: buf.len(),
|
||||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(3, 10),
|
||||
},
|
||||
&mut grid,
|
||||
);
|
||||
|
||||
// Line 0: red (error) marker wins over the hint's gray; the
|
||||
// glyph survives untouched.
|
||||
assert_eq!(grid.get(CellCoord::new(0, 0)).style.bg, Color::Indexed(1));
|
||||
assert_eq!(grid.get(CellCoord::new(0, 0)).glyph, Glyph::Char('h'));
|
||||
// Line 1: zero-width warning gets a marker and a single-cell
|
||||
// squiggle at its anchor column — and only there.
|
||||
assert_eq!(grid.get(CellCoord::new(1, 0)).style.bg, Color::Indexed(3));
|
||||
assert_eq!(
|
||||
grid.get(CellCoord::new(1, 2)).style.underline,
|
||||
UnderlineStyle::Curly
|
||||
);
|
||||
assert_eq!(
|
||||
grid.get(CellCoord::new(1, 3)).style.underline,
|
||||
UnderlineStyle::None
|
||||
);
|
||||
// Line 2: clean — no marker.
|
||||
assert_eq!(grid.get(CellCoord::new(2, 0)).style.bg, Color::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_of_line_zero_width_error_squiggles_the_cell_past_eol() {
|
||||
// The missing-comma shape: rust-analyzer anchors "expected
|
||||
// COMMA" as a zero-width range one past the line's last
|
||||
// character (`b: 2` → col 12..12 on a 12-byte line). The
|
||||
// squiggle must land on the blank cell just past EOL, not
|
||||
// vanish in the empty-range clamp.
|
||||
use crate::cell::{Cell, CellSize, UnderlineStyle};
|
||||
|
||||
let store = make_shared_store();
|
||||
store.lock().expect("diag store").set(
|
||||
"file:///a",
|
||||
vec![Diagnostic {
|
||||
start_line: 0,
|
||||
start_col: 5, // one past "hello" (5 bytes)
|
||||
end_line: 0,
|
||||
end_col: 5,
|
||||
severity: DiagnosticSeverity::Error,
|
||||
message: "expected COMMA".to_owned(),
|
||||
source: None,
|
||||
code: None,
|
||||
}],
|
||||
);
|
||||
|
||||
let mut buf = Buffer::new(crate::buffer::BufferId::next(), "test.rs");
|
||||
buf.apply_edit(crate::buffer::EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: b"hello\nworld\n",
|
||||
})
|
||||
.expect("seed buffer");
|
||||
|
||||
let mut view = DiagnosticView::new("file:///a", store);
|
||||
let mut backing = vec![Cell::default(); 20];
|
||||
let mut grid = CellGrid {
|
||||
cells: &mut backing,
|
||||
stride: 10,
|
||||
size: CellSize::new(2, 10),
|
||||
};
|
||||
view.render(
|
||||
&buf,
|
||||
Viewport {
|
||||
buffer_start: 0,
|
||||
buffer_end: buf.len(),
|
||||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(2, 10),
|
||||
},
|
||||
&mut grid,
|
||||
);
|
||||
|
||||
// Single-cell red squiggle on the blank cell past "hello".
|
||||
let cell = grid.get(CellCoord::new(0, 5));
|
||||
assert_eq!(cell.style.underline, UnderlineStyle::Curly);
|
||||
assert_eq!(cell.style.underline_color, Color::Indexed(1));
|
||||
// Nothing under the word itself or beyond the anchor.
|
||||
assert_eq!(
|
||||
grid.get(CellCoord::new(0, 4)).style.underline,
|
||||
UnderlineStyle::None
|
||||
);
|
||||
assert_eq!(
|
||||
grid.get(CellCoord::new(0, 6)).style.underline,
|
||||
UnderlineStyle::None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
173
src/editor.rs
173
src/editor.rs
|
|
@ -1207,6 +1207,7 @@ 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();
|
||||
for (id, window) in &mut core.windows {
|
||||
let Some(rect) = placements.get(id).copied() else {
|
||||
continue;
|
||||
|
|
@ -1247,6 +1248,15 @@ pub fn paint_frame(
|
|||
window.text_view.line_count(),
|
||||
coord.row as usize,
|
||||
);
|
||||
// Lock scoped to the summary computation only: the overlay
|
||||
// renders above include `DiagnosticView`, which takes this
|
||||
// same mutex — holding the guard across the loop deadlocked
|
||||
// the daemon on the first frame after a file (and thus a
|
||||
// diagnostic overlay) was opened.
|
||||
let diags = {
|
||||
let guard = diag_store.lock().expect("diag store mutex poisoned");
|
||||
diag_mode_line_summary(&guard, buf)
|
||||
};
|
||||
paint_mode_line(
|
||||
grid,
|
||||
&rect,
|
||||
|
|
@ -1256,6 +1266,7 @@ pub fn paint_frame(
|
|||
coord.row,
|
||||
coord.col,
|
||||
&scroll,
|
||||
&diags,
|
||||
);
|
||||
}
|
||||
drop(reg);
|
||||
|
|
@ -1398,9 +1409,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 +1456,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 +1465,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 +5139,125 @@ 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_with_attached_diagnostic_view_does_not_deadlock() {
|
||||
// Regression: paint_frame once held the diag-store mutex
|
||||
// across the whole window loop, and `DiagnosticView::render`
|
||||
// (attached as a window overlay when a file with an LSP
|
||||
// opens) locks the same mutex — the daemon froze on the
|
||||
// first frame after C-x C-f. This test renders the full
|
||||
// paint_frame path with a real DiagnosticView attached; it
|
||||
// hangs the suite if the lock is ever widened again.
|
||||
use crate::diag::DiagnosticSeverity::Error;
|
||||
let s = fresh_with(b"hello\n");
|
||||
let uri = set_active_buffer_path(&s, "/tmp/modeline_overlay.rs");
|
||||
let store = s.lsp_manager.borrow().diag_store();
|
||||
store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.set(uri.clone(), vec![diag_with_severity(Error)]);
|
||||
{
|
||||
let mut core = s.core.borrow_mut();
|
||||
core.active_window_mut()
|
||||
.push_overlay(Box::new(crate::diag::DiagnosticView::new(uri, store)));
|
||||
}
|
||||
let (cells, stride, _) = render_to_grid(&s, 24, 80);
|
||||
// Both surfaces of the same store: the overlay's underline
|
||||
// and the mode line's count.
|
||||
assert_eq!(
|
||||
cells[0].style.underline,
|
||||
crate::cell::UnderlineStyle::Curly,
|
||||
"diagnostic overlay should underline the error range"
|
||||
);
|
||||
let mode_text = row_text(&cells, stride, 22, 80);
|
||||
assert!(
|
||||
mode_text.contains("E:1"),
|
||||
"mode line missing count: {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");
|
||||
|
|
|
|||
|
|
@ -634,16 +634,30 @@ fn apply_style<W: Write>(w: &mut W, style: &Style) -> io::Result<()> {
|
|||
}
|
||||
match style.underline {
|
||||
crate::cell::UnderlineStyle::None => {}
|
||||
crate::cell::UnderlineStyle::Single
|
||||
| crate::cell::UnderlineStyle::Double
|
||||
| crate::cell::UnderlineStyle::Curly
|
||||
| crate::cell::UnderlineStyle::Dotted
|
||||
| crate::cell::UnderlineStyle::Dashed => {
|
||||
// crossterm exposes Underlined; richer styles need a manual
|
||||
// CSI 4:N m emit, which most terminals fall back to plain
|
||||
// underline on. M2 can wire CSI 4:N m for kitty/iTerm.
|
||||
crate::cell::UnderlineStyle::Single => {
|
||||
queue!(w, SetAttribute(Attribute::Underlined))?;
|
||||
}
|
||||
// Kitty-style underline subparameters (CSI 4:N m). Terminals
|
||||
// that predate the extension treat the whole sequence as
|
||||
// SGR 4 (plain underline) or ignore the subparameter, so the
|
||||
// fallback is a straight underline — same as the previous
|
||||
// flatten-to-`Underlined` behavior (T M4.6).
|
||||
crate::cell::UnderlineStyle::Double => write!(w, "\x1b[4:2m")?,
|
||||
crate::cell::UnderlineStyle::Curly => write!(w, "\x1b[4:3m")?,
|
||||
crate::cell::UnderlineStyle::Dotted => write!(w, "\x1b[4:4m")?,
|
||||
crate::cell::UnderlineStyle::Dashed => write!(w, "\x1b[4:5m")?,
|
||||
}
|
||||
// Underline color (SGR 58, colon subparameter form). Emitted only
|
||||
// when set and an underline is present: the SGR 0 at the top of
|
||||
// this function already reset the underline color to
|
||||
// follow-text-color (SGR 59 state), which is what
|
||||
// `Color::Default` means.
|
||||
if style.underline != crate::cell::UnderlineStyle::None {
|
||||
match style.underline_color {
|
||||
Color::Default => {}
|
||||
Color::Rgb(r, g, b) => write!(w, "\x1b[58:2::{r}:{g}:{b}m")?,
|
||||
Color::Indexed(n) => write!(w, "\x1b[58:5:{n}m")?,
|
||||
}
|
||||
}
|
||||
if style.reverse {
|
||||
queue!(w, SetAttribute(Attribute::Reverse))?;
|
||||
|
|
@ -771,6 +785,54 @@ mod tests {
|
|||
assert!(s.contains("\x1b[1m"), "missing bold-on in {s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curly_underline_emits_csi_4_3_and_underline_color() {
|
||||
// A diagnostic-style cell: curly underline colored red via
|
||||
// SGR 58 (T M4.6). The text color must NOT be touched.
|
||||
let span = DiffSpan {
|
||||
start: CellCoord::new(0, 0),
|
||||
cells: vec![Cell {
|
||||
glyph: Glyph::Char('x'),
|
||||
style: Style {
|
||||
underline: crate::cell::UnderlineStyle::Curly,
|
||||
underline_color: crate::cell::Color::Indexed(1),
|
||||
..Style::default()
|
||||
},
|
||||
attachment: None,
|
||||
}],
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
emit_span(&mut out, &span).unwrap();
|
||||
let s = String::from_utf8_lossy(&out);
|
||||
assert!(s.contains("\x1b[4:3m"), "missing curly underline in {s:?}");
|
||||
assert!(
|
||||
s.contains("\x1b[58:5:1m"),
|
||||
"missing underline color in {s:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_underline_color_emits_no_sgr_58() {
|
||||
// A plain single underline with follow-text color: no SGR 58
|
||||
// on the wire (the reset at the start of every style apply
|
||||
// already put the terminal in SGR 59 state).
|
||||
let span = DiffSpan {
|
||||
start: CellCoord::new(0, 0),
|
||||
cells: vec![Cell {
|
||||
glyph: Glyph::Char('x'),
|
||||
style: Style {
|
||||
underline: crate::cell::UnderlineStyle::Single,
|
||||
..Style::default()
|
||||
},
|
||||
attachment: None,
|
||||
}],
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
emit_span(&mut out, &span).unwrap();
|
||||
let s = String::from_utf8_lossy(&out);
|
||||
assert!(!s.contains("\x1b[58"), "unexpected SGR 58 in {s:?}");
|
||||
}
|
||||
|
||||
// ---- status overlay (T M5.8) -----------------------------------------
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -6248,6 +6248,7 @@ fn lua_to_style(t: &Table) -> mlua::Result<Style> {
|
|||
let fg: mlua::Value = t.get("fg").unwrap_or(mlua::Value::Nil);
|
||||
let bg: mlua::Value = t.get("bg").unwrap_or(mlua::Value::Nil);
|
||||
let underline: mlua::Value = t.get("underline").unwrap_or(mlua::Value::Nil);
|
||||
let underline_color: mlua::Value = t.get("underline_color").unwrap_or(mlua::Value::Nil);
|
||||
Ok(Style {
|
||||
fg: lua_to_color(&fg)?,
|
||||
bg: lua_to_color(&bg)?,
|
||||
|
|
@ -6255,17 +6256,19 @@ fn lua_to_style(t: &Table) -> mlua::Result<Style> {
|
|||
italic: t.get("italic").unwrap_or(false),
|
||||
underline: lua_to_underline(&underline)?,
|
||||
reverse: t.get("reverse").unwrap_or(false),
|
||||
underline_color: lua_to_color(&underline_color)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn style_to_lua(lua: &Lua, style: Style) -> mlua::Result<Table> {
|
||||
let t = lua.create_table_with_capacity(0, 6)?;
|
||||
let t = lua.create_table_with_capacity(0, 7)?;
|
||||
t.set("fg", color_to_lua(lua, style.fg)?)?;
|
||||
t.set("bg", color_to_lua(lua, style.bg)?)?;
|
||||
t.set("bold", style.bold)?;
|
||||
t.set("italic", style.italic)?;
|
||||
t.set("underline", underline_to_lua(style.underline))?;
|
||||
t.set("reverse", style.reverse)?;
|
||||
t.set("underline_color", color_to_lua(lua, style.underline_color)?)?;
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -125,12 +125,12 @@ impl View for StyleSpanOverlay {
|
|||
|
||||
/// Merge `overlay` over `base`, producing a new [`Style`].
|
||||
///
|
||||
/// `fg`/`bg`/`underline` use "non-default wins" --- the overlay's
|
||||
/// value applies only when it is non-default; otherwise the base
|
||||
/// value is preserved. Boolean attributes (`bold`, `italic`,
|
||||
/// `reverse`) OR-blend so that two stacked overlays can each
|
||||
/// contribute. Pulled out of [`StyleSpanOverlay`] so
|
||||
/// [`crate::highlight::SyntaxHighlightView`] can reuse the same
|
||||
/// `fg`/`bg`/`underline`/`underline_color` use "non-default wins"
|
||||
/// --- the overlay's value applies only when it is non-default;
|
||||
/// otherwise the base value is preserved. Boolean attributes
|
||||
/// (`bold`, `italic`, `reverse`) OR-blend so that two stacked
|
||||
/// overlays can each contribute. Pulled out of [`StyleSpanOverlay`]
|
||||
/// so [`crate::highlight::SyntaxHighlightView`] can reuse the same
|
||||
/// composition rule (T M4.3).
|
||||
#[must_use]
|
||||
pub fn merge_styles(base: Style, overlay: Style) -> Style {
|
||||
|
|
@ -154,6 +154,11 @@ pub fn merge_styles(base: Style, overlay: Style) -> Style {
|
|||
overlay.underline
|
||||
},
|
||||
reverse: base.reverse || overlay.reverse,
|
||||
underline_color: if overlay.underline_color == Color::Default {
|
||||
base.underline_color
|
||||
} else {
|
||||
overlay.underline_color
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -614,6 +619,34 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_styles_underline_color_non_default_wins() {
|
||||
use crate::cell::Color;
|
||||
let base = Style {
|
||||
fg: Color::Indexed(2),
|
||||
underline: UnderlineStyle::Single,
|
||||
underline_color: Color::Indexed(6),
|
||||
..Default::default()
|
||||
};
|
||||
// Overlay sets its own underline color: it wins, while the
|
||||
// base's fg (syntax color) survives untouched.
|
||||
let diag_overlay = Style {
|
||||
underline: UnderlineStyle::Curly,
|
||||
underline_color: Color::Indexed(1),
|
||||
..Default::default()
|
||||
};
|
||||
let merged = merge_styles(base, diag_overlay);
|
||||
assert_eq!(merged.underline_color, Color::Indexed(1));
|
||||
assert_eq!(merged.fg, Color::Indexed(2));
|
||||
// Overlay with default underline color: base's is preserved.
|
||||
let plain_overlay = Style {
|
||||
bold: true,
|
||||
..Default::default()
|
||||
};
|
||||
let merged = merge_styles(base, plain_overlay);
|
||||
assert_eq!(merged.underline_color, Color::Indexed(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_viewport_spans_are_silently_skipped() {
|
||||
let buf = Buffer::from_bytes(BufferId::next(), "t", b"x\n");
|
||||
|
|
|
|||
135
src/protocol.rs
135
src/protocol.rs
|
|
@ -1683,34 +1683,34 @@ mod tests {
|
|||
// --- M5.5a handshake & postcard round-trips ---
|
||||
|
||||
#[test]
|
||||
fn protocol_version_is_five_for_pointer_events() {
|
||||
fn protocol_version_is_six_for_underline_color() {
|
||||
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
|
||||
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
|
||||
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
|
||||
// bumped 3→4 (DispatchIdle for the optimistic-apply gate).
|
||||
// The mouse framing Q#M1 bumped 4→5 (FrontendEvent::Pointer,
|
||||
// byte-position gestures from semantic frontends). The
|
||||
// current binary serves v1..=v5 sessions — the slice-
|
||||
// membership handshake makes the relaxation symmetric.
|
||||
assert_eq!(PROTOCOL_VERSION, 5);
|
||||
// The mouse framing Q#M1 bumped 4→5 (FrontendEvent::Pointer).
|
||||
// T M4.6 bumped 5→6 (`Style::underline_color`) — the first
|
||||
// bump that changed an existing struct's postcard encoding,
|
||||
// so v6 binaries serve v6 sessions only.
|
||||
assert_eq!(PROTOCOL_VERSION, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_protocol_versions_includes_one_through_five() {
|
||||
// T M10.5: v1.0 binaries accept v1+v2. T M11.1: v1.1 binaries
|
||||
// accept v1+v2+v3. T M11.6: v4 binaries accept v1..=v4. Mouse
|
||||
// framing Q#M1: v5 binaries accept v1..=v5. The check is
|
||||
// slice membership, not strict equality, so older binaries
|
||||
// keep connecting to current binaries unchanged. v6+ is
|
||||
// rejected until the next bump.
|
||||
assert!(is_supported_protocol_version(1));
|
||||
assert!(is_supported_protocol_version(2));
|
||||
assert!(is_supported_protocol_version(3));
|
||||
assert!(is_supported_protocol_version(4));
|
||||
assert!(is_supported_protocol_version(5));
|
||||
assert!(!is_supported_protocol_version(0));
|
||||
assert!(!is_supported_protocol_version(6));
|
||||
assert!(!is_supported_protocol_version(u32::MAX));
|
||||
fn supported_protocol_versions_is_exactly_v6() {
|
||||
// T M4.6: `Style::underline_color` changed the encoding of
|
||||
// every cell-carrying message (`Cell` / `CellDelta` /
|
||||
// `Snapshot` / `StyleSpans`). The v1–v5 compat ladder relied
|
||||
// on shared-struct encodings never changing — additive enum
|
||||
// variants filtered per session — so the ladder ends here:
|
||||
// pre-v6 peers are refused at the handshake (a clean
|
||||
// VersionMismatch) rather than garbling postcard mid-session.
|
||||
assert!(is_supported_protocol_version(6));
|
||||
for rejected in [0, 1, 2, 3, 4, 5, 7, u32::MAX] {
|
||||
assert!(
|
||||
!is_supported_protocol_version(rejected),
|
||||
"v{rejected} must be rejected by a v6 binary"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2041,79 +2041,40 @@ mod tests {
|
|||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// T M10.5 — backward-compat handshake matrix tests.
|
||||
// Handshake version-policy tests.
|
||||
//
|
||||
// Four cases per the framing-pass handshake matrix:
|
||||
// 1. v1 daemon ↔ v1 frontend: pre-existing behavior; not retested.
|
||||
// 2. v1 daemon ↔ v2 frontend: rejected with VersionMismatch.
|
||||
// 3. v2 daemon ↔ v1 frontend: success; v1 session.
|
||||
// 4. v2 daemon ↔ v2 frontend: success; v2 session.
|
||||
//
|
||||
// These tests exercise `is_supported_protocol_version` directly
|
||||
// since the full daemon-attach path requires socket setup that's
|
||||
// in m5_5_acceptance.rs. The version-check predicate is the
|
||||
// load-bearing piece; daemon-level integration tests are in the
|
||||
// separate integration test file.
|
||||
// History: T M10.5 introduced the slice-membership relaxation
|
||||
// (`is_supported_protocol_version`) so v1.0 daemons could accept
|
||||
// v0.1 frontends, and the ladder grew through v5 (T M11.1, T
|
||||
// M11.6, mouse framing Q#M1) — all additive enum variants,
|
||||
// filtered per session, with shared-struct encodings untouched.
|
||||
// T M4.6 (`Style::underline_color`) changed a shared struct's
|
||||
// postcard encoding, ending the ladder: v6 binaries accept only
|
||||
// v6 peers. These tests exercise the predicate directly; the
|
||||
// daemon-level handshake integration lives in m5_5_acceptance.rs.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn m10_5_handshake_matrix_v2_daemon_accepts_v1_frontend() {
|
||||
// The relaxation that makes §sec:m10-backward-compat hold.
|
||||
assert!(
|
||||
is_supported_protocol_version(1),
|
||||
"v2 daemon must accept v1 frontend per §sec:m10-backward-compat"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m10_5_handshake_matrix_v2_daemon_accepts_v2_frontend() {
|
||||
// The new case M10.5 enables.
|
||||
assert!(
|
||||
is_supported_protocol_version(2),
|
||||
"v2 daemon must accept v2 frontend (the v1.0 happy path)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m10_5_handshake_matrix_versions_outside_range_rejected() {
|
||||
// v1 daemon's strict-equality behavior is documented at the
|
||||
// v0.1 code level (different binary); the current daemon's
|
||||
// range check accepts v1..=v5 (T M11.1 added v3; T M11.6
|
||||
// added v4; the mouse framing Q#M1 added v5) and rejects v6+
|
||||
// until the next protocol bump.
|
||||
assert!(!is_supported_protocol_version(0));
|
||||
assert!(!is_supported_protocol_version(6));
|
||||
assert!(!is_supported_protocol_version(u32::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m10_5_strict_equality_v1_frontend_simulation() {
|
||||
// T M10.5 framing-pass risk #5 verification: existing v1
|
||||
// frontends (the v0.1.0 release codebase, pre-M10.5) do
|
||||
// strict equality on Hello.protocol_version. Simulate that
|
||||
// check explicitly so the audit doc has empirical evidence
|
||||
// of the actual backward-compat surface.
|
||||
//
|
||||
// Before M10.5: `if hello.protocol_version != 1 { reject }`.
|
||||
// After M10.5: `if !is_supported_protocol_version(...) { reject }`.
|
||||
//
|
||||
// For a v1-strict-frontend connecting to a v2 daemon: the
|
||||
// daemon's Hello carries protocol_version=2; the v1-strict
|
||||
// frontend rejects with VersionMismatch.
|
||||
fn v1_strict_check(hello_version: u32) -> bool {
|
||||
hello_version == 1
|
||||
fn m4_6_handshake_rejects_every_pre_v6_wire() {
|
||||
// A v5-or-older peer cannot decode v6 cell traffic (postcard
|
||||
// is not self-describing), so the handshake must refuse the
|
||||
// session up front with VersionMismatch — slice membership is
|
||||
// how that policy is expressed.
|
||||
for old in 1..=5 {
|
||||
assert!(
|
||||
!is_supported_protocol_version(old),
|
||||
"v6 binary must refuse a v{old} peer: its Style encoding \
|
||||
predates underline_color and would mis-decode every CellDelta"
|
||||
);
|
||||
}
|
||||
// v1-strict frontend hitting v2 daemon's Hello: rejected.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m4_6_handshake_accepts_v6_peer() {
|
||||
assert!(
|
||||
!v1_strict_check(2),
|
||||
"v1-strict frontend rejects v2 daemon's Hello — pre-M10.5 binaries \
|
||||
can NOT connect to v2 daemons even though v2 daemons accept their requests"
|
||||
is_supported_protocol_version(PROTOCOL_VERSION),
|
||||
"the current wire version must accept itself"
|
||||
);
|
||||
// v1-strict frontend hitting v1 daemon's Hello: accepted.
|
||||
assert!(v1_strict_check(1));
|
||||
// For comparison, M10.5's relaxed check (v2 frontend after this milestone):
|
||||
assert!(is_supported_protocol_version(1));
|
||||
assert!(is_supported_protocol_version(2));
|
||||
}
|
||||
|
||||
// T M10.6 — PresenceUpdate wire shape tests.
|
||||
|
|
|
|||
|
|
@ -494,6 +494,18 @@ mod tests {
|
|||
}
|
||||
handles.push(h);
|
||||
}
|
||||
// Drop may discard still-queued work after setting shutdown,
|
||||
// so on a loaded CI runner it can win the race before any
|
||||
// worker finishes even one job, flaking the `count > 0`
|
||||
// assert below (observed on the macOS runner). ~2/3 of the
|
||||
// queue is non-cancelled, so one completion must land unless
|
||||
// workers are wedged — wait for it, bounded.
|
||||
let wait_start = std::time::Instant::now();
|
||||
while completed.load(Ordering::Relaxed) == 0
|
||||
&& wait_start.elapsed() < std::time::Duration::from_secs(10)
|
||||
{
|
||||
std::thread::yield_now();
|
||||
}
|
||||
// Drain shutdown synchronously via Drop: this returns only
|
||||
// after every queued, non-cancelled job has run (or every
|
||||
// cancelled job has either run-then-noop or been silently
|
||||
|
|
|
|||
Loading…
Reference in New Issue