Merge pull request #15 from levineuwirth/m11.1-semantic
M11: semantic-frontend protocol arc (M11.1–M11.5)
This commit is contained in:
commit
526347cfe2
134
CHANGELOG.md
134
CHANGELOG.md
|
|
@ -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.1–M11.5): wire +
|
||||
capability scaffolding, the instance-side projection seam,
|
||||
decorations, segment diffing, and the consumer-side glue with
|
||||
end-to-end coverage. `InlineAdornments`/`BlockAdornments`/`FoldState`/
|
||||
`ResourceOffer` remain honest stubs pending their source features.
|
||||
|
||||
## [1.0.0] --- 2026-05-18
|
||||
|
||||
First stable release. Builds on the 0.1.0 preview (M1–M6) with the
|
||||
|
|
|
|||
|
|
@ -0,0 +1,259 @@
|
|||
# Semantic frontend protocol (design note)
|
||||
|
||||
**Status: implemented by the M11 arc (M11.1–M11.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.5–M10.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.
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
49
src/crdt.rs
49
src/crdt.rs
|
|
@ -204,6 +204,31 @@ impl CrdtState {
|
|||
self.doc.oplog_vv()
|
||||
}
|
||||
|
||||
/// T M11.2 — the oplog version projected to a single monotonic
|
||||
/// scalar: the sum of every peer's op counter in the version
|
||||
/// vector.
|
||||
///
|
||||
/// This is the `generation` anchor for the semantic projection
|
||||
/// (`InstanceMessage::StyleSpans::generation`). A loro counter is
|
||||
/// per-peer non-decreasing and only ever grows as ops accrue, so
|
||||
/// the sum is non-decreasing for the document as a whole — a
|
||||
/// frontend can compare a received `generation` against the one
|
||||
/// it computed locally and discard styling that predates an edit
|
||||
/// it already applied optimistically. It is deliberately *not* a
|
||||
/// causal clock: equal scalars do not imply equal states across
|
||||
/// divergent replicas. It is only ever compared against itself on
|
||||
/// one replica (the frontend's own mirror vs. the instance's
|
||||
/// authoritative doc), where it is monotone, which is all the
|
||||
/// staleness check needs.
|
||||
#[must_use]
|
||||
pub fn version_scalar(&self) -> u64 {
|
||||
self.doc
|
||||
.oplog_vv()
|
||||
.values()
|
||||
.map(|counter| u64::try_from(*counter).unwrap_or(0))
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// T M10.2 Day 3: export wire-format bytes for ops added since
|
||||
/// `from`.
|
||||
///
|
||||
|
|
@ -460,6 +485,30 @@ mod tests {
|
|||
assert_eq!(s.peer_id(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_scalar_is_monotonic_non_decreasing() {
|
||||
// T M11.2 — the semantic projection's `generation` anchor.
|
||||
// Empty doc is 0; each applied op only grows the scalar; a
|
||||
// no-op delete does not shrink it.
|
||||
let s = CrdtState::new(1).expect("new");
|
||||
assert_eq!(s.version_scalar(), 0, "empty doc has generation 0");
|
||||
|
||||
s.insert(0, "hello").expect("insert");
|
||||
let g1 = s.version_scalar();
|
||||
assert!(g1 > 0, "an applied op must advance the generation");
|
||||
|
||||
s.insert(5, " world").expect("insert");
|
||||
let g2 = s.version_scalar();
|
||||
assert!(g2 >= g1, "generation must not decrease across ops");
|
||||
|
||||
s.delete(0, 1).expect("delete");
|
||||
let g3 = s.version_scalar();
|
||||
assert!(
|
||||
g3 >= g2,
|
||||
"a delete is still an op — the version vector only grows"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_round_trip_ascii() {
|
||||
let s = CrdtState::new(1).expect("new");
|
||||
|
|
|
|||
231
src/daemon.rs
231
src/daemon.rs
|
|
@ -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).
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
743
src/protocol.rs
743
src/protocol.rs
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -230,6 +230,7 @@ pub fn build_default_caps() -> FrontendCapabilities {
|
|||
terminal_kind: Some("test".into()),
|
||||
multi_frontend: false,
|
||||
crdt_replica: false,
|
||||
semantic_render: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
@ -152,6 +152,7 @@ fn build_default_caps() -> FrontendCapabilities {
|
|||
terminal_kind: Some("perf-gate".into()),
|
||||
multi_frontend: false,
|
||||
crdt_replica: false,
|
||||
semantic_render: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue