Merge pull request #15 from levineuwirth/m11.1-semantic

M11: semantic-frontend protocol arc (M11.1–M11.5)
This commit is contained in:
Levi Neuwirth 2026-05-19 01:27:34 +00:00 committed by GitHub
commit 526347cfe2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 3126 additions and 80 deletions

View File

@ -5,6 +5,140 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
#### Semantic-frontend protocol scaffolding (M11.1)
First milestone of the M11 semantic-frontend arc (see
`docs/semantic-frontend-protocol.md`). Wire-format scaffolding only —
no producer or consumer yet; mechanically identical to the M10.5 CRDT
wire declaration.
- `PROTOCOL_VERSION` bumped 2 → 3; `SUPPORTED_PROTOCOL_VERSIONS` now
`[1, 2, 3]`. The slice-membership handshake keeps v0.1/v1.0 binaries
connecting to v1.1 binaries unchanged.
- New `semantic_render` capability bit on `FrontendCapabilities`,
`InstanceCapabilities`, and `NegotiatedCapabilities`
(`#[serde(default)]`; instance default `false` until the M11.2
projection seam lands). `negotiate_capabilities` AND-combines it and
enforces the `semantic_render ⇒ crdt_replica` dependency (a semantic
session is also a text replica) — rejected as a
`CapabilityMismatch`, never silently degraded.
- `InstanceMessage` gains the `SemanticFrame` family: `StyleSpans`,
`Decorations`, `InlineAdornments`, `BlockAdornments`, `FoldState`,
`ResourceOffer`; `FrontendEvent` gains `Viewport`. All anchored in
byte offsets — the instance never learns a pixel.
- `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.
#### Decorations projection (M11.3)
`SemanticRenderState` now also projects `InstanceMessage::Decorations`
from the editor state pmacs actually has instance-side:
- **Selection** — per-window (per-frontend) byte-native state, scoped
to the session's active window for the declared buffer and clipped
to the viewport. `DecorationKind::Selection`.
- **Diagnostics** — the shared `DiagnosticStore`, keyed by the file
URI the LSP glue opened the document under (`lsp::path_to_file_uri`
is now `pub(crate)`, byte-identical to the Lua `file_uri_for`).
LSP `(line, col)` is converted to a byte range against the buffer
source; severity maps to `DiagnosticError`/`Warning`/`Info`/`Hint`.
- `StyleSpans` and `Decorations` suppress unchanged frames
independently — a selection move does not force a styling re-send.
- `Decorations::SearchMatch`/`SearchMatchActive`/`CurrentLine` are not
emitted: pmacs has no instance-side search-hit store, and
current-line is a pure cursor derivation the frontend already owns
via `CursorByte` (emitting it would breach the contract boundary).
- `InlineAdornments`/`BlockAdornments`/`FoldState` remain unproduced
by design — pmacs has no inlay-hint/blame/lens/fold/diff source
yet. The wire variants exist (M11.1); their producers are wired
when those features land. Honest stubs, not fabricated data.
#### Segment diffing (M11.4)
Coarse whole-payload re-sends replaced with a `CellDelta`-style diff,
lifted from positional cells to byte-anchored ranges.
- `StyleSpans`/`Decorations` refined: `{ buffer_id, generation,
full: bool, segments: Vec<…Segment> }`. `full = true` is a resync
(frontend discards prior state for the buffer); `full = false`
ships only the dirty byte regions, each `StyleSegment` /
`DecorationSegment` replacing styling within its range. Bytes in no
segment keep prior state; an unchanged span overlapping a dirty
range is faithfully reconstructed (segments carry *all* current
items intersecting the range, not only changed ones). `Decorations`
also gains `generation` for parity with `StyleSpans`.
- The diff: symmetric difference of the previous/current ordered
item sets, changed ranges coalesced into maximal disjoint dirty
intervals. First frame and any viewport-region change force `full`;
an unchanged frame ships nothing. Byte offsets cascade on edits, so
an incremental frame after an edit dirties `[edit, viewport_end)`
bounded; no-edit frames (cursor/scroll/selection) stay free.
- `ResourceOffer` remains an honest stub (no resource-bearing
adornment producer exists yet) — same discipline as M11.3.
#### Semantic frontend↔instance glue (M11.5)
The arc's consumer side and end-to-end coverage. pmacs has no GUI
toolkit, so — per the design note's testability strategy — the
deliverable is the bounded testable glue, not a GPU renderer.
- New headless `SemanticClient` (`src/semantic_client.rs`, `crdt`-
gated): composes the `BufferMirror` rope replica (M10.10) with a
tile-based `SemanticModel` that reconstructs styling/decorations
from the `full` + dirty-segment deltas (M11.4). Self-contained:
no terminal, no pixels. Emits `FrontendEvent::Viewport`; exposes
read-back accessors (`text`, `effective_style_at`,
`decoration_kinds_at`, tile ranges). The M11.4 contract (segments
carry every current item intersecting their range) makes a tile
self-contained, so incremental application is a clean per-tile
replacement with edge-clipping, not cross-span surgery.
- `tests/m11_5_semantic_acceptance.rs`: (a) reconstruction-
equivalence — an incrementally-driven client is asserted byte-for-
byte identical to a fresh full projection across a scripted
viewport/edit/selection sequence including a viewport jump (the
golden discipline without a snapshot crate); (b) end-to-end —
a real daemon routes `StyleSpans`/`Decorations` to a semantic
session (after it declares a `Viewport`) and never to a grid
session, and `CellDelta` vice versa, validating the M11.2
per-session projection through the socket.
This completes the M11 semantic-frontend arc (M11.1M11.5): wire +
capability scaffolding, the instance-side projection seam,
decorations, segment diffing, and the consumer-side glue with
end-to-end coverage. `InlineAdornments`/`BlockAdornments`/`FoldState`/
`ResourceOffer` remain honest stubs pending their source features.
## [1.0.0] --- 2026-05-18
First stable release. Builds on the 0.1.0 preview (M1M6) with the

View File

@ -0,0 +1,259 @@
# Semantic frontend protocol (design note)
**Status: implemented by the M11 arc (M11.1M11.5), post-v1.0.**
This note was written as a post-v1.0 design draft — recorded so the
v1.0 tag was a conscious decision point — and has since been built
out. It originally concluded the v1.0 wire was *already safe* for
this direction: the capability/version scaffolding in `protocol.rs`
(`SUPPORTED_PROTOCOL_VERSIONS`, `negotiate_capabilities`,
per-session outgoing filters) made the work a non-breaking later
addition, mechanically identical to the M10.5M10.10 CRDT rollout.
It was, and the rollout matched the plan. Implementation status
against this design:
- **M11.1** — wire + capability scaffolding (`semantic_render`,
`PROTOCOL_VERSION` 3, the `SemanticFrame` variant family,
`FrontendEvent::Viewport`).
- **M11.2** — the instance-side projection seam
(`SemanticRenderState`), selected per session; `StyleSpans`.
- **M11.3**`Decorations` from diagnostics + selection.
- **M11.4**`full` + dirty-segment diffing.
- **M11.5** — the headless `SemanticClient` glue + reconstruction-
equivalence and end-to-end tests.
`InlineAdornments` / `BlockAdornments` / `FoldState` /
`ResourceOffer` are declared but deliberately unproduced — pmacs has
no inlay-hint / blame / lens / fold / diff source yet; their
producers wire in when those features land (the same "declared, not
yet wired" discipline this arc used throughout). The "Open
questions" section at the end remains open by design.
The grid path (TUI, SSH, a future GPU terminal-grade frontend)
is unaffected by everything here. This note describes a *second*
projection selected per-session by a negotiated capability, in the
VSCode/Zed "two renderers, one core" shape.
## The content-model decision this assumes
The exploration weighed two models: *semantics-down, layout-local*
(Monaco / VSCode-Remote) versus *layout-down, paint-local* (the
Emacs glyph-matrix end-state). This note draughts the first. The
deciding asymmetry: the optimistic-edit + CRDT-replica substrate
v1.0 already shipped (`optimistic.rs`, the `CrdtOp` flow,
`CursorByte`, the per-session multi-frontend dispatcher) is only
useful if the frontend can lay out a speculative edit locally —
which *is* the semantics-down model. Layout-down would leave that
substrate nearly inert. The one cost consciously accepted: a
slice of rendering correctness (shaping, wrap, hit-testing) moves
into a GPU frontend the instance test harness cannot exercise;
the mitigation is golden-testing the *semantic projection*
instance-side (see "Testability").
## Contract boundary
One sentence, because everything below derives from it:
> The frontend owns the viewport and all visual-motion semantics.
> The instance owns the document and all edit/command semantics.
The instance never learns a pixel. Not viewport pixel size, not
DPI, not font metrics, not glyph advances. This is a deliberate
invariant, not an omission: the moment the instance knows pixels,
it is tempted to lay out, and the model collapses toward
layout-down with per-session reflow caches inside a multi-tenant
daemon — the property pmacs's thesis exists to reject. The only
spatial fact the instance learns is *which buffer byte range is
on screen*, so it can scope its projection rather than ship a
100k-line file's styling.
Corollary — there is **no hit-test round trip**. The frontend
resolves pixel→offset locally (it has the layout) and only ever
emits buffer offsets and edits. Click-to-caret latency is local
and therefore zero over SSH. Re-importing a pixel→offset request
into the wire would forfeit the entire latency argument; it is
prohibited by this contract, not merely discouraged.
## Composition with v1.0 primitives
The semantic projection ships **no text**. A `semantic_render`
session is required to also be a text replica — it holds the rope
locally via the existing `crdt_replica` machinery
(`BufferSnapshot` to bootstrap, `CrdtOp` to stay live). The
semantic frame is purely the *interpretation layer* over a buffer
the frontend already has: styling and decoration keyed by byte
range. This mirrors how v1.0 already coupled `multi_frontend`
and `crdt_replica`, and it keeps the new wire tiny — single-digit
KB for a screenful, diffable at span granularity.
Consequently the new surface is small. Cursor reuses the existing
`InstanceMessage::CursorByte` (authoritative cursor as a buffer
offset — added for CRDT optimistic-apply, exactly what a
layout-local frontend consumes). Peer cursors reuse the existing
`PresenceUpdate`. Edits and local cursor travel the existing
`FrontendEvent::CrdtOp` / presence path. The genuinely new wire
is: one capability bit, ~five instance→frontend interpretation
variants, and one frontend→instance `Viewport` variant.
## Capability and version mechanics
Identical pattern to `crdt_replica`:
- New bit `semantic_render` on `FrontendCapabilities` and
`InstanceCapabilities`, `#[serde(default)]` false — every v1.0
wire byte still deserializes.
- `negotiate_capabilities` AND-combines it into
`NegotiatedCapabilities`; mismatch yields the existing
`Goodbye(CapabilityMismatch { missing: ["semantic_render"] })`.
- `semantic_render` requires `crdt_replica` (text-replica
dependency above). Negotiation rejects `semantic_render: true`
with `crdt_replica: false` as a capability mismatch rather than
silently degrading.
- `PROTOCOL_VERSION` 2 → 3; `SUPPORTED_PROTOCOL_VERSIONS`
`&[1, 2, 3]`. The slice-membership check already in place means
v0.1/v1.0 binaries keep connecting unchanged.
- The daemon's per-session outgoing filter gates the entire
semantic variant family on the negotiated bit. Postcard's
hard-error on unknown variants is mooted exactly as it is for
`CursorByte` (M10.10): a non-semantic session never receives a
variant it cannot decode, because the filter never emits it.
## Instance → frontend: the `SemanticFrame` family
New `InstanceMessage` variants, all gated on negotiated
`semantic_render`, all keyed by `BufferId`, all anchored in
**byte offsets** (consistent with `CursorByte`; line/col is a
rendering concern the frontend derives, CRDT-position is internal
to the replica and not a stable cross-frontend anchor).
A `ByteRange` is `{ start: u64, end: u64 }` (half-open, like the
rope's own ranges).
```rust
/// Syntax + face styling over the frontend's current viewport
/// range. `generation` ties the spans to a CRDT version so the
/// frontend can discard styling that predates an edit it has
/// already applied optimistically.
StyleSpans {
buffer_id: BufferId,
generation: u64,
spans: Vec<StyleSpan>, // { range: ByteRange, style: Style }
},
/// Diagnostics, selection, search hits, current-line, and any
/// other "this region means something" overlay, as offset
/// ranges plus a kind. Peer selection is NOT here — it stays on
/// the existing PresenceUpdate path.
Decorations {
buffer_id: BufferId,
decorations: Vec<Decoration>, // { range: ByteRange, kind: DecorationKind }
},
/// Inlay hints, blame, lens, virtual text. Anchored at a single
/// offset with a placement; content is text+style or a resource
/// handle (images, see ResourceOffer). Occupies no document
/// bytes — the frontend interleaves it at layout time.
InlineAdornments {
buffer_id: BufferId,
items: Vec<InlineAdornment>,
// { at: u64, placement: BeforeLine|EndOfLine|AtOffset, content: AdornmentContent }
},
/// Diff zones, folded-region placeholders, anything occupying
/// its own vertical band. Anchored to an offset (the line it
/// precedes/replaces); the frontend allocates the vertical space.
BlockAdornments {
buffer_id: BufferId,
items: Vec<BlockAdornment>,
},
/// The instance's authoritative fold set, as document facts.
/// The frontend renders the placeholder and adjusts its own
/// layout. Folding is an instance command-semantics concern
/// (Lua can fold); visual collapse is a frontend layout concern.
FoldState {
buffer_id: BufferId,
folds: Vec<ByteRange>,
},
/// Out-of-band content an adornment refers to (images, etc.).
/// Sent once, referenced by handle, so a blame avatar or an
/// inline image is not re-shipped per frame.
ResourceOffer {
handle: u64,
mime: String,
body: ResourceBody, // Inline(Vec<u8>) | Uri(String)
},
```
Each family member diffs against the previous frame the same way
`CellDelta` does today — the instance ships changed spans, not
full re-sends, scoped to the viewport range the frontend last
declared.
## Frontend → instance: `Viewport`
One new `FrontendEvent` variant, gated identically:
```rust
/// The buffer byte range currently on screen, in buffer
/// coordinates. Replaces the instance-derived grid viewport for
/// semantic sessions. `generation` lets the instance ignore a
/// viewport that races a not-yet-applied edit. NO pixels: see
/// the contract boundary invariant.
Viewport {
frontend_id: FrontendId,
buffer_id: BufferId,
visible: ByteRange,
generation: u64,
},
```
That is the *entire* new frontend→instance surface. Cursor,
selection, edits, focus, paste, detach all reuse existing
variants. There is deliberately no `SemanticResize` and no
hit-test request — both would leak pixels across the contract
boundary.
## Instance-side projection seam
`SemanticRenderState`, a sibling of
`instance_render::RenderState`, reading the same `EditorState`.
`RenderState` rasterizes to cells and exits late; `SemanticRenderState`
exits earlier — it emits the structured ranges the cell painter
would have consumed (tree-sitter spans from `syntax.rs`/
`highlight.rs`, overlays from `overlay*.rs`, diagnostics from
`diag.rs`, LSP adornments from `lsp.rs`/`hover.rs`) without the
grid-packing step. The dispatcher selects the projection
**per session, not per buffer**, so a grid frontend and a
semantic frontend can attach to the same buffer simultaneously —
the M10.8 multi-frontend dispatcher already supports per-session
fan-out; this is a constraint on `SemanticRenderState`, not new
dispatcher work.
## Open questions (deliberately unresolved here)
These are the residue of the responsibility migration; they are
design work, not blockers, and none affect the v1.0 tag.
1. **Soft-wrap-dependent commands.** `move-by-visual-line`,
`recenter`, `scroll-by-page` assume the instance knows visual
layout. For semantic sessions it does not. Likely resolution:
the instance emits *intent* ("recenter the cursor") and the
frontend interprets against its layout; commands that are
irreducibly visual become frontend capabilities. Needs a Lua
API story so package authors see one model, not two.
2. **Minimap / whole-file overview.** The frontend only receives
styling for the viewport range. A Zed/VSCode-style minimap of
a 100k-line file needs either a coarse whole-file style
summary variant or frontend-side syntax. Unresolved; leaning
toward a coarse summary variant so the instance stays the
single syntax authority.
3. **Testability strategy.** Recommended: golden-test
`SemanticFrame` sequences instance-side (cleaner than golden
cell grids — they are semantic, not pixel). Accept GPUI's
layout engine as externally battle-tested. This bounds the
untested surface to the frontend↔instance glue, not all
rendering. The existing `audit/` + proptest discipline
extends naturally to semantic-frame goldens.

View File

@ -578,6 +578,11 @@ pub fn build_capabilities() -> FrontendCapabilities {
terminal_kind: std::env::var("TERM").ok(),
multi_frontend: cfg!(feature = "crdt"),
crdt_replica: cfg!(feature = "crdt"),
// T M11.1 — the v0.1/v1.0 TUI is a grid frontend, not a
// semantic (layout-local) renderer. It never consumes the
// SemanticFrame family. A future GPU/GUI frontend sets this
// true; the TUI stays false.
semantic_render: false,
}
}

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

