M11.2: semantic projection seam (SemanticRenderState)

The first real producer of the semantic-frontend arc. The instance
projects syntax styling to semantic_render sessions without
rasterizing to a cell grid.

- src/semantic_render.rs: SemanticRenderState, sibling of
  instance_render::RenderState. Reads the same EditorState, emits
  InstanceMessage::StyleSpans (tree-sitter spans via the active
  Theme), scoped + clipped to the FrontendEvent::Viewport byte range.
  Emits nothing until a viewport is declared; suppresses
  byte-identical frames (per-span delta encoding deferred to M11.4).
- CrdtState::version_scalar(): oplog version vector summed to a
  monotonic non-decreasing u64 — the StyleSpans.generation anchor.
- daemon dispatcher: semantic_states map parallel to render_states;
  projection selected per session. Semantic sessions get StyleSpans +
  CursorByte + BufferSnapshot + CrdtOp + presence, never CellDelta /
  grid Cursor. FrontendEvent::Viewport consumed (routed by
  authenticated source). SessionEstablished body extracted to
  handle_session_established (clippy 100-line ceiling). Grid-less
  sessions no longer panic the _ => apply_event arm.
- InstanceCapabilities default semantic_render flipped to
  cfg!(feature = "crdt") — the "M11.2 enables semantic" moment,
  analogous to the M10.8 Day-4 flip. M11.1 negotiation test comment
  updated for the flip (frontend-side default still false keeps M10.7
  outcomes unperturbed).

Lib (1392 crdt / 1237 non-crdt) + integration suites green on both
feature flavors; clippy -D warnings clean on both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-18 20:14:08 -04:00
parent 1c5169b8f8
commit 4ba6fd7bb8
6 changed files with 586 additions and 81 deletions

View File

@ -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 (M1M6) with the

View File

@ -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");

View File

@ -827,6 +827,13 @@ fn dispatcher_loop(
) -> Result<(), DaemonError> {
// Per-frontend dispatcher state.
let mut render_states: HashMap<FrontendId, RenderState> = 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<FrontendId, crate::semantic_render::SemanticRenderState> =
HashMap::new();
let mut streams: HashMap<FrontendId, UnixStream> = HashMap::new();
let mut term_sizes: HashMap<FrontendId, CellSize> = 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<FrontendId> = 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<FrontendId> = 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<FrontendId, RenderState>,
semantic_states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
streams: &mut HashMap<FrontendId, UnixStream>,
term_sizes: &mut HashMap<FrontendId, CellSize>,
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<FrontendId, RenderState>,
semantic_states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
streams: &mut HashMap<FrontendId, UnixStream>,
term_sizes: &mut HashMap<FrontendId, CellSize>,
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);

View File

@ -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;

View File

@ -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

313
src/semantic_render.rs Normal file
View File

@ -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<DeclaredViewport>,
/// 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<BufferId, (u64, Vec<StyleSpan>)>,
}
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<InstanceMessage> {
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<StyleSpan> {
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
}
}