From efa8f6f8eda86ebf98ee36ecede31d8dd23a4477 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 18 May 2026 20:47:18 -0400 Subject: [PATCH] M11.3: Decorations projection (selection + diagnostics) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SemanticRenderState now also projects InstanceMessage::Decorations from the instance-side state pmacs actually has. - Selection: per-window byte-native state via active_window_for(fid) (SemanticRenderState now carries the session FrontendId), gated to the declared buffer and clipped to the viewport → DecorationKind::Selection. - Diagnostics: the shared DiagnosticStore keyed by file URI. Made lsp::path_to_file_uri pub(crate) (byte-identical to the Lua file_uri_for) so the projection reproduces the exact store key from core.file_path. LSP (line,col) -> byte via a line-start scan against the buffer source; severity -> DiagnosticError/Warning/ Info/Hint. Clipped to the viewport. - StyleSpans and Decorations suppress unchanged frames independently (separate last_* maps): a selection move doesn't force a styling re-send and vice versa. - Deliberately NOT emitted: SearchMatch/SearchMatchActive (no instance search-hit store), CurrentLine (frontend derives from CursorByte; emitting it would breach the contract boundary). - InlineAdornments/BlockAdornments/FoldState remain unproduced by design — no inlay/blame/lens/fold/diff source exists in pmacs yet. Honest stubs (the M11.1 "declared, not yet wired" discipline), not empty messages every frame. Dispatcher updated for SemanticRenderState::new(frontend_id). Lib (1394 crdt / 1239 non-crdt) + integration green on both feature flavors; clippy -D warnings clean on both. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 24 +++ src/daemon.rs | 2 +- src/lsp.rs | 8 +- src/semantic_render.rs | 471 ++++++++++++++++++++++++++++++++++------- 4 files changed, 422 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f884196..584f771 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,30 @@ The first real producer. The instance now projects syntax styling to `cfg!(feature = "crdt")` — the "M11.2 enables semantic" moment, analogous to the M10.8 Day-4 `multi_frontend`/`crdt_replica` flip. +#### Decorations projection (M11.3) + +`SemanticRenderState` now also projects `InstanceMessage::Decorations` +from the editor state pmacs actually has instance-side: + +- **Selection** — per-window (per-frontend) byte-native state, scoped + to the session's active window for the declared buffer and clipped + to the viewport. `DecorationKind::Selection`. +- **Diagnostics** — the shared `DiagnosticStore`, keyed by the file + URI the LSP glue opened the document under (`lsp::path_to_file_uri` + is now `pub(crate)`, byte-identical to the Lua `file_uri_for`). + LSP `(line, col)` is converted to a byte range against the buffer + source; severity maps to `DiagnosticError`/`Warning`/`Info`/`Hint`. +- `StyleSpans` and `Decorations` suppress unchanged frames + independently — a selection move does not force a styling re-send. +- `Decorations::SearchMatch`/`SearchMatchActive`/`CurrentLine` are not + emitted: pmacs has no instance-side search-hit store, and + current-line is a pure cursor derivation the frontend already owns + via `CursorByte` (emitting it would breach the contract boundary). +- `InlineAdornments`/`BlockAdornments`/`FoldState` remain unproduced + by design — pmacs has no inlay-hint/blame/lens/fold/diff source + yet. The wire variants exist (M11.1); their producers are wired + when those features land. Honest stubs, not fabricated data. + ## [1.0.0] --- 2026-05-18 First stable release. Builds on the 0.1.0 preview (M1–M6) with the diff --git a/src/daemon.rs b/src/daemon.rs index 71640b2..bfbeac6 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1190,7 +1190,7 @@ fn handle_session_established( if semantic_render { semantic_states.insert( frontend_id, - crate::semantic_render::SemanticRenderState::new(), + crate::semantic_render::SemanticRenderState::new(frontend_id), ); } else { let mut render_state = RenderState::new(initial_size); diff --git a/src/lsp.rs b/src/lsp.rs index 283dbd0..946dcb0 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -1857,7 +1857,13 @@ fn canonicalize_root_for_scope(root: &Path) -> PathBuf { root.canonicalize().unwrap_or_else(|_| root.to_path_buf()) } -fn path_to_file_uri(path: &std::path::Path) -> String { +/// `file://` URI encoder. Byte-identical to +/// `builtin/runtime/lsp.lua`'s `file_uri_for` (same passthrough set), +/// so a URI built here keys into the same `DiagnosticStore` entry the +/// Lua LSP glue opened the document under. T M11.3 reuses this from +/// `crate::semantic_render` for the diagnostics projection — hence +/// `pub(crate)`. +pub(crate) fn path_to_file_uri(path: &std::path::Path) -> String { // Minimal file:// URI encoder: percent-encode anything outside // the LSP-friendly set. Adequate for v0.1 (paths in a typical // project root); a fuller URL crate would be overkill here. diff --git a/src/semantic_render.rs b/src/semantic_render.rs index c46615d..ce6b4cd 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -28,7 +28,9 @@ use std::collections::HashMap; use crate::buffer::BufferId; use crate::cell::Style; use crate::editor::EditorState; -use crate::protocol::{ByteRange, InstanceMessage, StyleSpan}; +use crate::protocol::{ + ByteRange, Decoration, DecorationKind, FrontendId, InstanceMessage, StyleSpan, +}; /// The viewport a `semantic_render` frontend last declared. #[derive(Clone, Debug, Eq, PartialEq)] @@ -43,10 +45,16 @@ struct DeclaredViewport { } /// Owns one `semantic_render` session's projection state: the last -/// viewport the frontend declared, and the last `StyleSpans` payload -/// shipped per buffer (for byte-identical-frame suppression). -#[derive(Default)] +/// viewport the frontend declared, and the last `StyleSpans` / +/// `Decorations` payloads shipped per buffer (for byte-identical-frame +/// suppression). pub struct SemanticRenderState { + /// The session this projection serves. Selection is per-window + /// (per-frontend) state, so the decoration projection needs the + /// fid to resolve *this* session's active window via + /// `active_window_for`. Styling and diagnostics are per-buffer and + /// do not consult it. + frontend_id: FrontendId, /// `None` until the frontend's first [`Self::set_viewport`]. While /// `None`, [`Self::render_frame`] emits nothing: the frontend /// bootstraps its rope from `BufferSnapshot`, declares what is on @@ -57,13 +65,23 @@ pub struct SemanticRenderState { /// nothing — the steady-state cost between edits is one map /// lookup. True per-span delta encoding is M11.4. last_sent: HashMap)>, + /// Same unchanged-frame suppression for the `Decorations` family + /// (T M11.3), tracked independently of `last_sent` so a styling + /// change does not force a decorations re-send and vice versa. + last_decorations: HashMap)>, } impl SemanticRenderState { - /// Fresh session state: no viewport declared, nothing sent. + /// Fresh session state for frontend `frontend_id`: no viewport + /// declared, nothing sent. #[must_use] - pub fn new() -> Self { - Self::default() + pub fn new(frontend_id: FrontendId) -> Self { + Self { + frontend_id, + viewport: None, + last_sent: HashMap::new(), + last_decorations: HashMap::new(), + } } /// Record the frontend's declared on-screen byte range. Called by @@ -80,12 +98,20 @@ impl SemanticRenderState { /// Project one frame. /// - /// Returns at most one [`InstanceMessage::StyleSpans`], scoped to - /// the declared viewport. Returns an empty vec when: no viewport - /// has been declared yet; the buffer has no parse view or settled - /// tree; the language has no highlights query; or the scoped span - /// set is byte-identical to the last one shipped at the same - /// generation (the unchanged-frame fast path). + /// Returns up to two messages — an [`InstanceMessage::StyleSpans`] + /// (T M11.2) and an [`InstanceMessage::Decorations`] (T M11.3) — + /// each scoped to the declared viewport and each suppressed + /// independently when byte-identical to its last send at the same + /// generation. Returns an empty vec before the frontend declares a + /// viewport. + /// + /// `InlineAdornments` / `BlockAdornments` / `FoldState` are + /// deliberately *not* produced: pmacs has no instance-side inlay- + /// hint / blame / lens / fold / diff source yet. The wire variants + /// exist (T M11.1); their producers are wired when those features + /// land — the same "declared, not yet wired" discipline M11.1 + /// applied to the whole family. Emitting empty messages every + /// frame would be waste, not honesty. pub fn render_frame(&mut self, state: &EditorState) -> Vec { let Some(vp) = self.viewport.clone() else { // Emit nothing before the frontend declares a viewport. @@ -93,29 +119,186 @@ impl SemanticRenderState { }; let generation = buffer_generation(state, vp.buffer_id); - let spans = scoped_style_spans(state, &vp); + let mut out = Vec::new(); + // --- StyleSpans (T M11.2) --- + let spans = scoped_style_spans(state, &vp); // Unchanged-frame suppression (M11.4 replaces this with // per-span delta encoding). A buffer with an empty scoped set // still suppresses correctly: the first empty frame ships once // (clearing any prior styling on the frontend), subsequent // identical empty frames are squelched. - if let Some((last_gen, last_spans)) = self.last_sent.get(&vp.buffer_id) - && *last_gen == generation - && *last_spans == spans - { - return Vec::new(); + let style_unchanged = self + .last_sent + .get(&vp.buffer_id) + .is_some_and(|(g, s)| *g == generation && *s == spans); + if !style_unchanged { + self.last_sent + .insert(vp.buffer_id, (generation, spans.clone())); + out.push(InstanceMessage::StyleSpans { + buffer_id: vp.buffer_id, + generation, + spans, + }); } - self.last_sent - .insert(vp.buffer_id, (generation, spans.clone())); - vec![InstanceMessage::StyleSpans { - buffer_id: vp.buffer_id, - generation, - spans, - }] + // --- Decorations (T M11.3) --- + let decorations = self.scoped_decorations(state, &vp); + let deco_unchanged = self + .last_decorations + .get(&vp.buffer_id) + .is_some_and(|(g, d)| *g == generation && *d == decorations); + if !deco_unchanged { + self.last_decorations + .insert(vp.buffer_id, (generation, decorations.clone())); + out.push(InstanceMessage::Decorations { + buffer_id: vp.buffer_id, + decorations, + }); + } + + out } + /// Project the [`Decoration`] set intersecting the declared + /// viewport: the session's selection (instance-authoritative, + /// byte-native) and LSP diagnostics (line/col → byte, severity → + /// kind). Search-hit and current-line decorations are + /// deliberately absent: pmacs has no instance-side search-hit + /// store, and current-line is a pure cursor derivation the + /// frontend already owns (it has `CursorByte`) — emitting it would + /// couple a visual-motion concern to the instance, against the + /// contract boundary. + fn scoped_decorations( + &self, + state: &EditorState, + vp: &DeclaredViewport, + ) -> Vec { + let core = state.core.borrow(); + let mut out = Vec::new(); + + // Selection — per-window (per-frontend) state, already byte + // offsets. Only this session's active window for the declared + // buffer contributes. + if let Some(win) = core.active_window_for(self.frontend_id) + && win.buffer_id == vp.buffer_id + && let Some((lo, hi)) = win.region() + && let Some(range) = clip_to_viewport(lo, hi, vp) + { + out.push(Decoration { + range, + kind: DecorationKind::Selection, + }); + } + + // Diagnostics — keyed in the shared store by the file URI the + // Lua LSP glue opened the document under. `core.file_path` is + // the editor's active file path; encoding it with the shared + // `path_to_file_uri` reproduces that exact key (the Lua + // `file_uri_for` is byte-identical). A buffer with no file + // path, or no diagnostics under its URI, contributes nothing. + if let Some(path) = core.file_path.as_ref() { + let uri = crate::lsp::path_to_file_uri(path); + let diags = { + let store = state.lsp_manager.borrow().diag_store(); + let guard = store.lock().expect("diag store mutex poisoned"); + guard.for_uri(&uri).to_vec() + }; + if !diags.is_empty() { + let registry = core.registry.clone(); + let reg = registry.borrow(); + if let Ok(buf) = reg.get(vp.buffer_id) { + let source = buffer_source_bytes(buf); + let line_starts = line_start_offsets(&source); + for d in &diags { + let lo = line_col_to_byte( + &line_starts, + source.len() as u64, + d.start_line, + d.start_col, + ); + let hi = line_col_to_byte( + &line_starts, + source.len() as u64, + d.end_line, + d.end_col, + ); + if let Some(range) = clip_to_viewport(lo, hi, vp) { + out.push(Decoration { + range, + kind: severity_to_kind(d.severity), + }); + } + } + } + } + } + + out + } +} + +/// Intersect `[lo, hi)` with the declared viewport (itself clamped to +/// 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. +fn clip_to_viewport(lo: u64, hi: u64, vp: &DeclaredViewport) -> Option { + let start = lo.max(vp.visible.start); + let end = hi.min(vp.visible.end); + if end <= start { + return None; + } + Some(ByteRange { start, end }) +} + +/// Map an LSP diagnostic severity onto the wire decoration kind. +fn severity_to_kind(sev: crate::diag::DiagnosticSeverity) -> DecorationKind { + use crate::diag::DiagnosticSeverity as S; + match sev { + S::Error => DecorationKind::DiagnosticError, + S::Warning => DecorationKind::DiagnosticWarning, + S::Information => DecorationKind::DiagnosticInfo, + S::Hint => DecorationKind::DiagnosticHint, + } +} + +/// Snapshot a buffer's bytes (refcount-cheap rope slice, mirroring +/// `diag.rs`'s render-time snapshot). +fn buffer_source_bytes(buf: &crate::buffer::Buffer) -> Vec { + let len = buf.len(); + let mut bytes = vec![0u8; len as usize]; + if !bytes.is_empty() { + buf.snapshot_rope().slice(0, len, &mut bytes); + } + bytes +} + +/// Byte offset of the start of each line (index 0 = byte 0; one entry +/// per line, where a line is a maximal run ended by `\n`). +fn line_start_offsets(source: &[u8]) -> Vec { + let mut starts = vec![0u64]; + for (i, b) in source.iter().enumerate() { + if *b == b'\n' { + starts.push(i as u64 + 1); + } + } + starts +} + +/// Translate an LSP `(line, col)` to a byte offset. pmacs v0.1 treats +/// the LSP column as a byte offset within the line (see +/// `crate::diag::Diagnostic`'s field docs); we clamp to the line's +/// end and the source length so a stale diagnostic from before an +/// edit can never index out of range. +fn line_col_to_byte(line_starts: &[u64], source_len: u64, line: u32, col: u32) -> u64 { + let li = line as usize; + let Some(&line_start) = line_starts.get(li) else { + return source_len; + }; + let line_end = line_starts + .get(li + 1) + .map_or(source_len, |&next| next.saturating_sub(1)); + (line_start + u64::from(col)).min(line_end).min(source_len) } /// Compute the styled byte runs intersecting the declared viewport, @@ -213,87 +396,213 @@ mod tests { EditorState::new() } + fn local() -> SemanticRenderState { + // FrontendId::LOCAL always has a registered FrontendView + // (EditorCore invariant), so `active_window_for(LOCAL)` — the + // selection projection's lookup — resolves in a fresh editor. + SemanticRenderState::new(FrontendId::LOCAL) + } + + fn active_buffer(state: &EditorState) -> BufferId { + state.core.borrow().active_window().buffer_id + } + + /// All `InstanceMessage` variants the semantic projection may + /// emit are `StyleSpans` or `Decorations` — never `CellDelta`, + /// grid `Cursor`, or the not-yet-wired adornment/fold families. + fn assert_semantic_only(msgs: &[InstanceMessage]) { + for m in msgs { + assert!( + matches!( + m, + InstanceMessage::StyleSpans { .. } | InstanceMessage::Decorations { .. } + ), + "semantic projection emitted an unexpected variant: {m:?}" + ); + } + } + #[test] fn emits_nothing_before_viewport_declared() { - let mut s = SemanticRenderState::new(); + let mut s = local(); assert!( s.render_frame(&empty_state()).is_empty(), - "no StyleSpans may be emitted before the frontend declares a viewport" + "nothing may be emitted before the frontend declares a viewport" ); } #[test] - fn after_viewport_emits_style_spans_message_then_suppresses() { + fn first_post_viewport_frame_ships_styles_and_decorations_then_suppresses() { let state = empty_state(); - let mut s = SemanticRenderState::new(); - // Pick whatever buffer the fresh editor's active window holds. - let buffer_id = { - let core = state.core.borrow(); - core.active_window().buffer_id - }; + let mut s = local(); + let buffer_id = active_buffer(&state); s.set_viewport(buffer_id, ByteRange { start: 0, end: 4096 }, 0); + // Empty scratch buffer: no syntax spans, no selection, no + // diagnostics — but the first frame ships both messages once + // (each clears any prior frontend state), carrying empty sets. let first = s.render_frame(&state); - assert_eq!(first.len(), 1, "first post-viewport frame emits once"); - match &first[0] { - InstanceMessage::StyleSpans { - buffer_id: b, - generation, - .. - } => { - assert_eq!(*b, buffer_id); - assert_eq!(*generation, 0, "fresh scratch buffer has generation 0"); - } - other => panic!("expected StyleSpans, got {other:?}"), - } + assert_eq!(first.len(), 2, "first frame ships StyleSpans + Decorations"); + assert_semantic_only(&first); + let has = |pred: fn(&InstanceMessage) -> bool| first.iter().any(pred); + assert!(has(|m| matches!(m, InstanceMessage::StyleSpans { generation: 0, .. }))); + assert!(has(|m| matches!(m, InstanceMessage::Decorations { .. }))); - // Nothing changed → byte-identical frame is suppressed. + // Nothing changed → both families suppressed. assert!( s.render_frame(&state).is_empty(), - "an unchanged frame must be suppressed" + "an unchanged frame must be fully suppressed" ); } #[test] - fn viewport_with_zero_width_range_yields_empty_span_set() { + fn selection_projects_as_a_decoration_clipped_to_viewport() { let state = empty_state(); - let mut s = SemanticRenderState::new(); - let buffer_id = { - let core = state.core.borrow(); - core.active_window().buffer_id - }; - // Degenerate viewport: end <= start after clamping. - s.set_viewport(buffer_id, ByteRange { start: 10, end: 10 }, 0); - let msgs = s.render_frame(&state); - // First frame still ships once (clears any prior styling), - // carrying an empty span set. - assert_eq!(msgs.len(), 1); - match &msgs[0] { - InstanceMessage::StyleSpans { spans, .. } => assert!(spans.is_empty()), - other => panic!("expected StyleSpans, got {other:?}"), + let buffer_id = active_buffer(&state); + // Put a selection on LOCAL's active window: anchor 2, cursor + // 5 → region (2, 5). region() compares offsets only, so the + // empty scratch buffer is fine for this projection test. + { + let mut core = state.core.borrow_mut(); + let win = core + .active_window_mut_for(FrontendId::LOCAL) + .expect("LOCAL always has a window"); + win.selection = Some(crate::window::Selection { anchor: 2 }); + win.cursor = 5; } + let mut s = local(); + s.set_viewport(buffer_id, ByteRange { start: 3, end: 64 }, 0); + + let msgs = s.render_frame(&state); + assert_semantic_only(&msgs); + let deco = msgs + .iter() + .find_map(|m| match m { + InstanceMessage::Decorations { decorations, .. } => Some(decorations), + _ => None, + }) + .expect("a Decorations message"); + assert_eq!(deco.len(), 1, "exactly the selection decoration"); + assert_eq!(deco[0].kind, DecorationKind::Selection); + // region (2,5) clipped to viewport [3,64) → [3,5). + assert_eq!(deco[0].range, ByteRange { start: 3, end: 5 }); } #[test] - fn semantic_state_default_constructs() { - // The dispatcher relies on `Default`/`new` parity. - let _ = SemanticRenderState::default(); - let _ = SemanticRenderState::new(); + fn diagnostics_project_with_line_col_to_byte_and_severity() { + // Buffer with two short lines so line/col → byte is exercised. + // "abc\nde" → line 0 starts at byte 0, line 1 at byte 4. + let state = empty_state(); + let buffer_id = active_buffer(&state); + { + let mut core = state.core.borrow_mut(); + let reg = core.registry.clone(); + reg.borrow_mut() + .get_mut(buffer_id) + .expect("active buffer") + .apply_edit(crate::buffer::EditOp::Insert { + pos: 0, + bytes: b"abc\nde", + }) + .expect("seed buffer text"); + // The diag store is keyed by file URI; point the editor's + // active file path at one and seed a diagnostic there. + core.file_path = Some(std::path::PathBuf::from("/tmp/m113.rs")); + } + let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m113.rs")); + { + let store = state.lsp_manager.borrow().diag_store(); + let mut g = store.lock().expect("diag store"); + g.set( + &uri, + vec![crate::diag::Diagnostic { + start_line: 1, + start_col: 0, + end_line: 1, + end_col: 2, + severity: crate::diag::DiagnosticSeverity::Warning, + message: "x".into(), + source: None, + code: None, + }], + ); + } + let mut s = local(); + s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); + + let msgs = s.render_frame(&state); + let deco = msgs + .iter() + .find_map(|m| match m { + InstanceMessage::Decorations { decorations, .. } => Some(decorations), + _ => None, + }) + .expect("a Decorations message"); + assert_eq!(deco.len(), 1); + assert_eq!(deco[0].kind, DecorationKind::DiagnosticWarning); + // line 1 starts at byte 4; cols [0,2) → bytes [4,6). + assert_eq!(deco[0].range, ByteRange { start: 4, end: 6 }); + } + + #[test] + fn styles_and_decorations_suppress_independently() { + // A selection change must re-send Decorations without forcing + // a StyleSpans re-send (and the empty scratch styling stays + // suppressed). + let state = empty_state(); + let buffer_id = active_buffer(&state); + let mut s = local(); + s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); + let _ = s.render_frame(&state); // first frame: both shipped + assert!(s.render_frame(&state).is_empty(), "steady state silent"); + + // Introduce a selection → only Decorations should re-emit. + { + let mut core = state.core.borrow_mut(); + let win = core + .active_window_mut_for(FrontendId::LOCAL) + .expect("LOCAL window"); + win.selection = Some(crate::window::Selection { anchor: 1 }); + win.cursor = 4; + } + let msgs = s.render_frame(&state); + assert_eq!(msgs.len(), 1, "only the changed family re-emits"); + assert!(matches!(msgs[0], InstanceMessage::Decorations { .. })); + } + + #[test] + fn adornment_and_fold_families_are_never_emitted() { + // M11.3 honest-stub contract: InlineAdornments / BlockAdornments + // / FoldState have no instance-side source yet, so the + // projection never produces them (not even empty ones). + let state = empty_state(); + let buffer_id = active_buffer(&state); + let mut s = local(); + s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); + for _ in 0..3 { + for m in s.render_frame(&state) { + assert!( + !matches!( + m, + InstanceMessage::InlineAdornments { .. } + | InstanceMessage::BlockAdornments { .. } + | InstanceMessage::FoldState { .. } + ), + "a not-yet-wired adornment/fold family was emitted: {m:?}" + ); + } + } } #[test] fn sibling_of_render_state_reads_same_editor_state() { - // Documents the M11.2 contract: a grid RenderState and a - // SemanticRenderState observe the same EditorState without - // interfering — the dispatcher selects the projection per - // session, not per buffer. + // The dispatcher selects the projection per session, not per + // buffer: a grid RenderState and a SemanticRenderState observe + // the same EditorState without interfering. let state = empty_state(); let mut grid = RenderState::new(CellSize::new(24, 80)); - let mut sem = SemanticRenderState::new(); - let buffer_id = { - let core = state.core.borrow(); - core.active_window().buffer_id - }; + let mut sem = local(); + let buffer_id = active_buffer(&state); sem.set_viewport(buffer_id, ByteRange { start: 0, end: 80 }, 0); let grid_msgs = grid.render_frame(&state, &[]); @@ -302,12 +611,12 @@ mod tests { matches!(grid_msgs[0], InstanceMessage::CellDelta { .. }), "grid projection still produces CellDelta" ); + assert_semantic_only(&sem_msgs); assert!( - sem_msgs + !sem_msgs .iter() - .all(|m| matches!(m, InstanceMessage::StyleSpans { .. })), - "semantic projection produces only StyleSpans, never CellDelta" + .any(|m| matches!(m, InstanceMessage::CellDelta { .. })), + "semantic projection never produces CellDelta" ); - let _ = FrontendId::LOCAL; // import anchor for future fid-scoped tests } }