@ -574,6 +574,9 @@ fn cleanup(socket_path: &Path, lock: LockHandle) {
/// anything else / absent → default `true`):
/// - `PMACS_INSTANCE_MULTI_FRONTEND`
/// - `PMACS_INSTANCE_CRDT_REPLICA`
/// - `PMACS_INSTANCE_SEMANTIC_RENDER` (T M11.1; default `false`
/// until the M11.2 projection seam lands, so this env var is the
/// only way to advertise the bit for negotiation tests)
///
/// Production daemons don't set these; tests do.
fn instance_capabilities_with_env_override() -> InstanceCapabilities {
@ -587,6 +590,7 @@ fn instance_capabilities_with_env_override() -> InstanceCapabilities {
InstanceCapabilities {
multi_frontend: env_bool("PMACS_INSTANCE_MULTI_FRONTEND", defaults.multi_frontend),
crdt_replica: env_bool("PMACS_INSTANCE_CRDT_REPLICA", defaults.crdt_replica),
semantic_render: env_bool("PMACS_INSTANCE_SEMANTIC_RENDER", defaults.semantic_render),
}
}
@ -823,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();
@ -841,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
@ -936,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
@ -1032,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);
@ -1069,6 +1097,7 @@ fn dispatcher_loop(
event,
editor,
&mut render_states,
&mut semantic_states,
&mut streams,
&mut term_sizes,
&mut session_registry,
@ -1082,6 +1111,7 @@ fn dispatcher_loop(
event,
editor,
&mut render_states,
&mut semantic_states,
&mut streams,
&mut term_sizes,
&mut session_registry,
@ -1106,10 +1136,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(frontend_id),
);
} 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 +1223,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 +1290,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);
@ -1757,7 +1863,18 @@ fn apply_event(
}
FrontendEvent::Paste { .. }
| FrontendEvent::FocusGained(_)
| FrontendEvent::FocusLost(_) => {
| FrontendEvent::FocusLost(_)
// T M11.1: the semantic-frontend viewport declaration. Its
// consumer is the instance-side projection seam
// (`SemanticRenderState`, M11.2), which scopes the
// SemanticFrame family to this byte range. M11.1 only
// declares the wire shape; no projection seam exists yet and
// the instance advertises `semantic_render: false`, so
// negotiation rejects any session that would emit this — it
// is unreachable in practice. Dropped silently until M11.2
// wires the consumer (same "declared, not yet wired" posture
// CrdtOp had between M10.5 and M10.8).
| FrontendEvent::Viewport { .. } => {
// v0.1: silently ignored. Future work surfaces these
// through Lua hooks (paste-text-fn, focus-changed-hook).
}

View File

@ -368,7 +368,21 @@ impl Frontend {
// byte position (consumed by BufferMirror's cursor tracker
// in attach.rs) drives optimistic-apply. The legacy path
// here only needs grid; the byte variant drops silently.
| InstanceMessage::CursorByte { .. } => {
| InstanceMessage::CursorByte { .. }
// T M11.1: the semantic-frontend projection family. This
// is the grid TUI — it advertises `semantic_render: false`,
// so a v3 daemon never sends these here (the per-session
// outgoing filter, M11.2, gates the family). If one
// arrives anyway it drops silently, same v0.1-ignored
// category as CrdtOp / PresenceUpdate. A semantic
// frontend (M11.5) consumes them via its own layout path,
// not this cell-grid path.
| InstanceMessage::StyleSpans { .. }
| InstanceMessage::Decorations { .. }
| InstanceMessage::InlineAdornments { .. }
| InstanceMessage::BlockAdornments { .. }
| InstanceMessage::FoldState { .. }
| InstanceMessage::ResourceOffer { .. } => {
// v0.1 TUI ignores these; v0.3 GUI consumes them.
}
}

View File

@ -90,6 +90,13 @@ pub mod project;
pub mod project_index;
pub mod protocol;
pub mod rope;
// T M11.5 — the headless semantic consumer composes BufferMirror +
// optimistic (both `crdt`-gated) and is only meaningful on a
// `semantic_render` session, which the negotiation dependency rule
// ties to `crdt_replica`. Gated to match.
#[cfg(feature = "crdt")]
pub mod semantic_client;
pub mod semantic_render;
pub mod signature;
pub mod socket_path;
pub mod syntax;

View File

@ -2452,7 +2452,13 @@ fn canonicalize_root_for_scope(root: &Path) -> PathBuf {
root.canonicalize().unwrap_or_else(|_| root.to_path_buf())
}
fn path_to_file_uri(path: &std::path::Path) -> String {
/// `file://` URI encoder. Byte-identical to
/// `builtin/runtime/lsp.lua`'s `file_uri_for` (same passthrough set),
/// so a URI built here keys into the same `DiagnosticStore` entry the
/// Lua LSP glue opened the document under. T M11.3 reuses this from
/// `crate::semantic_render` for the diagnostics projection — hence
/// `pub(crate)`.
pub(crate) fn path_to_file_uri(path: &std::path::Path) -> String {
// Minimal file:// URI encoder: percent-encode anything outside
// the LSP-friendly set. Adequate for v0.1 (paths in a typical
// project root); a fuller URL crate would be overkill here.

View File

@ -367,6 +367,7 @@ mod tests {
NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: false,
semantic_render: false,
},
0,
)
@ -382,6 +383,7 @@ mod tests {
NegotiatedCapabilities {
multi_frontend: false,
crdt_replica: false,
semantic_render: false,
},
0,
)
@ -665,6 +667,7 @@ mod tests {
NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: false,
},
0,
)
@ -677,6 +680,7 @@ mod tests {
NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: false,
semantic_render: false,
},
0,
)

View File

@ -320,6 +320,38 @@ pub enum FrontendEvent {
/// loro's `import_updates` decodes.
op: crate::rope::CrdtOp,
},
/// T M11.1: the buffer byte range a semantic frontend currently
/// has on screen, in buffer coordinates. Replaces the
/// instance-derived grid viewport for `semantic_render` sessions:
/// the instance scopes its `StyleSpans` / `Decorations` / … to
/// this range rather than shipping a whole file's styling.
///
/// **No pixels.** This carries a byte range, never viewport pixel
/// size, DPI, font metrics, or glyph advances — the contract
/// boundary invariant from the semantic-frontend design note. The
/// frontend owns all visual-motion semantics and resolves
/// pixel→offset locally; there is deliberately no hit-test
/// request variant and no `SemanticResize`, both of which would
/// leak pixels across the boundary.
///
/// `generation` ties the declared range to a CRDT version so the
/// instance can ignore a viewport that races a not-yet-applied
/// edit (symmetric with `StyleSpans::generation`).
///
/// Only emitted by sessions that negotiated `semantic_render`;
/// a non-semantic session never sends it. M11.1 declares the
/// wire shape; the instance-side consumer is wired with the
/// projection seam in M11.2.
Viewport {
/// Which frontend's viewport this is.
frontend_id: FrontendId,
/// Which buffer the visible range indexes into.
buffer_id: crate::buffer::BufferId,
/// Half-open byte range currently on screen.
visible: ByteRange,
/// CRDT generation the frontend computed `visible` against.
generation: u64,
},
}
impl FrontendEvent {
@ -334,7 +366,8 @@ impl FrontendEvent {
| Self::FocusGained(frontend_id)
| Self::FocusLost(frontend_id)
| Self::Detach(frontend_id)
| Self::CrdtOp { frontend_id, .. } => *frontend_id,
| Self::CrdtOp { frontend_id, .. }
| Self::Viewport { frontend_id, .. } => *frontend_id,
}
}
}
@ -581,6 +614,114 @@ pub enum InstanceMessage {
/// converts to `usize` for the loro API.
byte_pos: crate::rope::Position,
},
/// T M11.1 — syntax + face styling over the semantic frontend's
/// current viewport range. `generation` ties the spans to a CRDT
/// version so the frontend can discard styling that predates an
/// edit it has already applied optimistically. Ships **no text** —
/// the frontend holds the rope via the `crdt_replica` machinery
/// and interprets these spans over it.
///
/// # Diff shape (T M11.4)
///
/// Mirrors `CellDelta`'s `full_grid` + changed-runs structure,
/// lifted from positional cells to byte-anchored ranges. `full =
/// true` is a resync: the frontend discards all prior styling for
/// `buffer_id` and the `segments` are authoritative for the whole
/// declared viewport (first frame after a `Viewport`, a viewport
/// jump, or a generation discontinuity). `full = false` is
/// incremental: each [`StyleSegment`] replaces styling **only**
/// within its `range`; bytes covered by no segment keep their
/// previously-applied style. A frame whose styling is unchanged
/// ships no `StyleSpans` at all.
///
/// Because byte offsets cascade on edits (an insert shifts every
/// later span), an incremental frame after an edit dirties
/// `[edit, viewport_end)` — still bounded, and no-edit frames
/// (cursor move, scroll within the declared viewport, selection)
/// cost nothing. Each segment carries *all* current spans
/// intersecting its range (clipped), not only changed ones, so an
/// unchanged span overlapping a dirty range is faithfully
/// reconstructed.
///
/// Gated on negotiated `semantic_render`; never sent to a grid
/// session (the daemon's per-session outgoing filter — wired with
/// the producer in M11.2 — never emits it there, so postcard's
/// hard-error on unknown variants is mooted exactly as it is for
/// `CursorByte`).
StyleSpans {
/// Buffer these spans interpret.
buffer_id: crate::buffer::BufferId,
/// CRDT generation the spans were computed against.
generation: u64,
/// `true` → discard all prior styling for `buffer_id` first;
/// `segments` are authoritative for the declared viewport.
full: bool,
/// Dirty byte regions and the styling now covering them.
segments: Vec<StyleSegment>,
},
/// T M11.1 — diagnostics, search hits, current-line, and any
/// other "this region means something" overlay, as offset ranges
/// plus a kind. Peer selection is **not** here — it stays on the
/// existing `PresenceUpdate` path. Gated on `semantic_render`.
///
/// T M11.4 — same `full` + segment diff shape as `StyleSpans`
/// (see its docs), and gains `generation` for parity: a frontend
/// wants the CRDT version decorations were computed against for
/// the same optimistic-edit race reason styling does.
Decorations {
/// Buffer these decorations apply to.
buffer_id: crate::buffer::BufferId,
/// CRDT generation the decorations were computed against.
generation: u64,
/// `true` → discard all prior decorations for `buffer_id`
/// first; `segments` are authoritative for the viewport.
full: bool,
/// Dirty byte regions and the decorations now covering them.
segments: Vec<DecorationSegment>,
},
/// T M11.1 — inlay hints, blame, lens, virtual text. Anchored at
/// a single offset with a placement; occupies no document bytes —
/// the frontend interleaves it at layout time. Gated on
/// `semantic_render`.
InlineAdornments {
/// Buffer these adornments annotate.
buffer_id: crate::buffer::BufferId,
/// The adornment items for the declared viewport.
items: Vec<InlineAdornment>,
},
/// T M11.1 — diff zones, folded-region placeholders, anything
/// occupying its own vertical band. Anchored to the offset of the
/// line it precedes or replaces; the frontend allocates the
/// vertical space. Gated on `semantic_render`.
BlockAdornments {
/// Buffer these adornments annotate.
buffer_id: crate::buffer::BufferId,
/// The block items for the declared viewport.
items: Vec<BlockAdornment>,
},
/// T M11.1 — the instance's authoritative fold set as document
/// facts. Folding is an instance command-semantics concern (Lua
/// can fold); the visual collapse is a frontend layout concern —
/// the frontend renders the placeholder and adjusts its own
/// layout. Gated on `semantic_render`.
FoldState {
/// Buffer whose fold set this is.
buffer_id: crate::buffer::BufferId,
/// Folded byte ranges.
folds: Vec<ByteRange>,
},
/// T M11.1 — out-of-band content an adornment refers to (images,
/// blame avatars). Sent once, referenced by `handle`, so it is
/// not re-shipped per frame. Gated on `semantic_render`.
ResourceOffer {
/// Stable handle adornments reference via
/// [`AdornmentContent::Resource`].
handle: u64,
/// MIME type of `body`.
mime: String,
/// Inline bytes or a URI the frontend resolves itself.
body: ResourceBody,
},
}
/// Flat selection state for the wire.
@ -604,6 +745,178 @@ pub struct SelectionSnapshot {
pub active: crate::rope::Position,
}
// ---------------------------------------------------------------------------
// T M11.1 — Semantic-frontend projection types
//
// The payloads of the `InstanceMessage::StyleSpans` … `ResourceOffer`
// family and `FrontendEvent::Viewport`. Everything is anchored in
// **byte offsets** (consistent with `CursorByte`): line/col is a
// frontend rendering concern, CRDT position is replica-internal. The
// instance never learns a pixel — see the contract boundary in
// `docs/semantic-frontend-protocol.md`.
//
// The variant/kind sets here are provisional and co-evolve within the
// M11 arc behind the `semantic_render` capability + protocol v3,
// exactly as the CRDT op shape evolved M10.5→M10.10 behind
// `crdt_replica`. They are not a wire-compat hazard for non-semantic
// sessions: the daemon's per-session outgoing filter (wired with the
// producer in M11.2) never emits the family to a session that didn't
// negotiate `semantic_render`, so postcard's hard-error on unknown
// variants is mooted exactly as it is for `CursorByte`.
// ---------------------------------------------------------------------------
/// Half-open byte range `[start, end)` into a buffer's rope, matching
/// the rope's own range convention.
#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ByteRange {
/// Inclusive start byte offset.
pub start: u64,
/// Exclusive end byte offset.
pub end: u64,
}
/// One run of buffer bytes carrying a resolved visual style. The
/// instance is the single syntax/face authority; the frontend lays
/// the style out locally over rope text it already holds. Reuses
/// [`crate::cell::Style`] so the grid and semantic projections share
/// one style vocabulary.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StyleSpan {
/// Byte range this style covers.
pub range: ByteRange,
/// The resolved style (syntax highlight ∘ faces ∘ theme).
pub style: crate::cell::Style,
}
/// T M11.4 — one dirty byte region of an `InstanceMessage::StyleSpans`
/// frame and the styling now covering it. The semantic analog of a
/// `CellDelta` changed-run: the frontend clears styling within
/// `range` and applies `spans` (already clipped to `range`). `spans`
/// is every current span intersecting `range`, not only changed ones,
/// so an unchanged span overlapping the dirty region is preserved.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StyleSegment {
/// The byte region the frontend should clear and repaint.
pub range: ByteRange,
/// Spans intersecting `range`, each clipped to it.
pub spans: Vec<StyleSpan>,
}
/// What a [`Decoration`] region *means*. Provisional variant set (see
/// the module-section note above). Peer selection is deliberately
/// absent — it stays on the `PresenceUpdate` path.
#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum DecorationKind {
/// LSP diagnostic, error severity.
DiagnosticError,
/// LSP diagnostic, warning severity.
DiagnosticWarning,
/// LSP diagnostic, information severity.
DiagnosticInfo,
/// LSP diagnostic, hint severity.
DiagnosticHint,
/// The local selection region.
Selection,
/// A non-active search match.
SearchMatch,
/// The currently-focused search match.
SearchMatchActive,
/// The line containing the cursor.
CurrentLine,
}
/// A byte range tagged with what it means. The frontend decides how
/// to paint each [`DecorationKind`] (squiggle, highlight, gutter
/// mark) — the instance only states the fact.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Decoration {
/// Byte range the decoration covers.
pub range: ByteRange,
/// What the region signifies.
pub kind: DecorationKind,
}
/// T M11.4 — `StyleSegment`'s analog for the `Decorations` family:
/// one dirty byte region and the decorations now covering it (every
/// current decoration intersecting `range`, clipped to it).
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct DecorationSegment {
/// The byte region the frontend should clear and repaint.
pub range: ByteRange,
/// Decorations intersecting `range`, each clipped to it.
pub decorations: Vec<Decoration>,
}
/// Where an [`InlineAdornment`] sits relative to its anchor offset.
#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum AdornmentPlacement {
/// On its own, before the line containing `at`.
BeforeLine,
/// At the end of the line containing `at`.
EndOfLine,
/// Inline, exactly at the byte offset `at`.
AtOffset,
}
/// Adornment payload: either inline styled text, or a handle into a
/// previously-sent [`InstanceMessage::ResourceOffer`] so out-of-band
/// content (images, blame avatars) is shipped once, not per frame.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum AdornmentContent {
/// Styled virtual text.
Text {
/// The virtual text to display.
text: String,
/// Its style.
style: crate::cell::Style,
},
/// A handle into a `ResourceOffer`.
Resource {
/// The offered resource's handle.
handle: u64,
},
}
/// Virtual text occupying no document bytes (inlay hints, blame,
/// lens). Anchored at a single offset; the frontend interleaves it
/// at layout time.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct InlineAdornment {
/// Buffer byte offset this adornment anchors to.
pub at: u64,
/// Placement relative to `at`.
pub placement: AdornmentPlacement,
/// What to render.
pub content: AdornmentContent,
}
/// Content occupying its own vertical band (diff zones, folded-region
/// placeholders). Anchored to the offset of the line it precedes or
/// replaces. `replaces` is `Some` when the band stands in for a
/// collapsed region (the frontend renders the placeholder instead of
/// that range), `None` for an additive band. The frontend allocates
/// the vertical space — the instance never dictates pixel height.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct BlockAdornment {
/// Buffer byte offset of the line this band precedes/replaces.
pub at: u64,
/// The byte range this band stands in for, if it replaces one.
pub replaces: Option<ByteRange>,
/// What to render in the band.
pub content: AdornmentContent,
}
/// The body of an [`InstanceMessage::ResourceOffer`] — inline bytes
/// for small payloads, or a URI the frontend resolves itself for
/// large or remote resources.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum ResourceBody {
/// The resource bytes, carried inline.
Inline(Vec<u8>),
/// A URI the frontend fetches/resolves on its own.
Uri(String),
}
// ---------------------------------------------------------------------------
// Attachment
// ---------------------------------------------------------------------------
@ -1117,20 +1430,34 @@ impl AttachmentHandle {
/// accepted by v1.0 binaries; CRDT-only message variants
/// (`InstanceMessage::CrdtOp`, `FrontendEvent::CrdtOp`) are filtered
/// per-session for v1 negotiated sessions.
pub const PROTOCOL_VERSION: u32 = 2;
///
/// T M11.1: bumped from 2 to 3. The v1.0 wire (version 2) remains
/// accepted; the semantic-frontend variant family
/// (`InstanceMessage::StyleSpans` … `ResourceOffer`,
/// `FrontendEvent::Viewport`) is filtered per-session for sessions
/// that did not negotiate `semantic_render`. Mechanically identical
/// to the M10.5 bump: the slice-membership handshake check (not
/// strict equality) means v0.1/v1.0 binaries keep connecting
/// unchanged, and the new variants simply existing in the enums is
/// not a wire-compat issue for non-semantic sessions because the
/// daemon never emits them to those sessions.
pub const PROTOCOL_VERSION: u32 = 3;
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
/// `[1, 2]` so the version asymmetry the §sec:m10-backward-compat
/// spec section describes is handled symmetrically on both sides.
///
/// The handshake check is "is the peer's `protocol_version` present in
/// T M11.1: extended to `[1, 2, 3]`. v1.1 binaries accept the v0.1
/// (1), v1.0 (2), and semantic-frontend (3) wires. The check remains
/// slice membership — "is the peer's `protocol_version` present in
/// this slice?" — not strict equality on `PROTOCOL_VERSION`. The
/// session's negotiated version (the peer's) is recorded for
/// downstream filtering: v1 sessions don't receive
/// `InstanceMessage::CrdtOp` / `PresenceUpdate` messages even from
/// a v2 daemon.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[1, 2];
/// a v3 daemon, and only sessions that negotiated `semantic_render`
/// receive the `SemanticFrame` variant family.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[1, 2, 3];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].
@ -1234,6 +1561,21 @@ pub struct InstanceCapabilities {
/// broadcast routing for CRDT ops wires up in this milestone.
#[serde(default = "default_true")]
pub crdt_replica: bool,
/// T M11.1: instance can produce the semantic-frontend variant
/// family (`InstanceMessage::StyleSpans` … `ResourceOffer`) and
/// consume `FrontendEvent::Viewport`.
///
/// 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,
}
// Clippy in non-CRDT builds notes that `cfg!(feature = "crdt")`
@ -1255,9 +1597,26 @@ impl Default for InstanceCapabilities {
// is also a CRDT replica; gating both on the same feature
// keeps the daemon's advertised capabilities consistent
// with what it can actually do.
//
// 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: cfg!(feature = "crdt"),
}
}
}
@ -1322,6 +1681,20 @@ pub struct FrontendCapabilities {
/// negotiation; M10.5 declares the bit position.
#[serde(default)]
pub crdt_replica: bool,
/// T M11.1: frontend is a semantic (layout-local) renderer — it
/// consumes the `InstanceMessage::StyleSpans` … `ResourceOffer`
/// family and emits `FrontendEvent::Viewport`. false for v0.1 and
/// v1.0 grid/TUI frontends; a future GPU/GUI frontend opts in.
///
/// A semantic frontend is *required* to also be a text replica:
/// the semantic frame ships no text, so the frontend must hold
/// the rope locally via the `crdt_replica` machinery. This
/// dependency is enforced in [`negotiate_capabilities`], not just
/// documented — declaring `semantic_render: true` without
/// `crdt_replica: true` is a capability mismatch, never a silent
/// degrade.
#[serde(default)]
pub semantic_render: bool,
}
/// T M10.7 — the negotiated capability bits for one attached session.
@ -1349,11 +1722,21 @@ pub struct NegotiatedCapabilities {
/// `crdt_replica = true`. The daemon's outgoing-message filter for
/// `CrdtOp` consults this in M10.8.
pub crdt_replica: bool,
/// T M11.1 — session uses the semantic projection: it
/// produces/consumes the `InstanceMessage::StyleSpans` …
/// `ResourceOffer` family and `FrontendEvent::Viewport`. True iff
/// both sides declared `semantic_render = true` *and* the session
/// also negotiated `crdt_replica = true` (a semantic session is a
/// text replica; see [`negotiate_capabilities`]). The daemon's
/// per-session outgoing filter gates the entire semantic family
/// on this bit — wired with the producer in M11.2.
pub semantic_render: bool,
}
/// T M10.7 — pure-function capability negotiation.
///
/// For each negotiated bit (`multi_frontend`, `crdt_replica`):
/// For each negotiated bit (`multi_frontend`, `crdt_replica`,
/// `semantic_render`):
///
/// | Frontend wants | Instance has | Result |
/// |----------------|--------------|--------|
@ -1366,7 +1749,23 @@ pub struct NegotiatedCapabilities {
/// (returns `Err`). Otherwise the negotiated bits are returned as
/// [`NegotiatedCapabilities`]. The `Err` form gathers ALL missing
/// bits into one `CapabilityMismatch` — one round-trip carries the
/// complete picture rather than serial rejections.
/// complete picture rather than serial rejections. Missing bits are
/// ordered `multi_frontend`, `crdt_replica`, `semantic_render` for
/// deterministic wire output.
///
/// # T M11.1 — the `semantic_render ⇒ crdt_replica` dependency
///
/// A semantic-render session ships no text on the semantic frame;
/// the frontend holds the rope locally via the `crdt_replica`
/// machinery (`BufferSnapshot` to bootstrap, `CrdtOp` to stay live).
/// So `semantic_render` is only coherent on a session that also
/// negotiated `crdt_replica`. When the AND-rule would yield
/// `semantic_render = true` but the session did not also negotiate
/// `crdt_replica = true`, this function rejects with
/// `"semantic_render"` in `missing` rather than silently degrading
/// the session to a text-only replica. The rejected identifier is
/// `"semantic_render"` (the capability whose precondition is unmet),
/// not `"crdt_replica"`.
///
/// # Wire-format stability
///
@ -1396,10 +1795,31 @@ pub fn negotiate_capabilities(
}
(a, b) => a && b,
};
let semantic_render = match (frontend.semantic_render, instance.semantic_render) {
(true, false) => {
missing.push("semantic_render".to_string());
false
}
(a, b) => a && b,
};
// T M11.1 — dependency rule. A semantic session is a text replica
// (the semantic frame carries no text). If both sides declared
// `semantic_render` but the session did not also negotiate
// `crdt_replica`, reject rather than silently degrade. Guard
// against a duplicate push: the only path where `semantic_render`
// is already in `missing` is the `(true, false)` arm above, which
// also sets the local `semantic_render` to false, so the
// condition below cannot re-fire for that case — but the explicit
// membership check keeps this robust against future reordering.
if semantic_render && !crdt_replica && !missing.iter().any(|m| m == "semantic_render") {
missing.push("semantic_render".to_string());
}
let semantic_render = semantic_render && crdt_replica;
if missing.is_empty() {
Ok(NegotiatedCapabilities {
multi_frontend,
crdt_replica,
semantic_render,
})
} else {
Err(GoodbyeReason::CapabilityMismatch { missing })
@ -2577,23 +2997,27 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_two_for_v10() {
// Pin the value: T M10.5 bumped from 1 to 2. The v1.0 wire
// adds CrdtOp / PresenceUpdate variants; the v1.0 binary
// serves both v1 and v2 sessions per §sec:m10-backward-compat.
assert_eq!(PROTOCOL_VERSION, 2);
fn protocol_version_is_three_for_v11() {
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). The v1.1
// binary serves v1, v2, and v3 sessions — the slice-membership
// handshake makes the relaxation symmetric, exactly as M10.5
// did for §sec:m10-backward-compat.
assert_eq!(PROTOCOL_VERSION, 3);
}
#[test]
fn supported_protocol_versions_includes_one_and_two() {
// T M10.5: v1.0 binaries accept both wire versions during the
// handshake. v0.1 binaries (with their strict-equality check)
// accepted only v1; this is the symmetric relaxation that
// makes §sec:m10-backward-compat hold once both binaries ship.
fn supported_protocol_versions_includes_one_two_three() {
// T M10.5: v1.0 binaries accept v1+v2. T M11.1: v1.1 binaries
// accept v1+v2+v3. The check is slice membership, not strict
// equality, so v0.1/v1.0 binaries keep connecting to v1.1
// binaries unchanged. v4+ is rejected until the next bump.
assert!(is_supported_protocol_version(1));
assert!(is_supported_protocol_version(2));
assert!(is_supported_protocol_version(3));
assert!(!is_supported_protocol_version(0));
assert!(!is_supported_protocol_version(3));
assert!(!is_supported_protocol_version(4));
assert!(!is_supported_protocol_version(u32::MAX));
}
@ -2629,6 +3053,7 @@ mod tests {
terminal_kind: Some("xterm-256color".into()),
multi_frontend: false,
crdt_replica: false,
semantic_render: false,
},
initial_size: CellSize::new(50, 200),
};
@ -2945,10 +3370,11 @@ mod tests {
#[test]
fn m10_5_handshake_matrix_versions_outside_range_rejected() {
// v1 daemon's strict-equality behavior is documented at the
// v0.1 code level (different binary); v2 daemon's range check
// rejects v3+ until v0.2 ships.
// v0.1 code level (different binary); the v1.1 daemon's range
// check accepts v1/v2/v3 (T M11.1 added v3) and rejects v4+
// until the next protocol bump.
assert!(!is_supported_protocol_version(0));
assert!(!is_supported_protocol_version(3));
assert!(!is_supported_protocol_version(4));
assert!(!is_supported_protocol_version(u32::MAX));
}
@ -3157,6 +3583,36 @@ mod tests {
InstanceCapabilities {
multi_frontend,
crdt_replica,
..InstanceCapabilities::default()
}
}
/// T M11.1 — caps builder that also sets `semantic_render`, for
/// the semantic-negotiation matrix. The 2-arg `inst_caps` keeps
/// `semantic_render` at its `Default` (`false`) so the existing
/// M10.7 matrix tests are untouched.
fn inst_caps_s(
multi_frontend: bool,
crdt_replica: bool,
semantic_render: bool,
) -> InstanceCapabilities {
InstanceCapabilities {
multi_frontend,
crdt_replica,
semantic_render,
}
}
fn front_caps_s(
multi_frontend: bool,
crdt_replica: bool,
semantic_render: bool,
) -> FrontendCapabilities {
FrontendCapabilities {
multi_frontend,
crdt_replica,
semantic_render,
..FrontendCapabilities::default()
}
}
@ -3287,4 +3743,249 @@ mod tests {
other => panic!("expected CapabilityMismatch, got {other:?}"),
}
}
// T M11.1 — semantic_render negotiation matrix + the
// semantic_render ⇒ crdt_replica dependency rule.
#[test]
fn negotiate_semantic_render_both_sides_with_crdt() {
// The only success shape: both sides want semantic_render AND
// the session also negotiates crdt_replica (the text-replica
// dependency). semantic_render true implies crdt_replica true.
let res = negotiate_capabilities(
&front_caps_s(true, true, true),
&inst_caps_s(true, true, true),
)
.expect("ok");
assert!(res.crdt_replica);
assert!(res.semantic_render);
}
#[test]
fn negotiate_semantic_render_frontend_silent() {
// Instance offers semantic_render; frontend doesn't ask. The
// subset (no semantic projection) is accepted, no error —
// identical posture to the multi_frontend/crdt_replica
// "frontend silent" case.
let res = negotiate_capabilities(
&front_caps_s(false, false, false),
&inst_caps_s(true, true, true),
)
.expect("ok");
assert!(!res.semantic_render);
assert!(!res.crdt_replica);
}
#[test]
fn negotiate_semantic_render_frontend_wants_instance_lacks() {
// Frontend wants crdt+semantic; instance has crdt but not the
// semantic projection (the M11.1 reality until M11.2 flips
// the instance default). Only semantic_render is missing.
let err = negotiate_capabilities(
&front_caps_s(false, true, true),
&inst_caps_s(false, true, false),
)
.expect_err("should mismatch");
match err {
GoodbyeReason::CapabilityMismatch { missing } => {
assert_eq!(missing, vec!["semantic_render".to_string()]);
}
other => panic!("expected CapabilityMismatch, got {other:?}"),
}
}
#[test]
fn negotiate_semantic_render_requires_crdt_replica_dependency() {
// Both sides declare semantic_render, but the frontend did
// NOT request crdt_replica. The AND-rule alone would yield
// semantic_render=true; the dependency rule rejects instead
// of silently degrading to a text-only replica. The rejected
// identifier is "semantic_render" (the capability whose
// precondition is unmet), not "crdt_replica".
let err = negotiate_capabilities(
&front_caps_s(false, false, true),
&inst_caps_s(false, true, true),
)
.expect_err("should mismatch");
match err {
GoodbyeReason::CapabilityMismatch { missing } => {
assert_eq!(missing, vec!["semantic_render".to_string()]);
}
other => panic!("expected CapabilityMismatch, got {other:?}"),
}
}
#[test]
fn negotiate_semantic_render_dependency_orders_after_crdt_replica() {
// Frontend wants crdt+semantic; instance has the semantic
// projection but lacks crdt. crdt_replica fails the AND-rule
// (true,false) → "crdt_replica"; the dependency rule then
// appends "semantic_render". Deterministic order:
// [crdt_replica, semantic_render]. No duplicate semantic_render.
let err = negotiate_capabilities(
&front_caps_s(false, true, true),
&inst_caps_s(false, false, true),
)
.expect_err("should mismatch");
match err {
GoodbyeReason::CapabilityMismatch { missing } => {
assert_eq!(
missing,
vec!["crdt_replica".to_string(), "semantic_render".to_string()]
);
}
other => panic!("expected CapabilityMismatch, got {other:?}"),
}
}
#[test]
fn negotiate_ok_semantic_render_always_implies_crdt_replica() {
// Invariant: every successful negotiation with
// semantic_render=true also has crdt_replica=true. Exhaust
// the 2³ declared-bit combinations on each side that the
// helpers can express; any Ok with semantic_render must carry
// crdt_replica.
for fc in [false, true] {
for fr in [false, true] {
for fs in [false, true] {
for ic in [false, true] {
for ir in [false, true] {
for is in [false, true] {
if let Ok(neg) = negotiate_capabilities(
&front_caps_s(fc, fr, fs),
&inst_caps_s(ic, ir, is),
) && neg.semantic_render
{
assert!(
neg.crdt_replica,
"semantic_render without crdt_replica leaked through \
negotiation: front=({fc},{fr},{fs}) inst=({ic},{ir},{is})"
);
}
}
}
}
}
}
}
}
#[test]
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,
"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
// FrontendEvent::Viewport. Mirrors the M10.x variant round-trip
// tests: encode → decode → structural equality.
#[test]
fn semantic_frame_family_round_trips_through_postcard() {
let bid = crate::buffer::BufferId::next();
let msgs = vec![
InstanceMessage::StyleSpans {
buffer_id: bid,
generation: 7,
full: true,
segments: vec![StyleSegment {
range: ByteRange { start: 0, end: 12 },
spans: vec![StyleSpan {
range: ByteRange { start: 0, end: 12 },
style: crate::cell::Style::default(),
}],
}],
},
InstanceMessage::Decorations {
buffer_id: bid,
generation: 7,
full: false,
segments: vec![DecorationSegment {
range: ByteRange { start: 3, end: 20 },
decorations: vec![
Decoration {
range: ByteRange { start: 3, end: 9 },
kind: DecorationKind::DiagnosticError,
},
Decoration {
range: ByteRange { start: 20, end: 20 },
kind: DecorationKind::CurrentLine,
},
],
}],
},
InstanceMessage::InlineAdornments {
buffer_id: bid,
items: vec![InlineAdornment {
at: 42,
placement: AdornmentPlacement::EndOfLine,
content: AdornmentContent::Text {
text: "→ i32".to_string(),
style: crate::cell::Style::default(),
},
}],
},
InstanceMessage::BlockAdornments {
buffer_id: bid,
items: vec![BlockAdornment {
at: 64,
replaces: Some(ByteRange { start: 64, end: 256 }),
content: AdornmentContent::Resource { handle: 1 },
}],
},
InstanceMessage::FoldState {
buffer_id: bid,
folds: vec![ByteRange { start: 100, end: 400 }],
},
InstanceMessage::ResourceOffer {
handle: 1,
mime: "image/png".to_string(),
body: ResourceBody::Inline(vec![0x89, b'P', b'N', b'G']),
},
InstanceMessage::ResourceOffer {
handle: 2,
mime: "image/svg+xml".to_string(),
body: ResourceBody::Uri("file:///tmp/blame.svg".to_string()),
},
];
for msg in msgs {
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(msg, decoded);
}
}
#[test]
fn frontend_event_viewport_round_trips_through_postcard() {
let ev = FrontendEvent::Viewport {
frontend_id: FrontendId(4),
buffer_id: crate::buffer::BufferId::next(),
visible: ByteRange {
start: 1_024,
end: 4_096,
},
generation: 99,
};
let bytes = postcard::to_allocvec(&ev).expect("encode");
let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(ev, decoded);
// The contract-boundary invariant, asserted structurally:
// frontend_id() must resolve for the new variant (it is part
// of the per-frontend routing alternation).
assert_eq!(decoded.frontend_id(), FrontendId(4));
}
}

