diff --git a/CHANGELOG.md b/CHANGELOG.md index 0af5ce3..b928ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,38 @@ lifted from positional cells to byte-anchored ranges. - `ResourceOffer` remains an honest stub (no resource-bearing adornment producer exists yet) — same discipline as M11.3. +#### Semantic frontend↔instance glue (M11.5) + +The arc's consumer side and end-to-end coverage. pmacs has no GUI +toolkit, so — per the design note's testability strategy — the +deliverable is the bounded testable glue, not a GPU renderer. + +- New headless `SemanticClient` (`src/semantic_client.rs`, `crdt`- + gated): composes the `BufferMirror` rope replica (M10.10) with a + tile-based `SemanticModel` that reconstructs styling/decorations + from the `full` + dirty-segment deltas (M11.4). Self-contained: + no terminal, no pixels. Emits `FrontendEvent::Viewport`; exposes + read-back accessors (`text`, `effective_style_at`, + `decoration_kinds_at`, tile ranges). The M11.4 contract (segments + carry every current item intersecting their range) makes a tile + self-contained, so incremental application is a clean per-tile + replacement with edge-clipping, not cross-span surgery. +- `tests/m11_5_semantic_acceptance.rs`: (a) reconstruction- + equivalence — an incrementally-driven client is asserted byte-for- + byte identical to a fresh full projection across a scripted + viewport/edit/selection sequence including a viewport jump (the + golden discipline without a snapshot crate); (b) end-to-end — + a real daemon routes `StyleSpans`/`Decorations` to a semantic + session (after it declares a `Viewport`) and never to a grid + session, and `CellDelta` vice versa, validating the M11.2 + per-session projection through the socket. + +This completes the M11 semantic-frontend arc (M11.1–M11.5): wire + +capability scaffolding, the instance-side projection seam, +decorations, segment diffing, and the consumer-side glue with +end-to-end coverage. `InlineAdornments`/`BlockAdornments`/`FoldState`/ +`ResourceOffer` remain honest stubs pending their source features. + ## [1.0.0] --- 2026-05-18 First stable release. Builds on the 0.1.0 preview (M1–M6) with the diff --git a/src/lib.rs b/src/lib.rs index c2fe253..2ab555b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -90,6 +90,12 @@ pub mod project; pub mod project_index; pub mod protocol; pub mod rope; +// T M11.5 — the headless semantic consumer composes BufferMirror + +// optimistic (both `crdt`-gated) and is only meaningful on a +// `semantic_render` session, which the negotiation dependency rule +// ties to `crdt_replica`. Gated to match. +#[cfg(feature = "crdt")] +pub mod semantic_client; pub mod semantic_render; pub mod signature; pub mod socket_path; diff --git a/src/semantic_client.rs b/src/semantic_client.rs new file mode 100644 index 0000000..dbb6cce --- /dev/null +++ b/src/semantic_client.rs @@ -0,0 +1,532 @@ +// 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:?}"), + } + } +} diff --git a/tests/m11_5_semantic_acceptance.rs b/tests/m11_5_semantic_acceptance.rs new file mode 100644 index 0000000..cdda17f --- /dev/null +++ b/tests/m11_5_semantic_acceptance.rs @@ -0,0 +1,328 @@ +// m11_5_semantic_acceptance.rs --- M11.5 acceptance: the semantic frontend↔instance glue. + +//! T M11.5 acceptance suite for the semantic-frontend arc. +//! +//! Two paths, both exercising the headless [`SemanticClient`] — the +//! frontend↔instance glue the design note names as the bounded +//! testable surface (`docs/semantic-frontend-protocol.md`, +//! "Testability strategy"): +//! +//! - **Reconstruction-equivalence (instance-side, deterministic).** +//! Drive a [`SemanticRenderState`] through a scripted sequence of +//! viewport declarations and editor mutations, feed every emitted +//! message into a `SemanticClient`, and assert the client's +//! incrementally-reconstructed view is byte-for-byte identical to a +//! *fresh full* projection of the same instant (the oracle). This +//! is the golden discipline without a snapshot crate: the property +//! asserted is "incremental ≡ from-scratch", which no incidental +//! wire-shape churn can falsely pass. +//! +//! - **End-to-end daemon filter.** A real daemon, a semantic session +//! (negotiating `semantic_render`, declaring a `Viewport`) and a +//! grid session: prove the M11.2 per-session projection actually +//! routes `StyleSpans`/`Decorations` to the semantic session and +//! never to the grid one, and `CellDelta` vice versa. + +#![cfg(feature = "crdt")] + +use std::time::{Duration, Instant}; + +use pmacs::buffer::BufferId; +use pmacs::cell::CellSize; +use pmacs::editor::EditorState; +use pmacs::protocol::{ + AttachRequest, ByteRange, FrontendCapabilities, FrontendEvent, FrontendId, Hello, + InstanceMessage, +}; +use pmacs::semantic_client::SemanticClient; +use pmacs::semantic_render::SemanticRenderState; +use pmacs::transport::{read_message, write_message}; + +mod common; +use common::daemon::{TestDaemon, build_default_caps}; + +// --------------------------------------------------------------------------- +// Part A — reconstruction-equivalence (instance-side, no daemon) +// --------------------------------------------------------------------------- + +const LOCAL: FrontendId = FrontendId::LOCAL; + +fn active_buffer(state: &EditorState) -> BufferId { + state.core.borrow().active_window().buffer_id +} + +fn set_selection(state: &EditorState, anchor: u64, cursor: u64) { + let mut core = state.core.borrow_mut(); + let win = core + .active_window_mut_for(LOCAL) + .expect("LOCAL always has a window"); + win.selection = Some(pmacs::window::Selection { anchor }); + win.cursor = cursor; +} + +/// The authoritative reconstruction for this instant: a fresh +/// `SemanticRenderState` emits a `full` first frame carrying the +/// complete current scoped set; a fresh client consuming only that is +/// the oracle the incrementally-driven client must match. +fn oracle(state: &EditorState, buffer_id: BufferId, vp: ByteRange) -> SemanticClient { + let mut o = SemanticRenderState::new(LOCAL); + o.set_viewport(buffer_id, vp, 0); + let mut oc = SemanticClient::new(LOCAL); + for m in &o.render_frame(state) { + oc.apply(m); + } + oc +} + +fn assert_equiv(client: &SemanticClient, state: &EditorState, buffer_id: BufferId, vp: ByteRange) { + let oc = oracle(state, buffer_id, vp); + for b in vp.start..vp.end { + assert_eq!( + client.decoration_kinds_at(buffer_id, b), + oc.decoration_kinds_at(buffer_id, b), + "decoration mismatch at byte {b}" + ); + assert_eq!( + client.effective_style_at(buffer_id, b), + oc.effective_style_at(buffer_id, b), + "style mismatch at byte {b}" + ); + } +} + +fn decorations_full(msgs: &[InstanceMessage]) -> Option { + msgs.iter().find_map(|m| match m { + InstanceMessage::Decorations { full, .. } => Some(*full), + _ => None, + }) +} + +fn has_style_spans(msgs: &[InstanceMessage]) -> bool { + msgs.iter() + .any(|m| matches!(m, InstanceMessage::StyleSpans { .. })) +} + +fn generation_of(msgs: &[InstanceMessage]) -> Option { + msgs.iter().find_map(|m| match m { + InstanceMessage::StyleSpans { generation, .. } + | InstanceMessage::Decorations { generation, .. } => Some(*generation), + _ => None, + }) +} + +fn assert_disjoint_within(ranges: &[ByteRange], vp: ByteRange) { + let mut sorted = ranges.to_vec(); + sorted.sort_by_key(|r| (r.start, r.end)); + let mut prev_end = vp.start; + for r in &sorted { + assert!( + r.start >= vp.start && r.end <= vp.end, + "tile {r:?} escapes the declared viewport {vp:?}" + ); + assert!( + r.start >= prev_end, + "tiles overlap: {r:?} starts before previous end {prev_end}" + ); + prev_end = r.end; + } +} + +#[test] +fn incremental_reconstruction_equals_fresh_full_projection() { + let state = EditorState::new(); + let buffer_id = active_buffer(&state); + let vp1 = ByteRange { start: 0, end: 64 }; + + let mut sem = SemanticRenderState::new(LOCAL); + sem.set_viewport(buffer_id, vp1, 0); + let mut client = SemanticClient::new(LOCAL); + let mut generations: Vec = Vec::new(); + + // Frame 1 — first frame: a full resync for both families (empty + // scratch, no selection → empty segments). + let f1 = sem.render_frame(&state); + assert_eq!(decorations_full(&f1), Some(true), "first frame full"); + assert!(has_style_spans(&f1), "first frame ships StyleSpans too"); + if let Some(g) = generation_of(&f1) { + generations.push(g); + } + for m in &f1 { + client.apply(m); + } + assert_equiv(&client, &state, buffer_id, vp1); + + // Unchanged → fully silent. + assert!( + sem.render_frame(&state).is_empty(), + "an unchanged frame emits nothing" + ); + + // A selection appears → Decorations re-emits incrementally + // (viewport region unchanged), styling stays suppressed. + set_selection(&state, 2, 5); + let f2 = sem.render_frame(&state); + assert_eq!(decorations_full(&f2), Some(false), "incremental, not full"); + assert!(!has_style_spans(&f2), "styling unchanged → not re-sent"); + if let Some(g) = generation_of(&f2) { + generations.push(g); + } + for m in &f2 { + client.apply(m); + } + assert_equiv(&client, &state, buffer_id, vp1); + + // Selection jumps far away → two disjoint dirty intervals (old + // cleared, new painted). The client must reconstruct both. + set_selection(&state, 40, 42); + let f3 = sem.render_frame(&state); + for m in &f3 { + client.apply(m); + } + if let Some(g) = generation_of(&f3) { + generations.push(g); + } + assert_equiv(&client, &state, buffer_id, vp1); + assert_disjoint_within(&client.decoration_tile_ranges(buffer_id), vp1); + + // Viewport region moves → a full resync. The selection at + // [40,42) is outside the new window, so the reconstruction is + // empty there — but only if the client correctly discarded the + // old viewport's tiles on the `full` frame. + let vp2 = ByteRange { + start: 100, + end: 200, + }; + sem.set_viewport(buffer_id, vp2, 0); + let f4 = sem.render_frame(&state); + assert_eq!( + decorations_full(&f4), + Some(true), + "viewport jump forces a full resync" + ); + for m in &f4 { + client.apply(m); + } + assert_equiv(&client, &state, buffer_id, vp2); + + // Generation is monotonic non-decreasing across the run. + for w in generations.windows(2) { + assert!(w[1] >= w[0], "generation went backwards: {generations:?}"); + } +} + +// --------------------------------------------------------------------------- +// Part B — end-to-end daemon: per-session projection routing +// --------------------------------------------------------------------------- + +fn semantic_caps() -> FrontendCapabilities { + // semantic_render requires crdt_replica (negotiation dependency + // rule); a semantic session is also a text replica. + FrontendCapabilities { + multi_frontend: true, + crdt_replica: true, + semantic_render: true, + ..build_default_caps() + } +} + +/// Read messages until `deadline`, classifying what arrives. Returns +/// `(saw_cell_delta, saw_semantic, first_buffer_id)`. +fn drain_kinds( + stream: &mut std::os::unix::net::UnixStream, + deadline: Instant, + mut on_snapshot: impl FnMut(BufferId), +) -> (bool, bool) { + let mut saw_cell = false; + let mut saw_semantic = false; + while Instant::now() < deadline { + match read_message::(stream) { + Ok(InstanceMessage::CellDelta { .. }) => saw_cell = true, + Ok(InstanceMessage::StyleSpans { .. } | InstanceMessage::Decorations { .. }) => { + saw_semantic = true; + } + Ok(InstanceMessage::BufferSnapshot { buffer_id, .. }) => on_snapshot(buffer_id), + // Other variants are irrelevant here; `Err` is a + // read-timeout slice — both just keep polling. + Ok(_) | Err(_) => {} + } + } + (saw_cell, saw_semantic) +} + +#[test] +fn daemon_routes_semantic_family_to_semantic_session_only() { + let daemon = TestDaemon::spawn(); + + // --- Semantic session --- + let mut sem = daemon.connect(); + sem.set_read_timeout(Some(Duration::from_millis(250))) + .unwrap(); + let hello: Hello = read_message(&mut sem).expect("semantic read Hello"); + let sem_fid = hello.assigned_frontend_id; + write_message( + &mut sem, + &AttachRequest { + protocol_version: hello.protocol_version, + frontend_capabilities: semantic_caps(), + initial_size: CellSize::new(24, 80), + }, + ) + .expect("semantic write AttachRequest"); + + // Learn a buffer id from the bootstrap snapshot, then declare a + // viewport — the daemon emits nothing semantic until it does + // (M11.2), so this also exercises the Viewport intercept e2e. + let mut buf: Option = None; + let by = Instant::now() + Duration::from_secs(5); + let _ = drain_kinds(&mut sem, Instant::now() + Duration::from_secs(2), |b| { + buf.get_or_insert(b); + }); + let buffer_id = buf.expect("semantic session received a BufferSnapshot"); + write_message( + &mut sem, + &FrontendEvent::Viewport { + frontend_id: sem_fid, + buffer_id, + visible: ByteRange { + start: 0, + end: 4096, + }, + generation: 0, + }, + ) + .expect("semantic write Viewport"); + let (sem_saw_cell, sem_saw_semantic) = drain_kinds(&mut sem, by, |_| {}); + assert!( + sem_saw_semantic, + "semantic session must receive StyleSpans/Decorations after declaring a viewport" + ); + assert!( + !sem_saw_cell, + "semantic session must NOT receive grid CellDelta (it lays out locally)" + ); + + // --- Grid session (same daemon) --- + let mut grid = daemon.connect(); + grid.set_read_timeout(Some(Duration::from_millis(250))) + .unwrap(); + let ghello: Hello = read_message(&mut grid).expect("grid read Hello"); + write_message( + &mut grid, + &AttachRequest { + protocol_version: ghello.protocol_version, + frontend_capabilities: build_default_caps(), + initial_size: CellSize::new(24, 80), + }, + ) + .expect("grid write AttachRequest"); + let (grid_saw_cell, grid_saw_semantic) = + drain_kinds(&mut grid, Instant::now() + Duration::from_secs(3), |_| {}); + assert!( + grid_saw_cell, + "grid session must receive CellDelta (the M5 projection)" + ); + assert!( + !grid_saw_semantic, + "grid session must NOT receive the semantic family" + ); +}