// semantic_client.rs --- Headless consumer of the SemanticFrame wire (T M11.5). //! The frontend↔instance glue for the semantic projection. //! //! `docs/semantic-frontend-protocol.md` deliberately moves rendering //! correctness (shaping, wrap, hit-testing) into a GPU frontend the //! instance test harness cannot exercise, and bounds the *testable* //! surface to "the frontend↔instance glue, not all rendering." //! `SemanticClient` is exactly that glue, made headless and //! self-contained: no terminal, no GPU, no pixels. //! //! It composes the two replica layers a `semantic_render` session //! needs: //! //! - [`BufferMirror`] — the rope replica (M10.10). The semantic frame //! ships *no text*; the client holds the document locally via //! `BufferSnapshot` + `CrdtOp`, exactly as the grid TUI does. //! - A [`SemanticModel`] per family — the *interpretation* layer: //! byte-anchored styling / decorations, reconstructed from the //! `full` + dirty-segment deltas (M11.4). //! //! The client also produces the one frontend→instance message the //! protocol adds — [`FrontendEvent::Viewport`] — declaring the byte //! range it has "on screen" so the instance scopes its projection. //! //! Read-back accessors (`text`, `effective_style_at`, //! `decoration_kinds_at`) exist so a test can assert the //! reconstruction equals the instance's intent — the //! "reconstruction-equivalence" golden discipline (no snapshot crate; //! matches the repo's explicit-assertion style). use std::collections::HashMap; use crate::buffer::BufferId; use crate::buffer_mirror::BufferMirror; use crate::cell::Style; use crate::overlay::merge_styles; use crate::protocol::{ ByteRange, Decoration, DecorationKind, DecorationSegment, FrontendEvent, FrontendId, InstanceMessage, StyleSegment, StyleSpan, }; /// An item the model can restrict to a sub-range. `range` is where it /// applies; `clipped` is the item narrowed to `bounds` (or `None` /// when disjoint). The semantic frame's items are byte-anchored, so /// both families implement this uniformly. trait Clip: Clone { fn range(&self) -> ByteRange; fn clipped(&self, bounds: ByteRange) -> Option; } fn intersect(a: ByteRange, b: ByteRange) -> Option { let start = a.start.max(b.start); let end = a.end.min(b.end); (end > start).then_some(ByteRange { start, end }) } impl Clip for StyleSpan { fn range(&self) -> ByteRange { self.range } fn clipped(&self, bounds: ByteRange) -> Option { intersect(self.range, bounds).map(|range| Self { range, style: self.style, }) } } impl Clip for Decoration { fn range(&self) -> ByteRange { self.range } fn clipped(&self, bounds: ByteRange) -> Option { intersect(self.range, bounds).map(|range| Self { range, kind: self.kind, }) } } /// One reconstructed dirty region. The M11.4 contract — *each segment /// carries every current item intersecting its range* — makes a tile /// self-contained: rendering any byte in `range` consults only this /// tile's `items`, never a neighbour's. That is what lets incremental /// application be a clean per-tile replacement instead of fragile /// cross-span surgery. #[derive(Clone, Debug, Eq, PartialEq)] struct Tile { range: ByteRange, items: Vec, } /// One family's reconstructed view of one buffer: disjoint tiles /// ordered by start. Bytes covered by no tile have no styling / /// decoration (default), exactly as the instance intends for regions /// outside the declared viewport. struct SemanticModel { tiles: Vec>, } // Manual `Default` — the derive would wrongly require `T: Default` // (a `StyleSpan`/`Decoration` has no meaningful default); an empty // model is just no tiles regardless of `T`. impl Default for SemanticModel { fn default() -> Self { Self { tiles: Vec::new() } } } impl SemanticModel { /// Apply one frame. `full` discards everything first (resync); /// otherwise each segment replaces only its own byte range — /// tiles straddling a segment are split, keeping the parts /// outside it (clipped), and the segment's items become the new /// tile for the region. fn apply(&mut self, full: bool, segments: &[(ByteRange, Vec)]) { if full { self.tiles = segments .iter() .map(|(range, items)| Tile { range: *range, items: items.clone(), }) .collect(); } else { for (range, items) in segments { self.replace_region(*range, items.clone()); } } self.tiles.sort_by_key(|t| (t.range.start, t.range.end)); } fn replace_region(&mut self, region: ByteRange, items: Vec) { let mut next: Vec> = Vec::with_capacity(self.tiles.len() + 1); for t in std::mem::take(&mut self.tiles) { if intersect(t.range, region).is_none() { next.push(t); continue; } // Keep the parts of `t` outside `region`, each carrying // only the items that survive the narrower range. if t.range.start < region.start { let left = ByteRange { start: t.range.start, end: region.start, }; next.push(Tile { range: left, items: t.items.iter().filter_map(|i| i.clipped(left)).collect(), }); } if t.range.end > region.end { let right = ByteRange { start: region.end, end: t.range.end, }; next.push(Tile { range: right, items: t.items.iter().filter_map(|i| i.clipped(right)).collect(), }); } // The overlapped middle is dropped — `items` re-supplies it. } next.push(Tile { range: region, items, }); self.tiles = next; } /// Items covering `byte`, in instance order (the order they were /// shipped — wider-first for styling, so a fold via /// [`merge_styles`] reproduces the grid path's layering). fn items_at(&self, byte: u64) -> impl Iterator { self.tiles .iter() .find(|t| t.range.start <= byte && byte < t.range.end) .into_iter() .flat_map(move |t| { t.items .iter() .filter(move |i| i.range().start <= byte && byte < i.range().end) }) } fn tile_ranges(&self) -> Vec { self.tiles.iter().map(|t| t.range).collect() } } /// A headless `semantic_render` session: rope replica + the styling /// and decoration interpretation layers, plus the `Viewport` event it /// emits. Drive it by feeding every [`InstanceMessage`] through /// [`Self::apply`]; read it back through the accessors. pub struct SemanticClient { frontend_id: FrontendId, mirror: BufferMirror, styles: HashMap>, decos: HashMap>, } impl SemanticClient { /// Construct a client for the session assigned `frontend_id` /// (the id the daemon stamped in `Hello`). #[must_use] pub fn new(frontend_id: FrontendId) -> Self { Self { frontend_id, mirror: BufferMirror::new(frontend_id), styles: HashMap::new(), decos: HashMap::new(), } } /// The session's assigned frontend id. #[must_use] pub fn frontend_id(&self) -> FrontendId { self.frontend_id } /// Build the [`FrontendEvent::Viewport`] declaring `visible` for /// `buffer_id`. The caller writes it to the daemon; the instance /// scopes its projection to this range. `generation` is the CRDT /// version the frontend computed the range against (M11.4 records /// it for the future viewport-race refinement). #[must_use] pub fn viewport_event( &self, buffer_id: BufferId, visible: ByteRange, generation: u64, ) -> FrontendEvent { FrontendEvent::Viewport { frontend_id: self.frontend_id, buffer_id, visible, generation, } } /// Route one instance message into the replica/interpretation /// layers. Unrelated variants (grid `CellDelta`/`Cursor`, /// presence, and the not-yet-produced adornment/fold/resource /// families) are ignored — a semantic session lays out locally /// and never consumes the grid projection. pub fn apply(&mut self, msg: &InstanceMessage) { match msg { InstanceMessage::BufferSnapshot { buffer_id, crdt_snapshot, } => { // `AlreadyInitialized` means a duplicate bootstrap for // a buffer we already mirror — benign for a consumer. let _ = self.mirror.init_from_snapshot(*buffer_id, crdt_snapshot); } InstanceMessage::CrdtOp { buffer_id, op } => { // A pure consumer never edits, so it is never the // op's source — no echo to filter (the daemon also // excludes the sender). Drop a non-applying op // silently, as the test Observer does. let _ = self.mirror.apply_remote_op(*buffer_id, &op.bytes); } InstanceMessage::CursorByte { buffer_id, byte_pos, } => { self.mirror .set_cursor_byte_pos(*buffer_id, *byte_pos as usize); } InstanceMessage::StyleSpans { buffer_id, full, segments, .. } => { let segs: Vec<(ByteRange, Vec)> = segments .iter() .map(|s: &StyleSegment| (s.range, s.spans.clone())) .collect(); self.styles .entry(*buffer_id) .or_default() .apply(*full, &segs); } InstanceMessage::Decorations { buffer_id, full, segments, .. } => { let segs: Vec<(ByteRange, Vec)> = segments .iter() .map(|s: &DecorationSegment| (s.range, s.decorations.clone())) .collect(); self.decos .entry(*buffer_id) .or_default() .apply(*full, &segs); } // Grid projection, presence, and the honest-stub families // (InlineAdornments / BlockAdornments / FoldState / // ResourceOffer) — a semantic session does not consume // these. ModeLine / Signal / Goodbye are session control, // handled by the attach loop, not the model. _ => {} } } /// The reconstructed document text for `buffer_id` (the rope /// replica materialized), or `None` if not yet bootstrapped. #[must_use] pub fn text(&self, buffer_id: BufferId) -> Option { self.mirror.materialize(buffer_id) } /// Whether the rope replica for `buffer_id` has been bootstrapped. #[must_use] pub fn is_ready(&self, buffer_id: BufferId) -> bool { self.mirror.is_ready(buffer_id) } /// The cursor byte position the instance last reported. #[must_use] pub fn cursor_byte_pos(&self, buffer_id: BufferId) -> Option { self.mirror.cursor_byte_pos(buffer_id) } /// The effective style at `byte`: every reconstructed span /// covering it, folded via [`merge_styles`] in instance order. /// `Style::default()` when nothing covers it (outside the /// declared viewport, or no styling there). #[must_use] pub fn effective_style_at(&self, buffer_id: BufferId, byte: u64) -> Style { self.styles .get(&buffer_id) .map_or_else(Style::default, |m| { m.items_at(byte) .fold(Style::default(), |acc, s| merge_styles(acc, s.style)) }) } /// The decoration kinds covering `byte`, in instance order /// (duplicates preserved — a byte can carry, e.g., both a /// selection and a diagnostic). #[must_use] pub fn decoration_kinds_at(&self, buffer_id: BufferId, byte: u64) -> Vec { self.decos .get(&buffer_id) .map_or_else(Vec::new, |m| m.items_at(byte).map(|d| d.kind).collect()) } /// Reconstructed styling tile ranges for `buffer_id` — for /// invariant assertions (disjointness, in-viewport bounds). #[must_use] pub fn style_tile_ranges(&self, buffer_id: BufferId) -> Vec { self.styles .get(&buffer_id) .map(SemanticModel::tile_ranges) .unwrap_or_default() } /// Reconstructed decoration tile ranges for `buffer_id`. #[must_use] pub fn decoration_tile_ranges(&self, buffer_id: BufferId) -> Vec { self.decos .get(&buffer_id) .map(SemanticModel::tile_ranges) .unwrap_or_default() } } #[cfg(test)] mod tests { use super::*; fn br(start: u64, end: u64) -> ByteRange { ByteRange { start, end } } fn styled(fg_bold: bool) -> Style { Style { bold: fg_bold, ..Style::default() } } fn span(start: u64, end: u64, bold: bool) -> StyleSpan { StyleSpan { range: br(start, end), style: styled(bold), } } fn deco(start: u64, end: u64, kind: DecorationKind) -> Decoration { Decoration { range: br(start, end), kind, } } #[test] fn full_frame_replaces_the_whole_model() { let mut m: SemanticModel = SemanticModel::default(); m.apply(true, &[(br(0, 10), vec![span(2, 5, true)])]); assert_eq!(m.tile_ranges(), vec![br(0, 10)]); // A second full frame discards the first entirely. m.apply(true, &[(br(0, 4), vec![span(0, 4, false)])]); assert_eq!(m.tile_ranges(), vec![br(0, 4)]); assert_eq!(m.items_at(2).count(), 1); assert!(m.items_at(8).next().is_none(), "byte 8 no longer covered"); } #[test] fn incremental_segment_splits_a_straddling_tile_and_keeps_the_edges() { let mut m: SemanticModel = SemanticModel::default(); // One wide tile spanning [0,30) with a span over [0,30). m.apply(true, &[(br(0, 30), vec![span(0, 30, true)])]); // A dirty segment repaints the middle [10,20). m.apply(false, &[(br(10, 20), vec![span(10, 20, false)])]); // Edges [0,10) and [20,30) survive (clipped), middle replaced. assert_eq!( m.tile_ranges(), vec![br(0, 10), br(10, 20), br(20, 30)], "straddling tile split into left edge / new middle / right edge" ); // Edge styling preserved (bold); middle replaced (not bold). assert!(m.items_at(5).next().unwrap().style.bold); assert!(!m.items_at(15).next().unwrap().style.bold); assert!(m.items_at(25).next().unwrap().style.bold); } #[test] fn bytes_outside_all_tiles_have_default_style() { let c = SemanticClient::new(FrontendId(7)); let b = BufferId::next(); assert_eq!(c.effective_style_at(b, 3), Style::default()); assert!(c.decoration_kinds_at(b, 3).is_empty()); } #[test] fn overlapping_spans_fold_in_order_via_merge_styles() { let mut m: SemanticModel = SemanticModel::default(); // Wider span (bold) then a nested non-bold span — instance // ships wider-first; merge_styles overlays in that order. let wide = StyleSpan { range: br(0, 10), style: Style { bold: true, ..Style::default() }, }; let inner = StyleSpan { range: br(4, 6), style: Style { italic: true, ..Style::default() }, }; m.apply(true, &[(br(0, 10), vec![wide, inner])]); let folded = m .items_at(5) .fold(Style::default(), |acc, s| merge_styles(acc, s.style)); assert!(folded.bold && folded.italic, "both layers apply at byte 5"); let only_wide = m .items_at(1) .fold(Style::default(), |acc, s| merge_styles(acc, s.style)); assert!(only_wide.bold && !only_wide.italic); } #[test] fn decoration_model_tracks_kinds_at_byte() { let mut m: SemanticModel = SemanticModel::default(); m.apply( true, &[( br(0, 20), vec![ deco(2, 8, DecorationKind::Selection), deco(5, 6, DecorationKind::DiagnosticError), ], )], ); let at5: Vec<_> = m.items_at(5).map(|d| d.kind).collect(); assert_eq!( at5, vec![DecorationKind::Selection, DecorationKind::DiagnosticError] ); assert_eq!( m.items_at(3).map(|d| d.kind).collect::>(), vec![DecorationKind::Selection] ); assert!(m.items_at(15).next().is_none()); } #[test] fn client_ignores_grid_and_stub_families() { let mut c = SemanticClient::new(FrontendId(2)); let b = BufferId::next(); // None of these should panic or affect the model. c.apply(&InstanceMessage::Cursor(None)); c.apply(&InstanceMessage::FoldState { buffer_id: b, folds: vec![br(0, 1)], }); c.apply(&InstanceMessage::ResourceOffer { handle: 1, mime: "image/png".into(), body: crate::protocol::ResourceBody::Inline(vec![1, 2]), }); assert!(c.style_tile_ranges(b).is_empty()); assert!(c.text(b).is_none()); } #[test] fn viewport_event_carries_the_sessions_fid() { let c = SemanticClient::new(FrontendId(9)); let b = BufferId::next(); match c.viewport_event(b, br(0, 64), 3) { FrontendEvent::Viewport { frontend_id, buffer_id, visible, generation, } => { assert_eq!(frontend_id, FrontendId(9)); assert_eq!(buffer_id, b); assert_eq!(visible, br(0, 64)); assert_eq!(generation, 3); } other => panic!("expected Viewport, got {other:?}"), } } }