532
src/semantic_client.rs Normal file
View File

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

886
src/semantic_render.rs Normal file
View File

@ -0,0 +1,886 @@
// 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, Decoration, DecorationKind, DecorationSegment, FrontendId, InstanceMessage,
StyleSegment, 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,
}
/// The diff baseline for one family on one buffer: the
/// declared-viewport region the set was computed for, and the full
/// scoped item set last shipped. The next frame diffs against
/// `items`; `visible` changing (or no entry) forces a `full` resync.
/// The frame's `generation` is recomputed each tick and carried on
/// the wire, so it is not retained here.
struct LastFrame<T> {
visible: ByteRange,
items: Vec<T>,
}
/// Owns one `semantic_render` session's projection state: the last
/// viewport the frontend declared, and the diff baseline per buffer
/// for the `StyleSpans` and `Decorations` families.
pub struct SemanticRenderState {
/// The session this projection serves. Selection is per-window
/// (per-frontend) state, so the decoration projection needs the
/// fid to resolve *this* session's active window via
/// `active_window_for`. Styling and diagnostics are per-buffer and
/// do not consult it.
frontend_id: FrontendId,
/// `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>,
/// Styling diff baseline, keyed by buffer (T M11.4). An unchanged
/// frame ships nothing; a changed frame ships only the dirty
/// byte-range segments.
last_sent: HashMap<BufferId, LastFrame<StyleSpan>>,
/// Decorations diff baseline, tracked independently of `last_sent`
/// so a styling change does not force a decorations re-send and
/// vice versa.
last_decorations: HashMap<BufferId, LastFrame<Decoration>>,
}
impl SemanticRenderState {
/// Fresh session state for frontend `frontend_id`: no viewport
/// declared, nothing sent.
#[must_use]
pub fn new(frontend_id: FrontendId) -> Self {
Self {
frontend_id,
viewport: None,
last_sent: HashMap::new(),
last_decorations: HashMap::new(),
}
}
/// 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 up to two messages — an [`InstanceMessage::StyleSpans`]
/// (T M11.2) and an [`InstanceMessage::Decorations`] (T M11.3) —
/// each scoped to the declared viewport and each suppressed
/// independently when byte-identical to its last send at the same
/// generation. Returns an empty vec before the frontend declares a
/// viewport.
///
/// `InlineAdornments` / `BlockAdornments` / `FoldState` are
/// deliberately *not* produced: pmacs has no instance-side inlay-
/// hint / blame / lens / fold / diff source yet. The wire variants
/// exist (T M11.1); their producers are wired when those features
/// land — the same "declared, not yet wired" discipline M11.1
/// applied to the whole family. Emitting empty messages every
/// frame would be waste, not honesty.
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 mut out = Vec::new();
// --- StyleSpans (T M11.2 producer, T M11.4 diff) ---
let spans = scoped_style_spans(state, &vp);
let prev = self.last_sent.get(&vp.buffer_id);
// Resync when there is no baseline, or the declared viewport
// region moved (the scoping window changed, so prior styling
// is no longer positioned correctly).
let full = prev.is_none_or(|p| p.visible != vp.visible);
if full {
// The first frame for this buffer/viewport. One segment
// covering the declared viewport carries the whole scoped
// set (possibly empty → frontend clears the viewport).
self.last_sent.insert(
vp.buffer_id,
LastFrame {
visible: vp.visible,
items: spans.clone(),
},
);
out.push(InstanceMessage::StyleSpans {
buffer_id: vp.buffer_id,
generation,
full: true,
segments: vec![StyleSegment {
range: vp.visible,
spans,
}],
});
} else {
let prev = prev.expect("checked is_none_or above");
let intervals = changed_intervals(&prev.items, &spans, |s| s.range);
if !intervals.is_empty() {
let segments = intervals
.into_iter()
.map(|range| StyleSegment {
range,
spans: clip_style_spans(range, &spans),
})
.collect();
self.last_sent.insert(
vp.buffer_id,
LastFrame {
visible: vp.visible,
items: spans,
},
);
out.push(InstanceMessage::StyleSpans {
buffer_id: vp.buffer_id,
generation,
full: false,
segments,
});
}
// No dirty interval → styling unchanged → emit nothing.
}
// --- Decorations (T M11.3 producer, T M11.4 diff) ---
let decorations = self.scoped_decorations(state, &vp);
let prev = self.last_decorations.get(&vp.buffer_id);
let full = prev.is_none_or(|p| p.visible != vp.visible);
if full {
self.last_decorations.insert(
vp.buffer_id,
LastFrame {
visible: vp.visible,
items: decorations.clone(),
},
);
out.push(InstanceMessage::Decorations {
buffer_id: vp.buffer_id,
generation,
full: true,
segments: vec![DecorationSegment {
range: vp.visible,
decorations,
}],
});
} else {
let prev = prev.expect("checked is_none_or above");
let intervals = changed_intervals(&prev.items, &decorations, |d| d.range);
if !intervals.is_empty() {
let segments = intervals
.into_iter()
.map(|range| DecorationSegment {
range,
decorations: clip_decorations(range, &decorations),
})
.collect();
self.last_decorations.insert(
vp.buffer_id,
LastFrame {
visible: vp.visible,
items: decorations,
},
);
out.push(InstanceMessage::Decorations {
buffer_id: vp.buffer_id,
generation,
full: false,
segments,
});
}
}
out
}
/// Project the [`Decoration`] set intersecting the declared
/// viewport: the session's selection (instance-authoritative,
/// byte-native) and LSP diagnostics (line/col → byte, severity →
/// kind). Search-hit and current-line decorations are
/// deliberately absent: pmacs has no instance-side search-hit
/// store, and current-line is a pure cursor derivation the
/// frontend already owns (it has `CursorByte`) — emitting it would
/// couple a visual-motion concern to the instance, against the
/// contract boundary.
fn scoped_decorations(
&self,
state: &EditorState,
vp: &DeclaredViewport,
) -> Vec<Decoration> {
let core = state.core.borrow();
let mut out = Vec::new();
// Selection — per-window (per-frontend) state, already byte
// offsets. Only this session's active window for the declared
// buffer contributes.
if let Some(win) = core.active_window_for(self.frontend_id)
&& win.buffer_id == vp.buffer_id
&& let Some((lo, hi)) = win.region()
&& let Some(range) = clip_to_viewport(lo, hi, vp)
{
out.push(Decoration {
range,
kind: DecorationKind::Selection,
});
}
// Diagnostics — keyed in the shared store by the file URI the
// Lua LSP glue opened the document under. `core.file_path` is
// the editor's active file path; encoding it with the shared
// `path_to_file_uri` reproduces that exact key (the Lua
// `file_uri_for` is byte-identical). A buffer with no file
// path, or no diagnostics under its URI, contributes nothing.
if let Some(path) = core.file_path.as_ref() {
let uri = crate::lsp::path_to_file_uri(path);
let diags = {
let store = state.lsp_manager.borrow().diag_store();
let guard = store.lock().expect("diag store mutex poisoned");
guard.for_uri(&uri).to_vec()
};
if !diags.is_empty() {
let registry = core.registry.clone();
let reg = registry.borrow();
if let Ok(buf) = reg.get(vp.buffer_id) {
let source = buffer_source_bytes(buf);
let line_starts = line_start_offsets(&source);
for d in &diags {
let lo = line_col_to_byte(
&line_starts,
source.len() as u64,
d.start_line,
d.start_col,
);
let hi = line_col_to_byte(
&line_starts,
source.len() as u64,
d.end_line,
d.end_col,
);
if let Some(range) = clip_to_viewport(lo, hi, vp) {
out.push(Decoration {
range,
kind: severity_to_kind(d.severity),
});
}
}
}
}
}
out
}
}
/// Intersect `[lo, hi)` with the declared viewport (itself clamped to
/// the source length is the caller's concern for styling; for
/// decorations we clamp against the viewport only). `None` when the
/// intersection is empty or degenerate.
fn clip_to_viewport(lo: u64, hi: u64, vp: &DeclaredViewport) -> Option<ByteRange> {
let start = lo.max(vp.visible.start);
let end = hi.min(vp.visible.end);
if end <= start {
return None;
}
Some(ByteRange { start, end })
}
/// T M11.4 — the dirty byte intervals between two ordered item sets.
///
/// Items are byte-anchored (`range_of` extracts the range). The
/// symmetric difference (items in exactly one set, by `==`) bounds
/// every byte whose covering set changed; its ranges are coalesced
/// into maximal disjoint intervals — the segments the frontend will
/// clear and repaint. Empty result ⇒ unchanged ⇒ the caller emits
/// nothing.
///
/// O(n·m) membership scans: a screenful is a few hundred items, far
/// cheaper than re-shipping the whole viewport every frame, and only
/// runs when the fast `prev == curr` slice check (caller side, via
/// the order-stable producers) would have failed anyway.
fn changed_intervals<T: PartialEq>(
prev: &[T],
curr: &[T],
range_of: impl Fn(&T) -> ByteRange,
) -> Vec<ByteRange> {
let mut changed: Vec<ByteRange> = Vec::new();
for p in prev {
if !curr.contains(p) {
changed.push(range_of(p));
}
}
for c in curr {
if !prev.contains(c) {
changed.push(range_of(c));
}
}
coalesce_ranges(&mut changed)
}
/// Sort and merge overlapping or touching ranges into maximal
/// disjoint intervals. Zero-width ranges are dropped (nothing to
/// repaint). Consumes `ranges` (sorts in place).
fn coalesce_ranges(ranges: &mut Vec<ByteRange>) -> Vec<ByteRange> {
ranges.retain(|r| r.end > r.start);
ranges.sort_by_key(|r| (r.start, r.end));
let mut out: Vec<ByteRange> = Vec::new();
for r in ranges.iter().copied() {
match out.last_mut() {
// Touching (`>=`) merges too: adjacent dirty ranges become
// one segment rather than two abutting clears.
Some(last) if r.start <= last.end => last.end = last.end.max(r.end),
_ => out.push(r),
}
}
out
}
/// Every span intersecting `iv`, clipped to it, order preserved.
fn clip_style_spans(iv: ByteRange, spans: &[StyleSpan]) -> Vec<StyleSpan> {
spans
.iter()
.filter_map(|s| {
let start = s.range.start.max(iv.start);
let end = s.range.end.min(iv.end);
(end > start).then_some(StyleSpan {
range: ByteRange { start, end },
style: s.style,
})
})
.collect()
}
/// Every decoration intersecting `iv`, clipped to it, order preserved.
fn clip_decorations(iv: ByteRange, decos: &[Decoration]) -> Vec<Decoration> {
decos
.iter()
.filter_map(|d| {
let start = d.range.start.max(iv.start);
let end = d.range.end.min(iv.end);
(end > start).then_some(Decoration {
range: ByteRange { start, end },
kind: d.kind,
})
})
.collect()
}
/// Map an LSP diagnostic severity onto the wire decoration kind.
fn severity_to_kind(sev: crate::diag::DiagnosticSeverity) -> DecorationKind {
use crate::diag::DiagnosticSeverity as S;
match sev {
S::Error => DecorationKind::DiagnosticError,
S::Warning => DecorationKind::DiagnosticWarning,
S::Information => DecorationKind::DiagnosticInfo,
S::Hint => DecorationKind::DiagnosticHint,
}
}
/// Snapshot a buffer's bytes (refcount-cheap rope slice, mirroring
/// `diag.rs`'s render-time snapshot).
fn buffer_source_bytes(buf: &crate::buffer::Buffer) -> Vec<u8> {
let len = buf.len();
let mut bytes = vec![0u8; len as usize];
if !bytes.is_empty() {
buf.snapshot_rope().slice(0, len, &mut bytes);
}
bytes
}
/// Byte offset of the start of each line (index 0 = byte 0; one entry
/// per line, where a line is a maximal run ended by `\n`).
fn line_start_offsets(source: &[u8]) -> Vec<u64> {
let mut starts = vec![0u64];
for (i, b) in source.iter().enumerate() {
if *b == b'\n' {
starts.push(i as u64 + 1);
}
}
starts
}
/// Translate an LSP `(line, col)` to a byte offset. pmacs v0.1 treats
/// the LSP column as a byte offset within the line (see
/// `crate::diag::Diagnostic`'s field docs); we clamp to the line's
/// end and the source length so a stale diagnostic from before an
/// edit can never index out of range.
fn line_col_to_byte(line_starts: &[u64], source_len: u64, line: u32, col: u32) -> u64 {
let li = line as usize;
let Some(&line_start) = line_starts.get(li) else {
return source_len;
};
let line_end = line_starts
.get(li + 1)
.map_or(source_len, |&next| next.saturating_sub(1));
(line_start + u64::from(col)).min(line_end).min(source_len)
}
/// 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()
}
fn local() -> SemanticRenderState {
// FrontendId::LOCAL always has a registered FrontendView
// (EditorCore invariant), so `active_window_for(LOCAL)` — the
// selection projection's lookup — resolves in a fresh editor.
SemanticRenderState::new(FrontendId::LOCAL)
}
fn active_buffer(state: &EditorState) -> BufferId {
state.core.borrow().active_window().buffer_id
}
/// All `InstanceMessage` variants the semantic projection may
/// emit are `StyleSpans` or `Decorations` — never `CellDelta`,
/// grid `Cursor`, or the not-yet-wired adornment/fold families.
fn assert_semantic_only(msgs: &[InstanceMessage]) {
for m in msgs {
assert!(
matches!(
m,
InstanceMessage::StyleSpans { .. } | InstanceMessage::Decorations { .. }
),
"semantic projection emitted an unexpected variant: {m:?}"
);
}
}
/// Find the `Decorations` message and flatten its segments into
/// `(full, all decorations across segments)`.
fn decorations_of(msgs: &[InstanceMessage]) -> Option<(bool, Vec<Decoration>)> {
msgs.iter().find_map(|m| match m {
InstanceMessage::Decorations { full, segments, .. } => Some((
*full,
segments.iter().flat_map(|s| s.decorations.clone()).collect(),
)),
_ => None,
})
}
/// Find the `StyleSpans` message: `(full, segment ranges)`.
fn style_segments(msgs: &[InstanceMessage]) -> Option<(bool, Vec<ByteRange>)> {
msgs.iter().find_map(|m| match m {
InstanceMessage::StyleSpans { full, segments, .. } => {
Some((*full, segments.iter().map(|s| s.range).collect()))
}
_ => None,
})
}
fn set_selection(state: &EditorState, anchor: u64, cursor: u64) {
let mut core = state.core.borrow_mut();
let win = core
.active_window_mut_for(FrontendId::LOCAL)
.expect("LOCAL always has a window");
win.selection = Some(crate::window::Selection { anchor });
win.cursor = cursor;
}
fn seed_diagnostic(state: &EditorState, buffer_id: BufferId) {
let mut core = state.core.borrow_mut();
core.registry
.clone()
.borrow_mut()
.get_mut(buffer_id)
.expect("active buffer")
.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"abc\nde",
})
.expect("seed buffer text");
core.file_path = Some(std::path::PathBuf::from("/tmp/m114.rs"));
drop(core);
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m114.rs"));
let store = state.lsp_manager.borrow().diag_store();
store.lock().expect("diag store").set(
&uri,
vec![crate::diag::Diagnostic {
start_line: 1,
start_col: 0,
end_line: 1,
end_col: 2,
severity: crate::diag::DiagnosticSeverity::Warning,
message: "x".into(),
source: None,
code: None,
}],
);
}
#[test]
fn emits_nothing_before_viewport_declared() {
let mut s = local();
assert!(
s.render_frame(&empty_state()).is_empty(),
"nothing may be emitted before the frontend declares a viewport"
);
}
#[test]
fn first_post_viewport_frame_is_full_for_both_then_suppresses() {
let state = empty_state();
let mut s = local();
let buffer_id = active_buffer(&state);
s.set_viewport(buffer_id, ByteRange { start: 0, end: 4096 }, 0);
// Empty scratch: no spans, no selection, no diagnostics — but
// the first frame is a `full` resync for both families (the
// frontend clears its viewport), carrying empty segments.
let first = s.render_frame(&state);
assert_eq!(first.len(), 2, "first frame ships StyleSpans + Decorations");
assert_semantic_only(&first);
let (style_full, _) = style_segments(&first).expect("StyleSpans present");
let (deco_full, decos) = decorations_of(&first).expect("Decorations present");
assert!(style_full, "first styling frame must be full");
assert!(deco_full, "first decorations frame must be full");
assert!(decos.is_empty(), "empty scratch has no decorations");
// Nothing changed → both families suppressed.
assert!(
s.render_frame(&state).is_empty(),
"an unchanged frame must be fully suppressed"
);
}
#[test]
fn selection_projects_as_a_decoration_clipped_to_viewport() {
let state = empty_state();
let buffer_id = active_buffer(&state);
// region (2,5) on LOCAL's window; region() compares offsets
// only, so the empty scratch buffer is fine here.
set_selection(&state, 2, 5);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 3, end: 64 }, 0);
let msgs = s.render_frame(&state);
assert_semantic_only(&msgs);
let (full, decos) = decorations_of(&msgs).expect("a Decorations message");
assert!(full, "first frame is a full resync");
assert_eq!(decos.len(), 1, "exactly the selection decoration");
assert_eq!(decos[0].kind, DecorationKind::Selection);
// region (2,5) clipped to viewport [3,64) → [3,5).
assert_eq!(decos[0].range, ByteRange { start: 3, end: 5 });
}
#[test]
fn diagnostics_project_with_line_col_to_byte_and_severity() {
// "abc\nde": line 0 at byte 0, line 1 at byte 4.
let state = empty_state();
let buffer_id = active_buffer(&state);
seed_diagnostic(&state, buffer_id);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let (_full, decos) =
decorations_of(&s.render_frame(&state)).expect("a Decorations message");
assert_eq!(decos.len(), 1);
assert_eq!(decos[0].kind, DecorationKind::DiagnosticWarning);
// line 1 starts at byte 4; cols [0,2) → bytes [4,6).
assert_eq!(decos[0].range, ByteRange { start: 4, end: 6 });
}
#[test]
fn styles_and_decorations_suppress_independently() {
let state = empty_state();
let buffer_id = active_buffer(&state);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let _ = s.render_frame(&state); // first frame: both full
assert!(s.render_frame(&state).is_empty(), "steady state silent");
// A selection appears → only Decorations re-emits, and as an
// incremental (full = false) frame since the viewport region
// did not move.
set_selection(&state, 1, 4);
let msgs = s.render_frame(&state);
assert_eq!(msgs.len(), 1, "only the changed family re-emits");
let (full, decos) = decorations_of(&msgs).expect("Decorations re-emitted");
assert!(!full, "viewport unchanged → incremental, not full");
assert_eq!(decos.len(), 1);
assert_eq!(decos[0].kind, DecorationKind::Selection);
}
#[test]
fn full_resync_on_viewport_region_change() {
let state = empty_state();
let buffer_id = active_buffer(&state);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let _ = s.render_frame(&state); // full
assert!(s.render_frame(&state).is_empty(), "unchanged → silent");
// Declaring a different on-screen range forces a full resync:
// prior styling/decorations are positioned for the old window.
s.set_viewport(buffer_id, ByteRange { start: 200, end: 264 }, 0);
let msgs = s.render_frame(&state);
let (style_full, _) = style_segments(&msgs).expect("StyleSpans");
let (deco_full, _) = decorations_of(&msgs).expect("Decorations");
assert!(style_full && deco_full, "viewport jump must be a full resync");
}
#[test]
fn incremental_decoration_change_ships_only_dirty_intervals() {
let state = empty_state();
let buffer_id = active_buffer(&state);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 256 }, 0);
set_selection(&state, 10, 12);
let _ = s.render_frame(&state); // full: selection [10,12)
assert!(s.render_frame(&state).is_empty());
// Move the selection far away. The symmetric difference is the
// old range [10,12) (removed) and the new [40,42) (added);
// they are disjoint and non-adjacent → two segments.
set_selection(&state, 40, 42);
let msgs = s.render_frame(&state);
let deco_msg = msgs
.iter()
.find_map(|m| match m {
InstanceMessage::Decorations { full, segments, .. } => Some((*full, segments)),
_ => None,
})
.expect("Decorations");
assert!(!deco_msg.0, "incremental");
let ranges: Vec<ByteRange> = deco_msg.1.iter().map(|s| s.range).collect();
assert_eq!(
ranges,
vec![
ByteRange { start: 10, end: 12 },
ByteRange { start: 40, end: 42 }
],
"two disjoint dirty intervals: old (cleared) + new"
);
// The [10,12) segment carries no decorations (selection moved
// away → frontend clears it); [40,42) carries the new one.
let s1 = &deco_msg.1[0];
assert!(s1.decorations.is_empty(), "old selection range cleared");
let s2 = &deco_msg.1[1];
assert_eq!(s2.decorations.len(), 1);
assert_eq!(s2.decorations[0].kind, DecorationKind::Selection);
}
#[test]
fn unchanged_decoration_overlapping_a_dirty_interval_is_reconstructed() {
// A diagnostic at [4,6) never changes; the selection moves to
// overlap it. The dirty segment must still carry the (clipped)
// diagnostic so the frontend, replacing styling within the
// range, faithfully reconstructs the unchanged decoration.
let state = empty_state();
let buffer_id = active_buffer(&state);
seed_diagnostic(&state, buffer_id); // Warning [4,6)
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
set_selection(&state, 20, 22);
let _ = s.render_frame(&state); // full: Sel[20,22) + Warn[4,6)
assert!(s.render_frame(&state).is_empty());
// Selection moves to [5,7), overlapping the diagnostic.
set_selection(&state, 5, 7);
let msgs = s.render_frame(&state);
let (_full, segs) = msgs
.iter()
.find_map(|m| match m {
InstanceMessage::Decorations { full, segments, .. } => Some((*full, segments)),
_ => None,
})
.expect("Decorations");
// The segment covering [5,7) must include the unchanged,
// overlapping diagnostic (clipped into the dirty range),
// not just the moved selection.
let overlapping = segs
.iter()
.find(|s| s.range.start <= 5 && s.range.end >= 6)
.expect("a segment covering the diagnostic's bytes");
assert!(
overlapping
.decorations
.iter()
.any(|d| d.kind == DecorationKind::DiagnosticWarning),
"unchanged overlapping diagnostic must be reconstructed in the dirty segment"
);
}
#[test]
fn adornment_and_fold_families_are_never_emitted() {
// M11.3 honest-stub contract: InlineAdornments / BlockAdornments
// / FoldState have no instance-side source yet, so the
// projection never produces them (not even empty ones).
let state = empty_state();
let buffer_id = active_buffer(&state);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
for _ in 0..3 {
for m in s.render_frame(&state) {
assert!(
!matches!(
m,
InstanceMessage::InlineAdornments { .. }
| InstanceMessage::BlockAdornments { .. }
| InstanceMessage::FoldState { .. }
),
"a not-yet-wired adornment/fold family was emitted: {m:?}"
);
}
}
}
#[test]
fn sibling_of_render_state_reads_same_editor_state() {
// The dispatcher selects the projection per session, not per
// buffer: a grid RenderState and a SemanticRenderState observe
// the same EditorState without interfering.
let state = empty_state();
let mut grid = RenderState::new(CellSize::new(24, 80));
let mut sem = local();
let buffer_id = active_buffer(&state);
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_semantic_only(&sem_msgs);
assert!(
!sem_msgs
.iter()
.any(|m| matches!(m, InstanceMessage::CellDelta { .. })),
"semantic projection never produces CellDelta"
);
}
}

