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:
parent
1c5169b8f8
commit
4ba6fd7bb8
27
CHANGELOG.md
27
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
|
||||
|
|
|
|||
49
src/crdt.rs
49
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");
|
||||
|
|
|
|||
214
src/daemon.rs
214
src/daemon.rs
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue