// semantic_render.rs --- Instance-side semantic projection (T M11.2). //! The semantic projection seam. //! //! [`crate::instance_render::RenderState`] rasterizes the editor to a //! cell grid and ships [`InstanceMessage::CellDelta`]. `SemanticRenderState` //! is its sibling for `semantic_render` sessions: it reads the same //! [`EditorState`] but exits the pipeline *earlier* — it emits the //! structured byte-range styling the cell painter would otherwise have //! consumed (tree-sitter spans from [`crate::syntax`] mapped through //! the active [`crate::highlight::Theme`]), without the grid-packing //! step. The frontend lays the styling out locally over rope text it //! already holds via its `crdt_replica` `BufferMirror`. //! //! Contract boundary (see `docs/semantic-frontend-protocol.md`): the //! instance never learns a pixel. The only spatial fact it consumes is //! the buffer byte range the frontend declared on screen via //! [`crate::protocol::FrontendEvent::Viewport`]; styling is scoped to //! that range so a 100k-line file's styling is never shipped wholesale. //! //! M11.2 scope: `StyleSpans` only. `Decorations` / `InlineAdornments` / //! `BlockAdornments` / `FoldState` / `ResourceOffer` are M11.3; true //! span-granularity diffing (this module currently suppresses only //! byte-identical frames) is M11.4. use std::collections::HashMap; use crate::buffer::BufferId; use crate::cell::Style; use crate::editor::EditorState; use crate::protocol::{ ByteRange, Decoration, DecorationKind, DecorationSegment, FrontendId, InstanceMessage, StyleSegment, StyleSpan, }; /// The viewport a `semantic_render` frontend last declared. #[derive(Clone, Debug, Eq, PartialEq)] struct DeclaredViewport { buffer_id: BufferId, visible: ByteRange, /// The CRDT generation the frontend computed `visible` against. /// Recorded for the M11.4 "ignore a viewport that races a /// not-yet-applied edit" refinement; M11.2 always honors the most /// recent declaration verbatim. frontend_generation: u64, } /// The diff baseline for one family on one buffer: the /// declared-viewport region the set was computed for, and the full /// scoped item set last shipped. The next frame diffs against /// `items`; `visible` changing (or no entry) forces a `full` resync. /// The frame's `generation` is recomputed each tick and carried on /// the wire, so it is not retained here. struct LastFrame { visible: ByteRange, items: Vec, } /// Owns one `semantic_render` session's projection state: the last /// viewport the frontend declared, and the diff baseline per buffer /// for the `StyleSpans` and `Decorations` families. 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 /// screen, and only then receives styling for exactly that range. viewport: Option, /// Styling diff baseline, keyed by buffer (T M11.4). An unchanged /// frame ships nothing; a changed frame ships only the dirty /// byte-range segments. last_sent: HashMap>, /// Decorations diff baseline, 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 for frontend `frontend_id`: no viewport /// declared, nothing sent. #[must_use] 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 /// the dispatcher when it receives /// [`crate::protocol::FrontendEvent::Viewport`]. Replaces any /// prior declaration wholesale — the latest viewport wins. pub fn set_viewport(&mut self, buffer_id: BufferId, visible: ByteRange, generation: u64) { self.viewport = Some(DeclaredViewport { buffer_id, visible, frontend_generation: generation, }); } /// Project one frame. /// /// 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. return Vec::new(); }; let generation = buffer_generation(state, vp.buffer_id); let mut out = Vec::new(); // --- StyleSpans (T M11.2 producer, T M11.4 diff) --- let spans = scoped_style_spans(state, &vp); let prev = self.last_sent.get(&vp.buffer_id); // Resync when there is no baseline, or the declared viewport // region moved (the scoping window changed, so prior styling // is no longer positioned correctly). let full = prev.is_none_or(|p| p.visible != vp.visible); if full { // The first frame for this buffer/viewport. One segment // covering the declared viewport carries the whole scoped // set (possibly empty → frontend clears the viewport). self.last_sent.insert( vp.buffer_id, LastFrame { visible: vp.visible, items: spans.clone(), }, ); out.push(InstanceMessage::StyleSpans { buffer_id: vp.buffer_id, generation, full: true, segments: vec![StyleSegment { range: vp.visible, spans, }], }); } else { let prev = prev.expect("checked is_none_or above"); let intervals = changed_intervals(&prev.items, &spans, |s| s.range); if !intervals.is_empty() { let segments = intervals .into_iter() .map(|range| StyleSegment { range, spans: clip_style_spans(range, &spans), }) .collect(); self.last_sent.insert( vp.buffer_id, LastFrame { visible: vp.visible, items: spans, }, ); out.push(InstanceMessage::StyleSpans { buffer_id: vp.buffer_id, generation, full: false, segments, }); } // No dirty interval → styling unchanged → emit nothing. } // --- Decorations (T M11.3 producer, T M11.4 diff) --- let decorations = self.scoped_decorations(state, &vp); let prev = self.last_decorations.get(&vp.buffer_id); let full = prev.is_none_or(|p| p.visible != vp.visible); if full { self.last_decorations.insert( vp.buffer_id, LastFrame { visible: vp.visible, items: decorations.clone(), }, ); out.push(InstanceMessage::Decorations { buffer_id: vp.buffer_id, generation, full: true, segments: vec![DecorationSegment { range: vp.visible, decorations, }], }); } else { let prev = prev.expect("checked is_none_or above"); let intervals = changed_intervals(&prev.items, &decorations, |d| d.range); if !intervals.is_empty() { let segments = intervals .into_iter() .map(|range| DecorationSegment { range, decorations: clip_decorations(range, &decorations), }) .collect(); self.last_decorations.insert( vp.buffer_id, LastFrame { visible: vp.visible, items: decorations, }, ); out.push(InstanceMessage::Decorations { buffer_id: vp.buffer_id, generation, full: false, segments, }); } } 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.active_buffer_path() { 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 }) } /// T M11.4 — the dirty byte intervals between two ordered item sets. /// /// Items are byte-anchored (`range_of` extracts the range). The /// symmetric difference (items in exactly one set, by `==`) bounds /// every byte whose covering set changed; its ranges are coalesced /// into maximal disjoint intervals — the segments the frontend will /// clear and repaint. Empty result ⇒ unchanged ⇒ the caller emits /// nothing. /// /// O(n·m) membership scans: a screenful is a few hundred items, far /// cheaper than re-shipping the whole viewport every frame, and only /// runs when the fast `prev == curr` slice check (caller side, via /// the order-stable producers) would have failed anyway. fn changed_intervals( prev: &[T], curr: &[T], range_of: impl Fn(&T) -> ByteRange, ) -> Vec { let mut changed: Vec = Vec::new(); for p in prev { if !curr.contains(p) { changed.push(range_of(p)); } } for c in curr { if !prev.contains(c) { changed.push(range_of(c)); } } coalesce_ranges(&mut changed) } /// Sort and merge overlapping or touching ranges into maximal /// disjoint intervals. Zero-width ranges are dropped (nothing to /// repaint). Consumes `ranges` (sorts in place). fn coalesce_ranges(ranges: &mut Vec) -> Vec { ranges.retain(|r| r.end > r.start); ranges.sort_by_key(|r| (r.start, r.end)); let mut out: Vec = Vec::new(); for r in ranges.iter().copied() { match out.last_mut() { // Touching (`>=`) merges too: adjacent dirty ranges become // one segment rather than two abutting clears. Some(last) if r.start <= last.end => last.end = last.end.max(r.end), _ => out.push(r), } } out } /// Every span intersecting `iv`, clipped to it, order preserved. fn clip_style_spans(iv: ByteRange, spans: &[StyleSpan]) -> Vec { spans .iter() .filter_map(|s| { let start = s.range.start.max(iv.start); let end = s.range.end.min(iv.end); (end > start).then_some(StyleSpan { range: ByteRange { start, end }, style: s.style, }) }) .collect() } /// Every decoration intersecting `iv`, clipped to it, order preserved. fn clip_decorations(iv: ByteRange, decos: &[Decoration]) -> Vec { decos .iter() .filter_map(|d| { let start = d.range.start.max(iv.start); let end = d.range.end.min(iv.end); (end > start).then_some(Decoration { range: ByteRange { start, end }, kind: d.kind, }) }) .collect() } /// 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, /// mapped through the active theme. Spans are clipped to the viewport /// and to the parsed source length; runs that resolve to the default /// style are dropped (wire economy, and consistent with the grid /// path, which skips default-style merges). fn scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec { let Some(handle) = state.syntax_registry.view(vp.buffer_id) else { return Vec::new(); }; let Some(bundle) = handle.current() else { return Vec::new(); }; let Some(query) = state .syntax_registry .highlights_query(&bundle.language_name) else { return Vec::new(); }; let theme = state .syntax_registry .theme() .lock() .expect("theme mutex poisoned") .clone(); let source_len = bundle.source.len() as u64; let vis_start = vp.visible.start.min(source_len); let vis_end = vp.visible.end.min(source_len); if vis_end <= vis_start { return Vec::new(); } let capture_names = query.capture_names(); let highlights = crate::syntax::compute_highlight_spans(&query, &bundle); let mut out = Vec::new(); for hs in highlights { let s = u64::from(hs.start_byte).max(vis_start); let e = u64::from(hs.end_byte).min(vis_end); if e <= s { continue; // No overlap with the viewport. } let Some(name) = capture_names.get(hs.capture_index as usize) else { continue; }; let style = theme.lookup(name); if style == Style::default() { continue; // Nothing to render — skip the wire byte. } out.push(StyleSpan { range: ByteRange { start: s, end: e }, style, }); } out } /// 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 /// negotiates `crdt_replica`, so in practice the buffer is CRDT-backed /// before any semantic frame is produced; the fallback keeps this /// total). #[cfg(feature = "crdt")] fn buffer_generation(state: &EditorState, buffer_id: BufferId) -> u64 { let core = state.core.borrow(); let registry = core.registry.clone(); let reg = registry.borrow(); reg.get(buffer_id) .ok() .and_then(crate::buffer::Buffer::crdt_state) .map_or(0, crate::crdt::CrdtState::version_scalar) } /// Non-CRDT builds cannot host a semantic session (the negotiation /// dependency rule requires `crdt_replica`, gated on the `crdt` /// feature), so this is never reached with a live viewport; it exists /// only to keep `render_frame` total across feature flavors. #[cfg(not(feature = "crdt"))] #[allow(clippy::missing_const_for_fn)] fn buffer_generation(_state: &EditorState, _buffer_id: BufferId) -> u64 { 0 } #[cfg(test)] mod tests { use super::*; use crate::cell::CellSize; use crate::editor::EditorState; use crate::instance_render::RenderState; use crate::protocol::FrontendId; fn empty_state() -> EditorState { 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:?}" ); } } /// Find the `Decorations` message and flatten its segments into /// `(full, all decorations across segments)`. fn decorations_of(msgs: &[InstanceMessage]) -> Option<(bool, Vec)> { msgs.iter().find_map(|m| match m { InstanceMessage::Decorations { full, segments, .. } => Some(( *full, segments .iter() .flat_map(|s| s.decorations.clone()) .collect(), )), _ => None, }) } /// Find the `StyleSpans` message: `(full, segment ranges)`. fn style_segments(msgs: &[InstanceMessage]) -> Option<(bool, Vec)> { msgs.iter().find_map(|m| match m { InstanceMessage::StyleSpans { full, segments, .. } => { Some((*full, segments.iter().map(|s| s.range).collect())) } _ => None, }) } fn set_selection(state: &EditorState, anchor: u64, cursor: u64) { 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 }); win.cursor = cursor; } fn seed_diagnostic(state: &EditorState, buffer_id: BufferId) { let mut core = state.core.borrow_mut(); core.registry .clone() .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"); core.set_buffer_path(buffer_id, Some(std::path::PathBuf::from("/tmp/m114.rs"))); drop(core); let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m114.rs")); let store = state.lsp_manager.borrow().diag_store(); store.lock().expect("diag store").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, }], ); } #[test] fn emits_nothing_before_viewport_declared() { let mut s = local(); assert!( s.render_frame(&empty_state()).is_empty(), "nothing may be emitted before the frontend declares a viewport" ); } #[test] fn first_post_viewport_frame_is_full_for_both_then_suppresses() { let state = empty_state(); let mut s = local(); let buffer_id = active_buffer(&state); s.set_viewport( buffer_id, ByteRange { start: 0, end: 4096, }, 0, ); // Empty scratch: no spans, no selection, no diagnostics — but // the first frame is a `full` resync for both families (the // frontend clears its viewport), carrying empty segments. let first = s.render_frame(&state); assert_eq!(first.len(), 2, "first frame ships StyleSpans + Decorations"); assert_semantic_only(&first); let (style_full, _) = style_segments(&first).expect("StyleSpans present"); let (deco_full, decos) = decorations_of(&first).expect("Decorations present"); assert!(style_full, "first styling frame must be full"); assert!(deco_full, "first decorations frame must be full"); assert!(decos.is_empty(), "empty scratch has no decorations"); // Nothing changed → both families suppressed. assert!( s.render_frame(&state).is_empty(), "an unchanged frame must be fully suppressed" ); } #[test] fn selection_projects_as_a_decoration_clipped_to_viewport() { let state = empty_state(); let buffer_id = active_buffer(&state); // region (2,5) on LOCAL's window; region() compares offsets // only, so the empty scratch buffer is fine here. set_selection(&state, 2, 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 (full, decos) = decorations_of(&msgs).expect("a Decorations message"); assert!(full, "first frame is a full resync"); assert_eq!(decos.len(), 1, "exactly the selection decoration"); assert_eq!(decos[0].kind, DecorationKind::Selection); // region (2,5) clipped to viewport [3,64) → [3,5). assert_eq!(decos[0].range, ByteRange { start: 3, end: 5 }); } #[test] fn diagnostics_project_with_line_col_to_byte_and_severity() { // "abc\nde": line 0 at byte 0, line 1 at byte 4. let state = empty_state(); let buffer_id = active_buffer(&state); seed_diagnostic(&state, buffer_id); let mut s = local(); s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); let (_full, decos) = decorations_of(&s.render_frame(&state)).expect("a Decorations message"); assert_eq!(decos.len(), 1); assert_eq!(decos[0].kind, DecorationKind::DiagnosticWarning); // line 1 starts at byte 4; cols [0,2) → bytes [4,6). assert_eq!(decos[0].range, ByteRange { start: 4, end: 6 }); } #[test] fn styles_and_decorations_suppress_independently() { 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 full assert!(s.render_frame(&state).is_empty(), "steady state silent"); // A selection appears → only Decorations re-emits, and as an // incremental (full = false) frame since the viewport region // did not move. set_selection(&state, 1, 4); let msgs = s.render_frame(&state); assert_eq!(msgs.len(), 1, "only the changed family re-emits"); let (full, decos) = decorations_of(&msgs).expect("Decorations re-emitted"); assert!(!full, "viewport unchanged → incremental, not full"); assert_eq!(decos.len(), 1); assert_eq!(decos[0].kind, DecorationKind::Selection); } #[test] fn full_resync_on_viewport_region_change() { 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); // full assert!(s.render_frame(&state).is_empty(), "unchanged → silent"); // Declaring a different on-screen range forces a full resync: // prior styling/decorations are positioned for the old window. s.set_viewport( buffer_id, ByteRange { start: 200, end: 264, }, 0, ); let msgs = s.render_frame(&state); let (style_full, _) = style_segments(&msgs).expect("StyleSpans"); let (deco_full, _) = decorations_of(&msgs).expect("Decorations"); assert!( style_full && deco_full, "viewport jump must be a full resync" ); } #[test] fn incremental_decoration_change_ships_only_dirty_intervals() { let state = empty_state(); let buffer_id = active_buffer(&state); let mut s = local(); s.set_viewport(buffer_id, ByteRange { start: 0, end: 256 }, 0); set_selection(&state, 10, 12); let _ = s.render_frame(&state); // full: selection [10,12) assert!(s.render_frame(&state).is_empty()); // Move the selection far away. The symmetric difference is the // old range [10,12) (removed) and the new [40,42) (added); // they are disjoint and non-adjacent → two segments. set_selection(&state, 40, 42); let msgs = s.render_frame(&state); let deco_msg = msgs .iter() .find_map(|m| match m { InstanceMessage::Decorations { full, segments, .. } => Some((*full, segments)), _ => None, }) .expect("Decorations"); assert!(!deco_msg.0, "incremental"); let ranges: Vec = deco_msg.1.iter().map(|s| s.range).collect(); assert_eq!( ranges, vec![ ByteRange { start: 10, end: 12 }, ByteRange { start: 40, end: 42 } ], "two disjoint dirty intervals: old (cleared) + new" ); // The [10,12) segment carries no decorations (selection moved // away → frontend clears it); [40,42) carries the new one. let s1 = &deco_msg.1[0]; assert!(s1.decorations.is_empty(), "old selection range cleared"); let s2 = &deco_msg.1[1]; assert_eq!(s2.decorations.len(), 1); assert_eq!(s2.decorations[0].kind, DecorationKind::Selection); } #[test] fn unchanged_decoration_overlapping_a_dirty_interval_is_reconstructed() { // A diagnostic at [4,6) never changes; the selection moves to // overlap it. The dirty segment must still carry the (clipped) // diagnostic so the frontend, replacing styling within the // range, faithfully reconstructs the unchanged decoration. let state = empty_state(); let buffer_id = active_buffer(&state); seed_diagnostic(&state, buffer_id); // Warning [4,6) let mut s = local(); s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); set_selection(&state, 20, 22); let _ = s.render_frame(&state); // full: Sel[20,22) + Warn[4,6) assert!(s.render_frame(&state).is_empty()); // Selection moves to [5,7), overlapping the diagnostic. set_selection(&state, 5, 7); let msgs = s.render_frame(&state); let (_full, segs) = msgs .iter() .find_map(|m| match m { InstanceMessage::Decorations { full, segments, .. } => Some((*full, segments)), _ => None, }) .expect("Decorations"); // The segment covering [5,7) must include the unchanged, // overlapping diagnostic (clipped into the dirty range), // not just the moved selection. let overlapping = segs .iter() .find(|s| s.range.start <= 5 && s.range.end >= 6) .expect("a segment covering the diagnostic's bytes"); assert!( overlapping .decorations .iter() .any(|d| d.kind == DecorationKind::DiagnosticWarning), "unchanged overlapping diagnostic must be reconstructed in the dirty segment" ); } #[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() { // 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 = 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, &[]); let sem_msgs = sem.render_frame(&state); assert!( matches!(grid_msgs[0], InstanceMessage::CellDelta { .. }), "grid projection still produces CellDelta" ); assert_semantic_only(&sem_msgs); assert!( !sem_msgs .iter() .any(|m| matches!(m, InstanceMessage::CellDelta { .. })), "semantic projection never produces CellDelta" ); } }