View File

@ -199,6 +199,7 @@ mod tests {
terminal_kind: Some("xterm-256color".into()),
multi_frontend: false,
crdt_replica: false,
semantic_render: false,
},
initial_size: crate::cell::CellSize::new(24, 80),
};

View File

@ -230,6 +230,7 @@ pub fn build_default_caps() -> FrontendCapabilities {
terminal_kind: Some("test".into()),
multi_frontend: false,
crdt_replica: false,
semantic_render: false,
}
}

View File

@ -215,6 +215,7 @@ impl Observer {
terminal_kind: Some("test-observer".into()),
multi_frontend: true,
crdt_replica: true,
semantic_render: false,
};
let req = AttachRequest {
protocol_version: PROTOCOL_VERSION,

View File

@ -0,0 +1,328 @@
// m11_5_semantic_acceptance.rs --- M11.5 acceptance: the semantic frontend↔instance glue.
//! T M11.5 acceptance suite for the semantic-frontend arc.
//!
//! Two paths, both exercising the headless [`SemanticClient`] — the
//! frontend↔instance glue the design note names as the bounded
//! testable surface (`docs/semantic-frontend-protocol.md`,
//! "Testability strategy"):
//!
//! - **Reconstruction-equivalence (instance-side, deterministic).**
//! Drive a [`SemanticRenderState`] through a scripted sequence of
//! viewport declarations and editor mutations, feed every emitted
//! message into a `SemanticClient`, and assert the client's
//! incrementally-reconstructed view is byte-for-byte identical to a
//! *fresh full* projection of the same instant (the oracle). This
//! is the golden discipline without a snapshot crate: the property
//! asserted is "incremental ≡ from-scratch", which no incidental
//! wire-shape churn can falsely pass.
//!
//! - **End-to-end daemon filter.** A real daemon, a semantic session
//! (negotiating `semantic_render`, declaring a `Viewport`) and a
//! grid session: prove the M11.2 per-session projection actually
//! routes `StyleSpans`/`Decorations` to the semantic session and
//! never to the grid one, and `CellDelta` vice versa.
#![cfg(feature = "crdt")]
use std::time::{Duration, Instant};
use pmacs::buffer::BufferId;
use pmacs::cell::CellSize;
use pmacs::editor::EditorState;
use pmacs::protocol::{
AttachRequest, ByteRange, FrontendCapabilities, FrontendEvent, FrontendId, Hello,
InstanceMessage,
};
use pmacs::semantic_client::SemanticClient;
use pmacs::semantic_render::SemanticRenderState;
use pmacs::transport::{read_message, write_message};
mod common;
use common::daemon::{TestDaemon, build_default_caps};
// ---------------------------------------------------------------------------
// Part A — reconstruction-equivalence (instance-side, no daemon)
// ---------------------------------------------------------------------------
const LOCAL: FrontendId = FrontendId::LOCAL;
fn active_buffer(state: &EditorState) -> BufferId {
state.core.borrow().active_window().buffer_id
}
fn set_selection(state: &EditorState, anchor: u64, cursor: u64) {
let mut core = state.core.borrow_mut();
let win = core
.active_window_mut_for(LOCAL)
.expect("LOCAL always has a window");
win.selection = Some(pmacs::window::Selection { anchor });
win.cursor = cursor;
}
/// The authoritative reconstruction for this instant: a fresh
/// `SemanticRenderState` emits a `full` first frame carrying the
/// complete current scoped set; a fresh client consuming only that is
/// the oracle the incrementally-driven client must match.
fn oracle(state: &EditorState, buffer_id: BufferId, vp: ByteRange) -> SemanticClient {
let mut o = SemanticRenderState::new(LOCAL);
o.set_viewport(buffer_id, vp, 0);
let mut oc = SemanticClient::new(LOCAL);
for m in &o.render_frame(state) {
oc.apply(m);
}
oc
}
fn assert_equiv(client: &SemanticClient, state: &EditorState, buffer_id: BufferId, vp: ByteRange) {
let oc = oracle(state, buffer_id, vp);
for b in vp.start..vp.end {
assert_eq!(
client.decoration_kinds_at(buffer_id, b),
oc.decoration_kinds_at(buffer_id, b),
"decoration mismatch at byte {b}"
);
assert_eq!(
client.effective_style_at(buffer_id, b),
oc.effective_style_at(buffer_id, b),
"style mismatch at byte {b}"
);
}
}
fn decorations_full(msgs: &[InstanceMessage]) -> Option<bool> {
msgs.iter().find_map(|m| match m {
InstanceMessage::Decorations { full, .. } => Some(*full),
_ => None,
})
}
fn has_style_spans(msgs: &[InstanceMessage]) -> bool {
msgs.iter()
.any(|m| matches!(m, InstanceMessage::StyleSpans { .. }))
}
fn generation_of(msgs: &[InstanceMessage]) -> Option<u64> {
msgs.iter().find_map(|m| match m {
InstanceMessage::StyleSpans { generation, .. }
| InstanceMessage::Decorations { generation, .. } => Some(*generation),
_ => None,
})
}
fn assert_disjoint_within(ranges: &[ByteRange], vp: ByteRange) {
let mut sorted = ranges.to_vec();
sorted.sort_by_key(|r| (r.start, r.end));
let mut prev_end = vp.start;
for r in &sorted {
assert!(
r.start >= vp.start && r.end <= vp.end,
"tile {r:?} escapes the declared viewport {vp:?}"
);
assert!(
r.start >= prev_end,
"tiles overlap: {r:?} starts before previous end {prev_end}"
);
prev_end = r.end;
}
}
#[test]
fn incremental_reconstruction_equals_fresh_full_projection() {
let state = EditorState::new();
let buffer_id = active_buffer(&state);
let vp1 = ByteRange { start: 0, end: 64 };
let mut sem = SemanticRenderState::new(LOCAL);
sem.set_viewport(buffer_id, vp1, 0);
let mut client = SemanticClient::new(LOCAL);
let mut generations: Vec<u64> = Vec::new();
// Frame 1 — first frame: a full resync for both families (empty
// scratch, no selection → empty segments).
let f1 = sem.render_frame(&state);
assert_eq!(decorations_full(&f1), Some(true), "first frame full");
assert!(has_style_spans(&f1), "first frame ships StyleSpans too");
if let Some(g) = generation_of(&f1) {
generations.push(g);
}
for m in &f1 {
client.apply(m);
}
assert_equiv(&client, &state, buffer_id, vp1);
// Unchanged → fully silent.
assert!(
sem.render_frame(&state).is_empty(),
"an unchanged frame emits nothing"
);
// A selection appears → Decorations re-emits incrementally
// (viewport region unchanged), styling stays suppressed.
set_selection(&state, 2, 5);
let f2 = sem.render_frame(&state);
assert_eq!(decorations_full(&f2), Some(false), "incremental, not full");
assert!(!has_style_spans(&f2), "styling unchanged → not re-sent");
if let Some(g) = generation_of(&f2) {
generations.push(g);
}
for m in &f2 {
client.apply(m);
}
assert_equiv(&client, &state, buffer_id, vp1);
// Selection jumps far away → two disjoint dirty intervals (old
// cleared, new painted). The client must reconstruct both.
set_selection(&state, 40, 42);
let f3 = sem.render_frame(&state);
for m in &f3 {
client.apply(m);
}
if let Some(g) = generation_of(&f3) {
generations.push(g);
}
assert_equiv(&client, &state, buffer_id, vp1);
assert_disjoint_within(&client.decoration_tile_ranges(buffer_id), vp1);
// Viewport region moves → a full resync. The selection at
// [40,42) is outside the new window, so the reconstruction is
// empty there — but only if the client correctly discarded the
// old viewport's tiles on the `full` frame.
let vp2 = ByteRange {
start: 100,
end: 200,
};
sem.set_viewport(buffer_id, vp2, 0);
let f4 = sem.render_frame(&state);
assert_eq!(
decorations_full(&f4),
Some(true),
"viewport jump forces a full resync"
);
for m in &f4 {
client.apply(m);
}
assert_equiv(&client, &state, buffer_id, vp2);
// Generation is monotonic non-decreasing across the run.
for w in generations.windows(2) {
assert!(w[1] >= w[0], "generation went backwards: {generations:?}");
}
}
// ---------------------------------------------------------------------------
// Part B — end-to-end daemon: per-session projection routing
// ---------------------------------------------------------------------------
fn semantic_caps() -> FrontendCapabilities {
// semantic_render requires crdt_replica (negotiation dependency
// rule); a semantic session is also a text replica.
FrontendCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
..build_default_caps()
}
}
/// Read messages until `deadline`, classifying what arrives. Returns
/// `(saw_cell_delta, saw_semantic, first_buffer_id)`.
fn drain_kinds(
stream: &mut std::os::unix::net::UnixStream,
deadline: Instant,
mut on_snapshot: impl FnMut(BufferId),
) -> (bool, bool) {
let mut saw_cell = false;
let mut saw_semantic = false;
while Instant::now() < deadline {
match read_message::<InstanceMessage>(stream) {
Ok(InstanceMessage::CellDelta { .. }) => saw_cell = true,
Ok(InstanceMessage::StyleSpans { .. } | InstanceMessage::Decorations { .. }) => {
saw_semantic = true;
}
Ok(InstanceMessage::BufferSnapshot { buffer_id, .. }) => on_snapshot(buffer_id),
// Other variants are irrelevant here; `Err` is a
// read-timeout slice — both just keep polling.
Ok(_) | Err(_) => {}
}
}
(saw_cell, saw_semantic)
}
#[test]
fn daemon_routes_semantic_family_to_semantic_session_only() {
let daemon = TestDaemon::spawn();
// --- Semantic session ---
let mut sem = daemon.connect();
sem.set_read_timeout(Some(Duration::from_millis(250)))
.unwrap();
let hello: Hello = read_message(&mut sem).expect("semantic read Hello");
let sem_fid = hello.assigned_frontend_id;
write_message(
&mut sem,
&AttachRequest {
protocol_version: hello.protocol_version,
frontend_capabilities: semantic_caps(),
initial_size: CellSize::new(24, 80),
},
)
.expect("semantic write AttachRequest");
// Learn a buffer id from the bootstrap snapshot, then declare a
// viewport — the daemon emits nothing semantic until it does
// (M11.2), so this also exercises the Viewport intercept e2e.
let mut buf: Option<BufferId> = None;
let by = Instant::now() + Duration::from_secs(5);
let _ = drain_kinds(&mut sem, Instant::now() + Duration::from_secs(2), |b| {
buf.get_or_insert(b);
});
let buffer_id = buf.expect("semantic session received a BufferSnapshot");
write_message(
&mut sem,
&FrontendEvent::Viewport {
frontend_id: sem_fid,
buffer_id,
visible: ByteRange {
start: 0,
end: 4096,
},
generation: 0,
},
)
.expect("semantic write Viewport");
let (sem_saw_cell, sem_saw_semantic) = drain_kinds(&mut sem, by, |_| {});
assert!(
sem_saw_semantic,
"semantic session must receive StyleSpans/Decorations after declaring a viewport"
);
assert!(
!sem_saw_cell,
"semantic session must NOT receive grid CellDelta (it lays out locally)"
);
// --- Grid session (same daemon) ---
let mut grid = daemon.connect();
grid.set_read_timeout(Some(Duration::from_millis(250)))
.unwrap();
let ghello: Hello = read_message(&mut grid).expect("grid read Hello");
write_message(
&mut grid,
&AttachRequest {
protocol_version: ghello.protocol_version,
frontend_capabilities: build_default_caps(),
initial_size: CellSize::new(24, 80),
},
)
.expect("grid write AttachRequest");
let (grid_saw_cell, grid_saw_semantic) =
drain_kinds(&mut grid, Instant::now() + Duration::from_secs(3), |_| {});
assert!(
grid_saw_cell,
"grid session must receive CellDelta (the M5 projection)"
);
assert!(
!grid_saw_semantic,
"grid session must NOT receive the semantic family"
);
}

View File

@ -152,6 +152,7 @@ fn build_default_caps() -> FrontendCapabilities {
terminal_kind: Some("perf-gate".into()),
multi_frontend: false,
crdt_replica: false,
semantic_render: false,
}
}