diff --git a/CHANGELOG.md b/CHANGELOG.md index 1395e4d..f884196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,33 @@ wire declaration. - `PMACS_INSTANCE_SEMANTIC_RENDER` env override mirrors the existing per-capability test overrides. +#### Semantic projection seam (M11.2) + +The first real producer. The instance now projects syntax styling to +`semantic_render` sessions without rasterizing to a cell grid. + +- New `SemanticRenderState` (`src/semantic_render.rs`), sibling of + `instance_render::RenderState`: reads the same `EditorState` but + emits `InstanceMessage::StyleSpans` — tree-sitter spans mapped + through the active `Theme`, scoped and clipped to the byte range the + frontend declared via `FrontendEvent::Viewport`. Emits nothing until + a viewport is declared; suppresses byte-identical frames (true + span-granularity diffing is M11.4). +- `StyleSpans.generation` is anchored to `CrdtState::version_scalar()` + — the oplog version vector summed to one monotonic non-decreasing + scalar, letting a frontend discard styling that predates an edit it + already applied optimistically. +- Dispatcher selects the projection **per session**: a semantic + session gets a `SemanticRenderState` and never `CellDelta`/grid + `Cursor` (it lays out locally) but still receives `CursorByte`, + `BufferSnapshot`, `CrdtOp`, and presence. A grid and a semantic + frontend can attach to the same buffer simultaneously. + `FrontendEvent::Viewport` is consumed (routed by authenticated + source, like `CrdtOp`). +- `InstanceCapabilities` default `semantic_render` flipped to + `cfg!(feature = "crdt")` — the "M11.2 enables semantic" moment, + analogous to the M10.8 Day-4 `multi_frontend`/`crdt_replica` flip. + ## [1.0.0] --- 2026-05-18 First stable release. Builds on the 0.1.0 preview (M1–M6) with the diff --git a/src/crdt.rs b/src/crdt.rs index d93683f..c7d493c 100644 --- a/src/crdt.rs +++ b/src/crdt.rs @@ -204,6 +204,31 @@ impl CrdtState { self.doc.oplog_vv() } + /// T M11.2 — the oplog version projected to a single monotonic + /// scalar: the sum of every peer's op counter in the version + /// vector. + /// + /// This is the `generation` anchor for the semantic projection + /// (`InstanceMessage::StyleSpans::generation`). A loro counter is + /// per-peer non-decreasing and only ever grows as ops accrue, so + /// the sum is non-decreasing for the document as a whole — a + /// frontend can compare a received `generation` against the one + /// it computed locally and discard styling that predates an edit + /// it already applied optimistically. It is deliberately *not* a + /// causal clock: equal scalars do not imply equal states across + /// divergent replicas. It is only ever compared against itself on + /// one replica (the frontend's own mirror vs. the instance's + /// authoritative doc), where it is monotone, which is all the + /// staleness check needs. + #[must_use] + pub fn version_scalar(&self) -> u64 { + self.doc + .oplog_vv() + .values() + .map(|counter| u64::try_from(*counter).unwrap_or(0)) + .sum() + } + /// T M10.2 Day 3: export wire-format bytes for ops added since /// `from`. /// @@ -460,6 +485,30 @@ mod tests { assert_eq!(s.peer_id(), 1); } + #[test] + fn version_scalar_is_monotonic_non_decreasing() { + // T M11.2 — the semantic projection's `generation` anchor. + // Empty doc is 0; each applied op only grows the scalar; a + // no-op delete does not shrink it. + let s = CrdtState::new(1).expect("new"); + assert_eq!(s.version_scalar(), 0, "empty doc has generation 0"); + + s.insert(0, "hello").expect("insert"); + let g1 = s.version_scalar(); + assert!(g1 > 0, "an applied op must advance the generation"); + + s.insert(5, " world").expect("insert"); + let g2 = s.version_scalar(); + assert!(g2 >= g1, "generation must not decrease across ops"); + + s.delete(0, 1).expect("delete"); + let g3 = s.version_scalar(); + assert!( + g3 >= g2, + "a delete is still an op — the version vector only grows" + ); + } + #[test] fn insert_round_trip_ascii() { let s = CrdtState::new(1).expect("new"); diff --git a/src/daemon.rs b/src/daemon.rs index f4202e5..71640b2 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -827,6 +827,13 @@ fn dispatcher_loop( ) -> Result<(), DaemonError> { // Per-frontend dispatcher state. let mut render_states: HashMap = HashMap::new(); + // T M11.2 — parallel to `render_states`, but for `semantic_render` + // sessions: the dispatcher selects the projection *per session*, + // so a frontend has exactly one of a `RenderState` (grid) or a + // `SemanticRenderState` (layout-local), never both. A grid and a + // semantic frontend can attach to the same buffer simultaneously. + let mut semantic_states: HashMap = + HashMap::new(); let mut streams: HashMap = HashMap::new(); let mut term_sizes: HashMap = HashMap::new(); let mut session_registry = SessionRegistry::new(); @@ -845,7 +852,13 @@ fn dispatcher_loop( // loop to the last-dispatched value (Q11: tick-driven render // doesn't update active_frontend in the user-driving sense). let last_dispatched = editor.core.borrow().active_frontend; - let attached_fids: Vec = render_states.keys().copied().collect(); + // Union of grid + semantic sessions — each fid is in exactly + // one of the two maps (projection selected per session). + let attached_fids: Vec = render_states + .keys() + .chain(semantic_states.keys()) + .copied() + .collect(); // T M10.10 post-audit-round-3 F18 — drain + broadcast pending // CRDT ops **before** the render pass. Otherwise frontends @@ -940,15 +953,25 @@ fn dispatcher_loop( let _ = ensure_active_buffer_crdt_backed(editor, *fid); } - // T M10.9 — gather other-frontend presences for the - // overlay paint. Reads `last_broadcast` (updated by the - // sweep below); other-frontend snapshots lag by at most - // one tick. Imperceptible at frame-rate cadence. - let other_presences = session_registry.other_presences_for(*fid); - let render_state = render_states - .get_mut(fid) - .expect("render_state present for attached fid"); - let messages = render_state.render_frame(editor, &other_presences); + // Projection selected per session (T M11.2). A semantic + // session produces `StyleSpans` scoped to its declared + // viewport and NEVER `CellDelta` / grid `Cursor` (it lays + // out locally); it still receives `CursorByte` below + // (semantic implies `crdt_replica`) and participates in + // presence. A grid session takes the M5.2 cell path. + let messages = if let Some(sem) = semantic_states.get_mut(fid) { + sem.render_frame(editor) + } else { + // T M10.9 — gather other-frontend presences for the + // overlay paint. Reads `last_broadcast` (updated by + // the sweep below); other-frontend snapshots lag by + // at most one tick. Imperceptible at frame cadence. + let other_presences = session_registry.other_presences_for(*fid); + let render_state = render_states + .get_mut(fid) + .expect("render_state present for attached grid fid"); + render_state.render_frame(editor, &other_presences) + }; // T M10.6 per-frontend presence sweep. The snapshot is // computed from this frontend's view; the sweep then @@ -1036,6 +1059,7 @@ fn dispatcher_loop( // Drop the broken connection. streams.remove(fid); render_states.remove(fid); + semantic_states.remove(fid); term_sizes.remove(fid); session_registry.unregister_session(*fid); editor.core.borrow_mut().unregister_frontend_view(*fid); @@ -1073,6 +1097,7 @@ fn dispatcher_loop( event, editor, &mut render_states, + &mut semantic_states, &mut streams, &mut term_sizes, &mut session_registry, @@ -1086,6 +1111,7 @@ fn dispatcher_loop( event, editor, &mut render_states, + &mut semantic_states, &mut streams, &mut term_sizes, &mut session_registry, @@ -1106,10 +1132,84 @@ fn dispatcher_loop( /// Handle one `DispatcherEvent`. Extracted so the dispatcher loop /// can both timeout-recv and burst-drain via the same code path. +/// T M11.2 — extracted from `handle_dispatcher_event`'s +/// `SessionEstablished` arm (kept the parent under the 100-line +/// clippy ceiling). Registers the frontend's view, bootstraps the +/// `BufferMirror` via `BufferSnapshot` when `crdt_replica`, and +/// selects the per-session projection: a `semantic_render` session +/// gets a `SemanticRenderState` (no grid `RenderState`, no +/// initial-full-grid analogue — it emits nothing until the frontend +/// declares a viewport); every other session keeps the M5.3 +/// force-full-grid grid path. +#[allow(clippy::too_many_arguments)] +fn handle_session_established( + editor: &mut EditorState, + render_states: &mut HashMap, + semantic_states: &mut HashMap, + streams: &mut HashMap, + term_sizes: &mut HashMap, + session_registry: &mut SessionRegistry, + frontend_id: FrontendId, + session_state: crate::presence::SessionState, + initial_size: CellSize, + mut write_stream: UnixStream, +) { + // Register the frontend's view (M10.8 Day 3: fresh scratch + // buffer view; future milestones may clone LOCAL's view or + // take an explicit initial-buffer argument). + let scratch_view = build_fresh_frontend_view(editor); + editor + .core + .borrow_mut() + .register_frontend_view(frontend_id, scratch_view); + + // T M10.10: bootstrap the new frontend's `BufferMirror` by + // sending one `BufferSnapshot` per CRDT-backed buffer. Gated on + // the negotiated `crdt_replica` capability — v0.1 / non-replica + // frontends never receive the variant (postcard would hard-error + // on the unknown variant; see M10.10-FRAMING.md Refinement 3). + // Ordering: snapshots are sent BEFORE any CellDelta flows (the + // next per-tick render is the first CellDelta source), so the + // mirror is initialized before any local-edit path can reference + // it. + let crdt_replica = session_state.negotiated_capabilities.crdt_replica; + // T M11.2 — a semantic session is always a text replica (the + // negotiation dependency rule guarantees `semantic_render ⇒ + // crdt_replica`), so the `BufferSnapshot` bootstrap below still + // fires: the semantic frontend holds the rope locally and the + // semantic frame ships no text. + let semantic_render = session_state.negotiated_capabilities.semantic_render; + if crdt_replica { + send_buffer_snapshots(editor, &mut write_stream); + } + + // Register the session in the registry (presence + capability + // filters). + session_registry.register_session(frontend_id, session_state); + + if semantic_render { + semantic_states.insert( + frontend_id, + crate::semantic_render::SemanticRenderState::new(), + ); + } else { + let mut render_state = RenderState::new(initial_size); + render_state.force_full_grid_resync(); + render_states.insert(frontend_id, render_state); + } + streams.insert(frontend_id, write_stream); + term_sizes.insert(frontend_id, initial_size); + + // Stamp active_frontend so the initial render's Lua statusline + // code sees the right fid. + editor.core.borrow_mut().active_frontend = frontend_id; +} + fn handle_dispatcher_event( event: DispatcherEvent, editor: &mut EditorState, render_states: &mut HashMap, + semantic_states: &mut HashMap, streams: &mut HashMap, term_sizes: &mut HashMap, session_registry: &mut SessionRegistry, @@ -1119,48 +1219,20 @@ fn handle_dispatcher_event( frontend_id, session_state, initial_size, - mut write_stream, + write_stream, } => { - // Register the frontend's view (M10.8 Day 3: fresh - // scratch buffer view; future milestones may clone - // LOCAL's view or take an explicit initial-buffer - // argument). - let scratch_view = build_fresh_frontend_view(editor); - editor - .core - .borrow_mut() - .register_frontend_view(frontend_id, scratch_view); - - // T M10.10: bootstrap the new frontend's `BufferMirror` - // by sending one `BufferSnapshot` per CRDT-backed buffer. - // Gated on the negotiated `crdt_replica` capability — - // v0.1 / non-replica frontends never receive the variant - // (postcard would hard-error on the unknown variant; see - // M10.10-FRAMING.md Refinement 3). Ordering: snapshots - // are sent BEFORE any CellDelta flows (the next per-tick - // render is the first CellDelta source), so the mirror - // is initialized before any local-edit path can - // reference it. - let crdt_replica = session_state.negotiated_capabilities.crdt_replica; - if crdt_replica { - send_buffer_snapshots(editor, &mut write_stream); - } - - // Register the session in the registry (presence + - // capability filters). - session_registry.register_session(frontend_id, session_state); - - // Allocate per-frontend RenderState; force initial - // full-grid sync so the first frame paints everything. - let mut render_state = RenderState::new(initial_size); - render_state.force_full_grid_resync(); - render_states.insert(frontend_id, render_state); - streams.insert(frontend_id, write_stream); - term_sizes.insert(frontend_id, initial_size); - - // Stamp active_frontend so the initial render's Lua - // statusline code sees the right fid. - editor.core.borrow_mut().active_frontend = frontend_id; + handle_session_established( + editor, + render_states, + semantic_states, + streams, + term_sizes, + session_registry, + frontend_id, + session_state, + initial_size, + write_stream, + ); } DispatcherEvent::FrontendEvent { source, event } => { match event { @@ -1214,21 +1286,51 @@ fn handle_dispatcher_event( handle_remote_crdt_op(editor, source, buffer_id, op); } } + FrontendEvent::Viewport { + buffer_id, + visible, + generation, + .. + } => { + // T M11.2 — feed the semantic projection the byte + // range the frontend has on screen. Routed by the + // authenticated `source` (the client-supplied + // `frontend_id` field is not trusted, consistent + // with the CrdtOp source-trust rule). A grid + // session never sends this; if one does, there is + // no `SemanticRenderState` to update and it is a + // benign no-op. + if let Some(sem) = semantic_states.get_mut(&source) { + sem.set_viewport(buffer_id, visible, generation); + } + } _ => { let term_size = *term_sizes .get(&source) .expect("term_size present for source"); - let render_state = render_states - .get_mut(&source) - .expect("render_state present for source"); let mut term_size = term_size; - apply_event(editor, event, &mut term_size, render_state); - term_sizes.insert(source, term_size); + if let Some(render_state) = render_states.get_mut(&source) { + apply_event(editor, event, &mut term_size, render_state); + term_sizes.insert(source, term_size); + } else { + // T M11.2 — a semantic (grid-less) session has + // no `RenderState`. Key/Mouse/Paste/Focus + // command handling for semantic frontends is + // M11.5 scope; until then these events are + // dropped rather than panicking the + // dispatcher on the absent grid state. + debug_assert!( + semantic_states.contains_key(&source), + "fid with neither a render_state nor a semantic_state \ + sent a frontend event" + ); + } } } } DispatcherEvent::SessionDetached { frontend_id } => { render_states.remove(&frontend_id); + semantic_states.remove(&frontend_id); streams.remove(&frontend_id); term_sizes.remove(&frontend_id); session_registry.unregister_session(frontend_id); diff --git a/src/lib.rs b/src/lib.rs index 9668714..c2fe253 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -90,6 +90,7 @@ pub mod project; pub mod project_index; pub mod protocol; pub mod rope; +pub mod semantic_render; pub mod signature; pub mod socket_path; pub mod syntax; diff --git a/src/protocol.rs b/src/protocol.rs index a3cefb8..dd87fa9 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1508,17 +1508,15 @@ pub struct InstanceCapabilities { /// family (`InstanceMessage::StyleSpans` … `ResourceOffer`) and /// consume `FrontendEvent::Viewport`. /// - /// Default is `false` — unlike `crdt_replica`, this does *not* - /// track the `crdt` Cargo feature. M11.1 declares the bit - /// position and the negotiation mechanics; the instance-side - /// projection seam (`SemanticRenderState`, the producer) is - /// M11.2. Advertising `true` before the producer exists would be - /// wire-protocol false advertising — the M10.5→M10.7 "bits false - /// until the path is wired" discipline, applied to M11. The - /// default flips to `cfg!(feature = "crdt")` when M11.2 lands the - /// projection seam (semantic sessions are also text replicas, so - /// the dependency on `crdt_replica` makes the feature gate the - /// natural ceiling). + /// T M11.1 declared the bit position + negotiation mechanics with + /// the default `false` (no producer yet). T M11.2 landed the + /// instance-side projection seam (`SemanticRenderState`) and + /// flipped the default to `cfg!(feature = "crdt")`: the instance + /// now advertises `semantic_render` on CRDT builds. It tracks the + /// `crdt` feature rather than being unconditional because the + /// negotiation dependency rule makes a semantic session + /// necessarily a text replica — a non-CRDT build can host + /// neither. See [`Default`] impl below. #[serde(default)] pub semantic_render: bool, } @@ -1543,18 +1541,25 @@ impl Default for InstanceCapabilities { // keeps the daemon's advertised capabilities consistent // with what it can actually do. // - // T M11.1 — `semantic_render` defaults to `false` - // unconditionally (not gated on the `crdt` feature like the - // two bits above). There is no projection-seam producer yet; - // the producer and the feature-tracking default flip are - // M11.2 scope. Until then a frontend declaring - // `semantic_render: true` gets `Goodbye(CapabilityMismatch)`, - // exactly as `multi_frontend`/`crdt_replica` did between - // M10.5 and the M10.8 Day-4 flip. + // T M11.1 declared `semantic_render` defaulting to `false` + // unconditionally — no projection-seam producer existed, so + // advertising it would have been wire-protocol false + // advertising (the M10.5→M10.7 "bits false until the path is + // wired" discipline). + // + // T M11.2 — **the flip**: the instance-side projection seam + // (`SemanticRenderState`, the producer) has landed and the + // dispatcher selects it per session, so the instance now + // advertises `semantic_render`. It tracks `cfg!(feature = + // "crdt")` like `crdt_replica` because the negotiation + // dependency rule makes a semantic session necessarily a + // text replica; a non-CRDT build can host neither. This is + // the "M11.2 enables semantic" moment, exactly analogous to + // the M10.8 Day-4 multi_frontend/crdt_replica flip. Self { multi_frontend: cfg!(feature = "crdt"), crdt_replica: cfg!(feature = "crdt"), - semantic_render: false, + semantic_render: cfg!(feature = "crdt"), } } } @@ -3809,15 +3814,23 @@ mod tests { } #[test] - fn negotiate_two_arg_helpers_default_semantic_render_false() { - // Regression: the M10.7 matrix uses the 2-arg helpers, which - // must keep semantic_render at its Default (false) so adding - // the bit did not perturb existing negotiation outcomes. + fn negotiate_two_arg_helpers_do_not_negotiate_semantic_render() { + // Regression: the M10.7 matrix uses the 2-arg helpers. After + // the T M11.2 flip the *instance* default is `cfg!(crdt)` + // (true under `--features crdt`), but the *frontend* 2-arg + // helper still defaults `semantic_render` to false — so the + // AND-rule yields `false` and existing M10.7 outcomes are + // unperturbed. (A frontend that wants the semantic projection + // opts in explicitly via the 3-arg helper.) let res = negotiate_capabilities(&front_caps(true, true), &inst_caps(true, true)).expect("ok"); assert!(res.multi_frontend); assert!(res.crdt_replica); - assert!(!res.semantic_render); + assert!( + !res.semantic_render, + "frontend that didn't request semantic_render must not negotiate it, \ + regardless of the instance default" + ); } // T M11.1 — postcard round-trips for the SemanticFrame family and diff --git a/src/semantic_render.rs b/src/semantic_render.rs new file mode 100644 index 0000000..c46615d --- /dev/null +++ b/src/semantic_render.rs @@ -0,0 +1,313 @@ +// 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, InstanceMessage, 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, +} + +/// 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)] +pub struct SemanticRenderState { + /// `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, + /// Last `(generation, spans)` shipped, keyed by buffer. A frame + /// whose scoped span set and generation match the last send emits + /// nothing — the steady-state cost between edits is one map + /// lookup. True per-span delta encoding is M11.4. + last_sent: HashMap)>, +} + +impl SemanticRenderState { + /// Fresh session state: no viewport declared, nothing sent. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 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 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). + 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 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(); + } + self.last_sent + .insert(vp.buffer_id, (generation, spans.clone())); + + vec![InstanceMessage::StyleSpans { + buffer_id: vp.buffer_id, + generation, + spans, + }] + } + +} + +/// 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() + } + + #[test] + fn emits_nothing_before_viewport_declared() { + let mut s = SemanticRenderState::new(); + assert!( + s.render_frame(&empty_state()).is_empty(), + "no StyleSpans may be emitted before the frontend declares a viewport" + ); + } + + #[test] + fn after_viewport_emits_style_spans_message_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 + }; + s.set_viewport(buffer_id, ByteRange { start: 0, end: 4096 }, 0); + + 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:?}"), + } + + // Nothing changed → byte-identical frame is suppressed. + assert!( + s.render_frame(&state).is_empty(), + "an unchanged frame must be suppressed" + ); + } + + #[test] + fn viewport_with_zero_width_range_yields_empty_span_set() { + 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:?}"), + } + } + + #[test] + fn semantic_state_default_constructs() { + // The dispatcher relies on `Default`/`new` parity. + let _ = SemanticRenderState::default(); + let _ = SemanticRenderState::new(); + } + + #[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. + 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 + }; + 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!( + sem_msgs + .iter() + .all(|m| matches!(m, InstanceMessage::StyleSpans { .. })), + "semantic projection produces only StyleSpans, never CellDelta" + ); + let _ = FrontendId::LOCAL; // import anchor for future fid-scoped tests + } +}