From 1c5169b8f8a0e6fef3a9426e34b2b034e3f2a573 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 18 May 2026 19:39:07 -0400 Subject: [PATCH 1/6] M11.1: semantic-frontend protocol scaffolding (wire + capability) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First milestone of the M11 semantic-frontend arc (docs/semantic-frontend-protocol.md). Wire-format scaffolding only — no producer or consumer; mechanically identical to the M10.5 CRDT wire declaration, and non-breaking by the same slice-membership + per-session-filter argument. - PROTOCOL_VERSION 2 -> 3; SUPPORTED_PROTOCOL_VERSIONS [1,2,3]. v0.1 and v1.0 binaries keep connecting unchanged (membership, not strict equality). - semantic_render capability bit on FrontendCapabilities, InstanceCapabilities, NegotiatedCapabilities (#[serde(default)]; instance default false until the M11.2 projection seam). negotiate_capabilities AND-combines it and enforces the semantic_render => crdt_replica dependency as a CapabilityMismatch (a semantic session is also a text replica), never a silent degrade. PMACS_INSTANCE_SEMANTIC_RENDER env override added. - InstanceMessage SemanticFrame family: StyleSpans, Decorations, InlineAdornments, BlockAdornments, FoldState, ResourceOffer. FrontendEvent::Viewport. Supporting types: ByteRange, StyleSpan, Decoration/DecorationKind, InlineAdornment/AdornmentPlacement/ AdornmentContent, BlockAdornment, ResourceBody. All byte-anchored; no pixels cross the contract boundary. - Grid TUI (frontend.rs) and daemon apply_event drop the new family silently — the "declared, not yet wired" posture CrdtOp held between M10.5 and M10.8. Stale v1.0 version-pin tests updated to the v1.1 truth; negotiation matrix + postcard round-trips added. Lib + integration suites green on both the default and crdt feature flavors; clippy -D warnings clean on both. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 28 ++ src/attach.rs | 5 + src/daemon.rs | 17 +- src/frontend.rs | 16 +- src/presence.rs | 4 + src/protocol.rs | 664 ++++++++++++++++++++++++++++++++++-- src/transport.rs | 1 + tests/common/daemon.rs | 1 + tests/m10_11_acceptance.rs | 1 + tests/m5_perf_acceptance.rs | 1 + 10 files changed, 715 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec77c1..1395e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ 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. + ## [1.0.0] --- 2026-05-18 First stable release. Builds on the 0.1.0 preview (M1–M6) with the diff --git a/src/attach.rs b/src/attach.rs index 284c98e..72c6a2a 100644 --- a/src/attach.rs +++ b/src/attach.rs @@ -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, } } diff --git a/src/daemon.rs b/src/daemon.rs index d80c349..f4202e5 100644 --- a/src/daemon.rs +++ b/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), } } @@ -1753,7 +1757,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). } diff --git a/src/frontend.rs b/src/frontend.rs index b11108a..60fbfd4 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -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. } } diff --git a/src/presence.rs b/src/presence.rs index 1ed40c6..9d6cdaf 100644 --- a/src/presence.rs +++ b/src/presence.rs @@ -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, ) diff --git a/src/protocol.rs b/src/protocol.rs index 64b7715..a3cefb8 100644 --- a/src/protocol.rs +++ b/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,82 @@ 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. Diffs against the previous + /// frame the way `CellDelta` does today: changed spans only, + /// scoped to the range the frontend last declared via + /// `FrontendEvent::Viewport`. + /// + /// 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, + /// Styled byte runs, scoped to the declared viewport. + spans: Vec, + }, + /// 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`. + Decorations { + /// Buffer these decorations apply to. + buffer_id: crate::buffer::BufferId, + /// Decoration regions for the declared viewport. + decorations: Vec, + }, + /// 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, + }, + /// 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, + }, + /// 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, + }, + /// 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 +713,153 @@ 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, +} + +/// 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, +} + +/// 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, + /// 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), + /// A URI the frontend fetches/resolves on its own. + Uri(String), +} + // --------------------------------------------------------------------------- // Attachment // --------------------------------------------------------------------------- @@ -1117,20 +1373,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 +1504,23 @@ 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`. + /// + /// Default is `false` — unlike `crdt_replica`, this does *not* + /// track the `crdt` Cargo feature. M11.1 declares the bit + /// position and the negotiation mechanics; the instance-side + /// projection seam (`SemanticRenderState`, the producer) is + /// M11.2. Advertising `true` before the producer exists would be + /// wire-protocol false advertising — the M10.5→M10.7 "bits false + /// until the path is wired" discipline, applied to M11. The + /// default flips to `cfg!(feature = "crdt")` when M11.2 lands the + /// projection seam (semantic sessions are also text replicas, so + /// the dependency on `crdt_replica` makes the feature gate the + /// natural ceiling). + #[serde(default)] + pub semantic_render: bool, } // Clippy in non-CRDT builds notes that `cfg!(feature = "crdt")` @@ -1255,9 +1542,19 @@ 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 — `semantic_render` defaults to `false` + // unconditionally (not gated on the `crdt` feature like the + // two bits above). There is no projection-seam producer yet; + // the producer and the feature-tracking default flip are + // M11.2 scope. Until then a frontend declaring + // `semantic_render: true` gets `Goodbye(CapabilityMismatch)`, + // exactly as `multi_frontend`/`crdt_replica` did between + // M10.5 and the M10.8 Day-4 flip. Self { multi_frontend: cfg!(feature = "crdt"), crdt_replica: cfg!(feature = "crdt"), + semantic_render: false, } } } @@ -1322,6 +1619,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 +1660,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 +1687,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 +1733,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 +2935,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 +2991,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 +3308,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 +3521,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 +3681,232 @@ 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_default_semantic_render_false() { + // Regression: the M10.7 matrix uses the 2-arg helpers, which + // must keep semantic_render at its Default (false) so adding + // the bit did not perturb existing negotiation outcomes. + 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); + } + + // 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, + spans: vec![StyleSpan { + range: ByteRange { start: 0, end: 12 }, + style: crate::cell::Style::default(), + }], + }, + InstanceMessage::Decorations { + buffer_id: bid, + 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)); + } } diff --git a/src/transport.rs b/src/transport.rs index 64fd9eb..092362a 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -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), }; diff --git a/tests/common/daemon.rs b/tests/common/daemon.rs index 6e3cd16..a0879cf 100644 --- a/tests/common/daemon.rs +++ b/tests/common/daemon.rs @@ -230,6 +230,7 @@ pub fn build_default_caps() -> FrontendCapabilities { terminal_kind: Some("test".into()), multi_frontend: false, crdt_replica: false, + semantic_render: false, } } diff --git a/tests/m10_11_acceptance.rs b/tests/m10_11_acceptance.rs index 12cc465..c82db70 100644 --- a/tests/m10_11_acceptance.rs +++ b/tests/m10_11_acceptance.rs @@ -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, diff --git a/tests/m5_perf_acceptance.rs b/tests/m5_perf_acceptance.rs index 506dbe9..4d1a680 100644 --- a/tests/m5_perf_acceptance.rs +++ b/tests/m5_perf_acceptance.rs @@ -152,6 +152,7 @@ fn build_default_caps() -> FrontendCapabilities { terminal_kind: Some("perf-gate".into()), multi_frontend: false, crdt_replica: false, + semantic_render: false, } } From 4ba6fd7bb8bf43a5cddb1a43b1824598f11b49de Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 18 May 2026 20:14:08 -0400 Subject: [PATCH 2/6] M11.2: semantic projection seam (SemanticRenderState) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first real producer of the semantic-frontend arc. The instance projects syntax styling to semantic_render sessions without rasterizing to a cell grid. - src/semantic_render.rs: SemanticRenderState, sibling of instance_render::RenderState. Reads the same EditorState, emits InstanceMessage::StyleSpans (tree-sitter spans via the active Theme), scoped + clipped to the FrontendEvent::Viewport byte range. Emits nothing until a viewport is declared; suppresses byte-identical frames (per-span delta encoding deferred to M11.4). - CrdtState::version_scalar(): oplog version vector summed to a monotonic non-decreasing u64 — the StyleSpans.generation anchor. - daemon dispatcher: semantic_states map parallel to render_states; projection selected per session. Semantic sessions get StyleSpans + CursorByte + BufferSnapshot + CrdtOp + presence, never CellDelta / grid Cursor. FrontendEvent::Viewport consumed (routed by authenticated source). SessionEstablished body extracted to handle_session_established (clippy 100-line ceiling). Grid-less sessions no longer panic the _ => apply_event arm. - InstanceCapabilities default semantic_render flipped to cfg!(feature = "crdt") — the "M11.2 enables semantic" moment, analogous to the M10.8 Day-4 flip. M11.1 negotiation test comment updated for the flip (frontend-side default still false keeps M10.7 outcomes unperturbed). Lib (1392 crdt / 1237 non-crdt) + integration suites green on both feature flavors; clippy -D warnings clean on both. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 27 ++++ src/crdt.rs | 49 +++++++ src/daemon.rs | 214 ++++++++++++++++++++-------- src/lib.rs | 1 + src/protocol.rs | 63 +++++---- src/semantic_render.rs | 313 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 586 insertions(+), 81 deletions(-) create mode 100644 src/semantic_render.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1395e4d..f884196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,33 @@ wire declaration. - `PMACS_INSTANCE_SEMANTIC_RENDER` env override mirrors the existing per-capability test overrides. +#### Semantic projection seam (M11.2) + +The first real producer. The instance now projects syntax styling to +`semantic_render` sessions without rasterizing to a cell grid. + +- New `SemanticRenderState` (`src/semantic_render.rs`), sibling of + `instance_render::RenderState`: reads the same `EditorState` but + emits `InstanceMessage::StyleSpans` — tree-sitter spans mapped + through the active `Theme`, scoped and clipped to the byte range the + frontend declared via `FrontendEvent::Viewport`. Emits nothing until + a viewport is declared; suppresses byte-identical frames (true + span-granularity diffing is M11.4). +- `StyleSpans.generation` is anchored to `CrdtState::version_scalar()` + — the oplog version vector summed to one monotonic non-decreasing + scalar, letting a frontend discard styling that predates an edit it + already applied optimistically. +- Dispatcher selects the projection **per session**: a semantic + session gets a `SemanticRenderState` and never `CellDelta`/grid + `Cursor` (it lays out locally) but still receives `CursorByte`, + `BufferSnapshot`, `CrdtOp`, and presence. A grid and a semantic + frontend can attach to the same buffer simultaneously. + `FrontendEvent::Viewport` is consumed (routed by authenticated + source, like `CrdtOp`). +- `InstanceCapabilities` default `semantic_render` flipped to + `cfg!(feature = "crdt")` — the "M11.2 enables semantic" moment, + analogous to the M10.8 Day-4 `multi_frontend`/`crdt_replica` flip. + ## [1.0.0] --- 2026-05-18 First stable release. Builds on the 0.1.0 preview (M1–M6) with the diff --git a/src/crdt.rs b/src/crdt.rs index d93683f..c7d493c 100644 --- a/src/crdt.rs +++ b/src/crdt.rs @@ -204,6 +204,31 @@ impl CrdtState { self.doc.oplog_vv() } + /// T M11.2 — the oplog version projected to a single monotonic + /// scalar: the sum of every peer's op counter in the version + /// vector. + /// + /// This is the `generation` anchor for the semantic projection + /// (`InstanceMessage::StyleSpans::generation`). A loro counter is + /// per-peer non-decreasing and only ever grows as ops accrue, so + /// the sum is non-decreasing for the document as a whole — a + /// frontend can compare a received `generation` against the one + /// it computed locally and discard styling that predates an edit + /// it already applied optimistically. It is deliberately *not* a + /// causal clock: equal scalars do not imply equal states across + /// divergent replicas. It is only ever compared against itself on + /// one replica (the frontend's own mirror vs. the instance's + /// authoritative doc), where it is monotone, which is all the + /// staleness check needs. + #[must_use] + pub fn version_scalar(&self) -> u64 { + self.doc + .oplog_vv() + .values() + .map(|counter| u64::try_from(*counter).unwrap_or(0)) + .sum() + } + /// T M10.2 Day 3: export wire-format bytes for ops added since /// `from`. /// @@ -460,6 +485,30 @@ mod tests { assert_eq!(s.peer_id(), 1); } + #[test] + fn version_scalar_is_monotonic_non_decreasing() { + // T M11.2 — the semantic projection's `generation` anchor. + // Empty doc is 0; each applied op only grows the scalar; a + // no-op delete does not shrink it. + let s = CrdtState::new(1).expect("new"); + assert_eq!(s.version_scalar(), 0, "empty doc has generation 0"); + + s.insert(0, "hello").expect("insert"); + let g1 = s.version_scalar(); + assert!(g1 > 0, "an applied op must advance the generation"); + + s.insert(5, " world").expect("insert"); + let g2 = s.version_scalar(); + assert!(g2 >= g1, "generation must not decrease across ops"); + + s.delete(0, 1).expect("delete"); + let g3 = s.version_scalar(); + assert!( + g3 >= g2, + "a delete is still an op — the version vector only grows" + ); + } + #[test] fn insert_round_trip_ascii() { let s = CrdtState::new(1).expect("new"); diff --git a/src/daemon.rs b/src/daemon.rs index f4202e5..71640b2 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -827,6 +827,13 @@ fn dispatcher_loop( ) -> Result<(), DaemonError> { // Per-frontend dispatcher state. let mut render_states: HashMap = HashMap::new(); + // T M11.2 — parallel to `render_states`, but for `semantic_render` + // sessions: the dispatcher selects the projection *per session*, + // so a frontend has exactly one of a `RenderState` (grid) or a + // `SemanticRenderState` (layout-local), never both. A grid and a + // semantic frontend can attach to the same buffer simultaneously. + let mut semantic_states: HashMap = + HashMap::new(); let mut streams: HashMap = HashMap::new(); let mut term_sizes: HashMap = HashMap::new(); let mut session_registry = SessionRegistry::new(); @@ -845,7 +852,13 @@ fn dispatcher_loop( // loop to the last-dispatched value (Q11: tick-driven render // doesn't update active_frontend in the user-driving sense). let last_dispatched = editor.core.borrow().active_frontend; - let attached_fids: Vec = render_states.keys().copied().collect(); + // Union of grid + semantic sessions — each fid is in exactly + // one of the two maps (projection selected per session). + let attached_fids: Vec = render_states + .keys() + .chain(semantic_states.keys()) + .copied() + .collect(); // T M10.10 post-audit-round-3 F18 — drain + broadcast pending // CRDT ops **before** the render pass. Otherwise frontends @@ -940,15 +953,25 @@ fn dispatcher_loop( let _ = ensure_active_buffer_crdt_backed(editor, *fid); } - // T M10.9 — gather other-frontend presences for the - // overlay paint. Reads `last_broadcast` (updated by the - // sweep below); other-frontend snapshots lag by at most - // one tick. Imperceptible at frame-rate cadence. - let other_presences = session_registry.other_presences_for(*fid); - let render_state = render_states - .get_mut(fid) - .expect("render_state present for attached fid"); - let messages = render_state.render_frame(editor, &other_presences); + // Projection selected per session (T M11.2). A semantic + // session produces `StyleSpans` scoped to its declared + // viewport and NEVER `CellDelta` / grid `Cursor` (it lays + // out locally); it still receives `CursorByte` below + // (semantic implies `crdt_replica`) and participates in + // presence. A grid session takes the M5.2 cell path. + let messages = if let Some(sem) = semantic_states.get_mut(fid) { + sem.render_frame(editor) + } else { + // T M10.9 — gather other-frontend presences for the + // overlay paint. Reads `last_broadcast` (updated by + // the sweep below); other-frontend snapshots lag by + // at most one tick. Imperceptible at frame cadence. + let other_presences = session_registry.other_presences_for(*fid); + let render_state = render_states + .get_mut(fid) + .expect("render_state present for attached grid fid"); + render_state.render_frame(editor, &other_presences) + }; // T M10.6 per-frontend presence sweep. The snapshot is // computed from this frontend's view; the sweep then @@ -1036,6 +1059,7 @@ fn dispatcher_loop( // Drop the broken connection. streams.remove(fid); render_states.remove(fid); + semantic_states.remove(fid); term_sizes.remove(fid); session_registry.unregister_session(*fid); editor.core.borrow_mut().unregister_frontend_view(*fid); @@ -1073,6 +1097,7 @@ fn dispatcher_loop( event, editor, &mut render_states, + &mut semantic_states, &mut streams, &mut term_sizes, &mut session_registry, @@ -1086,6 +1111,7 @@ fn dispatcher_loop( event, editor, &mut render_states, + &mut semantic_states, &mut streams, &mut term_sizes, &mut session_registry, @@ -1106,10 +1132,84 @@ fn dispatcher_loop( /// Handle one `DispatcherEvent`. Extracted so the dispatcher loop /// can both timeout-recv and burst-drain via the same code path. +/// T M11.2 — extracted from `handle_dispatcher_event`'s +/// `SessionEstablished` arm (kept the parent under the 100-line +/// clippy ceiling). Registers the frontend's view, bootstraps the +/// `BufferMirror` via `BufferSnapshot` when `crdt_replica`, and +/// selects the per-session projection: a `semantic_render` session +/// gets a `SemanticRenderState` (no grid `RenderState`, no +/// initial-full-grid analogue — it emits nothing until the frontend +/// declares a viewport); every other session keeps the M5.3 +/// force-full-grid grid path. +#[allow(clippy::too_many_arguments)] +fn handle_session_established( + editor: &mut EditorState, + render_states: &mut HashMap, + semantic_states: &mut HashMap, + streams: &mut HashMap, + term_sizes: &mut HashMap, + session_registry: &mut SessionRegistry, + frontend_id: FrontendId, + session_state: crate::presence::SessionState, + initial_size: CellSize, + mut write_stream: UnixStream, +) { + // Register the frontend's view (M10.8 Day 3: fresh scratch + // buffer view; future milestones may clone LOCAL's view or + // take an explicit initial-buffer argument). + let scratch_view = build_fresh_frontend_view(editor); + editor + .core + .borrow_mut() + .register_frontend_view(frontend_id, scratch_view); + + // T M10.10: bootstrap the new frontend's `BufferMirror` by + // sending one `BufferSnapshot` per CRDT-backed buffer. Gated on + // the negotiated `crdt_replica` capability — v0.1 / non-replica + // frontends never receive the variant (postcard would hard-error + // on the unknown variant; see M10.10-FRAMING.md Refinement 3). + // Ordering: snapshots are sent BEFORE any CellDelta flows (the + // next per-tick render is the first CellDelta source), so the + // mirror is initialized before any local-edit path can reference + // it. + let crdt_replica = session_state.negotiated_capabilities.crdt_replica; + // T M11.2 — a semantic session is always a text replica (the + // negotiation dependency rule guarantees `semantic_render ⇒ + // crdt_replica`), so the `BufferSnapshot` bootstrap below still + // fires: the semantic frontend holds the rope locally and the + // semantic frame ships no text. + let semantic_render = session_state.negotiated_capabilities.semantic_render; + if crdt_replica { + send_buffer_snapshots(editor, &mut write_stream); + } + + // Register the session in the registry (presence + capability + // filters). + session_registry.register_session(frontend_id, session_state); + + if semantic_render { + semantic_states.insert( + frontend_id, + crate::semantic_render::SemanticRenderState::new(), + ); + } else { + let mut render_state = RenderState::new(initial_size); + render_state.force_full_grid_resync(); + render_states.insert(frontend_id, render_state); + } + streams.insert(frontend_id, write_stream); + term_sizes.insert(frontend_id, initial_size); + + // Stamp active_frontend so the initial render's Lua statusline + // code sees the right fid. + editor.core.borrow_mut().active_frontend = frontend_id; +} + fn handle_dispatcher_event( event: DispatcherEvent, editor: &mut EditorState, render_states: &mut HashMap, + semantic_states: &mut HashMap, streams: &mut HashMap, term_sizes: &mut HashMap, session_registry: &mut SessionRegistry, @@ -1119,48 +1219,20 @@ fn handle_dispatcher_event( frontend_id, session_state, initial_size, - mut write_stream, + write_stream, } => { - // Register the frontend's view (M10.8 Day 3: fresh - // scratch buffer view; future milestones may clone - // LOCAL's view or take an explicit initial-buffer - // argument). - let scratch_view = build_fresh_frontend_view(editor); - editor - .core - .borrow_mut() - .register_frontend_view(frontend_id, scratch_view); - - // T M10.10: bootstrap the new frontend's `BufferMirror` - // by sending one `BufferSnapshot` per CRDT-backed buffer. - // Gated on the negotiated `crdt_replica` capability — - // v0.1 / non-replica frontends never receive the variant - // (postcard would hard-error on the unknown variant; see - // M10.10-FRAMING.md Refinement 3). Ordering: snapshots - // are sent BEFORE any CellDelta flows (the next per-tick - // render is the first CellDelta source), so the mirror - // is initialized before any local-edit path can - // reference it. - let crdt_replica = session_state.negotiated_capabilities.crdt_replica; - if crdt_replica { - send_buffer_snapshots(editor, &mut write_stream); - } - - // Register the session in the registry (presence + - // capability filters). - session_registry.register_session(frontend_id, session_state); - - // Allocate per-frontend RenderState; force initial - // full-grid sync so the first frame paints everything. - let mut render_state = RenderState::new(initial_size); - render_state.force_full_grid_resync(); - render_states.insert(frontend_id, render_state); - streams.insert(frontend_id, write_stream); - term_sizes.insert(frontend_id, initial_size); - - // Stamp active_frontend so the initial render's Lua - // statusline code sees the right fid. - editor.core.borrow_mut().active_frontend = frontend_id; + handle_session_established( + editor, + render_states, + semantic_states, + streams, + term_sizes, + session_registry, + frontend_id, + session_state, + initial_size, + write_stream, + ); } DispatcherEvent::FrontendEvent { source, event } => { match event { @@ -1214,21 +1286,51 @@ fn handle_dispatcher_event( handle_remote_crdt_op(editor, source, buffer_id, op); } } + FrontendEvent::Viewport { + buffer_id, + visible, + generation, + .. + } => { + // T M11.2 — feed the semantic projection the byte + // range the frontend has on screen. Routed by the + // authenticated `source` (the client-supplied + // `frontend_id` field is not trusted, consistent + // with the CrdtOp source-trust rule). A grid + // session never sends this; if one does, there is + // no `SemanticRenderState` to update and it is a + // benign no-op. + if let Some(sem) = semantic_states.get_mut(&source) { + sem.set_viewport(buffer_id, visible, generation); + } + } _ => { let term_size = *term_sizes .get(&source) .expect("term_size present for source"); - let render_state = render_states - .get_mut(&source) - .expect("render_state present for source"); let mut term_size = term_size; - apply_event(editor, event, &mut term_size, render_state); - term_sizes.insert(source, term_size); + if let Some(render_state) = render_states.get_mut(&source) { + apply_event(editor, event, &mut term_size, render_state); + term_sizes.insert(source, term_size); + } else { + // T M11.2 — a semantic (grid-less) session has + // no `RenderState`. Key/Mouse/Paste/Focus + // command handling for semantic frontends is + // M11.5 scope; until then these events are + // dropped rather than panicking the + // dispatcher on the absent grid state. + debug_assert!( + semantic_states.contains_key(&source), + "fid with neither a render_state nor a semantic_state \ + sent a frontend event" + ); + } } } } DispatcherEvent::SessionDetached { frontend_id } => { render_states.remove(&frontend_id); + semantic_states.remove(&frontend_id); streams.remove(&frontend_id); term_sizes.remove(&frontend_id); session_registry.unregister_session(frontend_id); diff --git a/src/lib.rs b/src/lib.rs index 9668714..c2fe253 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -90,6 +90,7 @@ pub mod project; pub mod project_index; pub mod protocol; pub mod rope; +pub mod semantic_render; pub mod signature; pub mod socket_path; pub mod syntax; diff --git a/src/protocol.rs b/src/protocol.rs index a3cefb8..dd87fa9 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1508,17 +1508,15 @@ pub struct InstanceCapabilities { /// family (`InstanceMessage::StyleSpans` … `ResourceOffer`) and /// consume `FrontendEvent::Viewport`. /// - /// Default is `false` — unlike `crdt_replica`, this does *not* - /// track the `crdt` Cargo feature. M11.1 declares the bit - /// position and the negotiation mechanics; the instance-side - /// projection seam (`SemanticRenderState`, the producer) is - /// M11.2. Advertising `true` before the producer exists would be - /// wire-protocol false advertising — the M10.5→M10.7 "bits false - /// until the path is wired" discipline, applied to M11. The - /// default flips to `cfg!(feature = "crdt")` when M11.2 lands the - /// projection seam (semantic sessions are also text replicas, so - /// the dependency on `crdt_replica` makes the feature gate the - /// natural ceiling). + /// T M11.1 declared the bit position + negotiation mechanics with + /// the default `false` (no producer yet). T M11.2 landed the + /// instance-side projection seam (`SemanticRenderState`) and + /// flipped the default to `cfg!(feature = "crdt")`: the instance + /// now advertises `semantic_render` on CRDT builds. It tracks the + /// `crdt` feature rather than being unconditional because the + /// negotiation dependency rule makes a semantic session + /// necessarily a text replica — a non-CRDT build can host + /// neither. See [`Default`] impl below. #[serde(default)] pub semantic_render: bool, } @@ -1543,18 +1541,25 @@ impl Default for InstanceCapabilities { // keeps the daemon's advertised capabilities consistent // with what it can actually do. // - // T M11.1 — `semantic_render` defaults to `false` - // unconditionally (not gated on the `crdt` feature like the - // two bits above). There is no projection-seam producer yet; - // the producer and the feature-tracking default flip are - // M11.2 scope. Until then a frontend declaring - // `semantic_render: true` gets `Goodbye(CapabilityMismatch)`, - // exactly as `multi_frontend`/`crdt_replica` did between - // M10.5 and the M10.8 Day-4 flip. + // T M11.1 declared `semantic_render` defaulting to `false` + // unconditionally — no projection-seam producer existed, so + // advertising it would have been wire-protocol false + // advertising (the M10.5→M10.7 "bits false until the path is + // wired" discipline). + // + // T M11.2 — **the flip**: the instance-side projection seam + // (`SemanticRenderState`, the producer) has landed and the + // dispatcher selects it per session, so the instance now + // advertises `semantic_render`. It tracks `cfg!(feature = + // "crdt")` like `crdt_replica` because the negotiation + // dependency rule makes a semantic session necessarily a + // text replica; a non-CRDT build can host neither. This is + // the "M11.2 enables semantic" moment, exactly analogous to + // the M10.8 Day-4 multi_frontend/crdt_replica flip. Self { multi_frontend: cfg!(feature = "crdt"), crdt_replica: cfg!(feature = "crdt"), - semantic_render: false, + semantic_render: cfg!(feature = "crdt"), } } } @@ -3809,15 +3814,23 @@ mod tests { } #[test] - fn negotiate_two_arg_helpers_default_semantic_render_false() { - // Regression: the M10.7 matrix uses the 2-arg helpers, which - // must keep semantic_render at its Default (false) so adding - // the bit did not perturb existing negotiation outcomes. + fn negotiate_two_arg_helpers_do_not_negotiate_semantic_render() { + // Regression: the M10.7 matrix uses the 2-arg helpers. After + // the T M11.2 flip the *instance* default is `cfg!(crdt)` + // (true under `--features crdt`), but the *frontend* 2-arg + // helper still defaults `semantic_render` to false — so the + // AND-rule yields `false` and existing M10.7 outcomes are + // unperturbed. (A frontend that wants the semantic projection + // opts in explicitly via the 3-arg helper.) let res = negotiate_capabilities(&front_caps(true, true), &inst_caps(true, true)).expect("ok"); assert!(res.multi_frontend); assert!(res.crdt_replica); - assert!(!res.semantic_render); + assert!( + !res.semantic_render, + "frontend that didn't request semantic_render must not negotiate it, \ + regardless of the instance default" + ); } // T M11.1 — postcard round-trips for the SemanticFrame family and diff --git a/src/semantic_render.rs b/src/semantic_render.rs new file mode 100644 index 0000000..c46615d --- /dev/null +++ b/src/semantic_render.rs @@ -0,0 +1,313 @@ +// semantic_render.rs --- Instance-side semantic projection (T M11.2). + +//! The semantic projection seam. +//! +//! [`crate::instance_render::RenderState`] rasterizes the editor to a +//! cell grid and ships [`InstanceMessage::CellDelta`]. `SemanticRenderState` +//! is its sibling for `semantic_render` sessions: it reads the same +//! [`EditorState`] but exits the pipeline *earlier* — it emits the +//! structured byte-range styling the cell painter would otherwise have +//! consumed (tree-sitter spans from [`crate::syntax`] mapped through +//! the active [`crate::highlight::Theme`]), without the grid-packing +//! step. The frontend lays the styling out locally over rope text it +//! already holds via its `crdt_replica` `BufferMirror`. +//! +//! Contract boundary (see `docs/semantic-frontend-protocol.md`): the +//! instance never learns a pixel. The only spatial fact it consumes is +//! the buffer byte range the frontend declared on screen via +//! [`crate::protocol::FrontendEvent::Viewport`]; styling is scoped to +//! that range so a 100k-line file's styling is never shipped wholesale. +//! +//! M11.2 scope: `StyleSpans` only. `Decorations` / `InlineAdornments` / +//! `BlockAdornments` / `FoldState` / `ResourceOffer` are M11.3; true +//! span-granularity diffing (this module currently suppresses only +//! byte-identical frames) is M11.4. + +use std::collections::HashMap; + +use crate::buffer::BufferId; +use crate::cell::Style; +use crate::editor::EditorState; +use crate::protocol::{ByteRange, InstanceMessage, StyleSpan}; + +/// The viewport a `semantic_render` frontend last declared. +#[derive(Clone, Debug, Eq, PartialEq)] +struct DeclaredViewport { + buffer_id: BufferId, + visible: ByteRange, + /// The CRDT generation the frontend computed `visible` against. + /// Recorded for the M11.4 "ignore a viewport that races a + /// not-yet-applied edit" refinement; M11.2 always honors the most + /// recent declaration verbatim. + frontend_generation: u64, +} + +/// Owns one `semantic_render` session's projection state: the last +/// viewport the frontend declared, and the last `StyleSpans` payload +/// shipped per buffer (for byte-identical-frame suppression). +#[derive(Default)] +pub struct SemanticRenderState { + /// `None` until the frontend's first [`Self::set_viewport`]. While + /// `None`, [`Self::render_frame`] emits nothing: the frontend + /// bootstraps its rope from `BufferSnapshot`, declares what is on + /// screen, and only then receives styling for exactly that range. + viewport: Option, + /// Last `(generation, spans)` shipped, keyed by buffer. A frame + /// whose scoped span set and generation match the last send emits + /// nothing — the steady-state cost between edits is one map + /// lookup. True per-span delta encoding is M11.4. + last_sent: HashMap)>, +} + +impl SemanticRenderState { + /// Fresh session state: no viewport declared, nothing sent. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Record the frontend's declared on-screen byte range. Called by + /// the dispatcher when it receives + /// [`crate::protocol::FrontendEvent::Viewport`]. Replaces any + /// prior declaration wholesale — the latest viewport wins. + pub fn set_viewport(&mut self, buffer_id: BufferId, visible: ByteRange, generation: u64) { + self.viewport = Some(DeclaredViewport { + buffer_id, + visible, + frontend_generation: generation, + }); + } + + /// Project one frame. + /// + /// Returns at most one [`InstanceMessage::StyleSpans`], scoped to + /// the declared viewport. Returns an empty vec when: no viewport + /// has been declared yet; the buffer has no parse view or settled + /// tree; the language has no highlights query; or the scoped span + /// set is byte-identical to the last one shipped at the same + /// generation (the unchanged-frame fast path). + pub fn render_frame(&mut self, state: &EditorState) -> Vec { + let Some(vp) = self.viewport.clone() else { + // Emit nothing before the frontend declares a viewport. + return Vec::new(); + }; + + let generation = buffer_generation(state, vp.buffer_id); + let spans = scoped_style_spans(state, &vp); + + // Unchanged-frame suppression (M11.4 replaces this with + // per-span delta encoding). A buffer with an empty scoped set + // still suppresses correctly: the first empty frame ships once + // (clearing any prior styling on the frontend), subsequent + // identical empty frames are squelched. + if let Some((last_gen, last_spans)) = self.last_sent.get(&vp.buffer_id) + && *last_gen == generation + && *last_spans == spans + { + return Vec::new(); + } + self.last_sent + .insert(vp.buffer_id, (generation, spans.clone())); + + vec![InstanceMessage::StyleSpans { + buffer_id: vp.buffer_id, + generation, + spans, + }] + } + +} + +/// Compute the styled byte runs intersecting the declared viewport, +/// mapped through the active theme. Spans are clipped to the viewport +/// and to the parsed source length; runs that resolve to the default +/// style are dropped (wire economy, and consistent with the grid +/// path, which skips default-style merges). +fn scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec { + let Some(handle) = state.syntax_registry.view(vp.buffer_id) else { + return Vec::new(); + }; + let Some(bundle) = handle.current() else { + return Vec::new(); + }; + let Some(query) = state + .syntax_registry + .highlights_query(&bundle.language_name) + else { + return Vec::new(); + }; + let theme = state + .syntax_registry + .theme() + .lock() + .expect("theme mutex poisoned") + .clone(); + + let source_len = bundle.source.len() as u64; + let vis_start = vp.visible.start.min(source_len); + let vis_end = vp.visible.end.min(source_len); + if vis_end <= vis_start { + return Vec::new(); + } + + let capture_names = query.capture_names(); + let highlights = crate::syntax::compute_highlight_spans(&query, &bundle); + let mut out = Vec::new(); + for hs in highlights { + let s = u64::from(hs.start_byte).max(vis_start); + let e = u64::from(hs.end_byte).min(vis_end); + if e <= s { + continue; // No overlap with the viewport. + } + let Some(name) = capture_names.get(hs.capture_index as usize) else { + continue; + }; + let style = theme.lookup(name); + if style == Style::default() { + continue; // Nothing to render — skip the wire byte. + } + out.push(StyleSpan { + range: ByteRange { start: s, end: e }, + style, + }); + } + out +} + +/// The buffer's CRDT version projected to a monotonic scalar — the +/// `generation` anchor for the semantic frame. `0` when the buffer is +/// absent or not CRDT-backed (a `semantic_render` session always +/// negotiates `crdt_replica`, so in practice the buffer is CRDT-backed +/// before any semantic frame is produced; the fallback keeps this +/// total). +#[cfg(feature = "crdt")] +fn buffer_generation(state: &EditorState, buffer_id: BufferId) -> u64 { + let core = state.core.borrow(); + let registry = core.registry.clone(); + let reg = registry.borrow(); + reg.get(buffer_id) + .ok() + .and_then(crate::buffer::Buffer::crdt_state) + .map_or(0, crate::crdt::CrdtState::version_scalar) +} + +/// Non-CRDT builds cannot host a semantic session (the negotiation +/// dependency rule requires `crdt_replica`, gated on the `crdt` +/// feature), so this is never reached with a live viewport; it exists +/// only to keep `render_frame` total across feature flavors. +#[cfg(not(feature = "crdt"))] +#[allow(clippy::missing_const_for_fn)] +fn buffer_generation(_state: &EditorState, _buffer_id: BufferId) -> u64 { + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cell::CellSize; + use crate::editor::EditorState; + use crate::instance_render::RenderState; + use crate::protocol::FrontendId; + + fn empty_state() -> EditorState { + EditorState::new() + } + + #[test] + fn emits_nothing_before_viewport_declared() { + let mut s = SemanticRenderState::new(); + assert!( + s.render_frame(&empty_state()).is_empty(), + "no StyleSpans may be emitted before the frontend declares a viewport" + ); + } + + #[test] + fn after_viewport_emits_style_spans_message_then_suppresses() { + let state = empty_state(); + let mut s = SemanticRenderState::new(); + // Pick whatever buffer the fresh editor's active window holds. + let buffer_id = { + let core = state.core.borrow(); + core.active_window().buffer_id + }; + s.set_viewport(buffer_id, ByteRange { start: 0, end: 4096 }, 0); + + let first = s.render_frame(&state); + assert_eq!(first.len(), 1, "first post-viewport frame emits once"); + match &first[0] { + InstanceMessage::StyleSpans { + buffer_id: b, + generation, + .. + } => { + assert_eq!(*b, buffer_id); + assert_eq!(*generation, 0, "fresh scratch buffer has generation 0"); + } + other => panic!("expected StyleSpans, got {other:?}"), + } + + // Nothing changed → byte-identical frame is suppressed. + assert!( + s.render_frame(&state).is_empty(), + "an unchanged frame must be suppressed" + ); + } + + #[test] + fn viewport_with_zero_width_range_yields_empty_span_set() { + let state = empty_state(); + let mut s = SemanticRenderState::new(); + let buffer_id = { + let core = state.core.borrow(); + core.active_window().buffer_id + }; + // Degenerate viewport: end <= start after clamping. + s.set_viewport(buffer_id, ByteRange { start: 10, end: 10 }, 0); + let msgs = s.render_frame(&state); + // First frame still ships once (clears any prior styling), + // carrying an empty span set. + assert_eq!(msgs.len(), 1); + match &msgs[0] { + InstanceMessage::StyleSpans { spans, .. } => assert!(spans.is_empty()), + other => panic!("expected StyleSpans, got {other:?}"), + } + } + + #[test] + fn semantic_state_default_constructs() { + // The dispatcher relies on `Default`/`new` parity. + let _ = SemanticRenderState::default(); + let _ = SemanticRenderState::new(); + } + + #[test] + fn sibling_of_render_state_reads_same_editor_state() { + // Documents the M11.2 contract: a grid RenderState and a + // SemanticRenderState observe the same EditorState without + // interfering — the dispatcher selects the projection per + // session, not per buffer. + let state = empty_state(); + let mut grid = RenderState::new(CellSize::new(24, 80)); + let mut sem = SemanticRenderState::new(); + let buffer_id = { + let core = state.core.borrow(); + core.active_window().buffer_id + }; + sem.set_viewport(buffer_id, ByteRange { start: 0, end: 80 }, 0); + + let grid_msgs = grid.render_frame(&state, &[]); + let sem_msgs = sem.render_frame(&state); + assert!( + matches!(grid_msgs[0], InstanceMessage::CellDelta { .. }), + "grid projection still produces CellDelta" + ); + assert!( + sem_msgs + .iter() + .all(|m| matches!(m, InstanceMessage::StyleSpans { .. })), + "semantic projection produces only StyleSpans, never CellDelta" + ); + let _ = FrontendId::LOCAL; // import anchor for future fid-scoped tests + } +} From efa8f6f8eda86ebf98ee36ecede31d8dd23a4477 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 18 May 2026 20:47:18 -0400 Subject: [PATCH 3/6] M11.3: Decorations projection (selection + diagnostics) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SemanticRenderState now also projects InstanceMessage::Decorations from the instance-side state pmacs actually has. - Selection: per-window byte-native state via active_window_for(fid) (SemanticRenderState now carries the session FrontendId), gated to the declared buffer and clipped to the viewport → DecorationKind::Selection. - Diagnostics: the shared DiagnosticStore keyed by file URI. Made lsp::path_to_file_uri pub(crate) (byte-identical to the Lua file_uri_for) so the projection reproduces the exact store key from core.file_path. LSP (line,col) -> byte via a line-start scan against the buffer source; severity -> DiagnosticError/Warning/ Info/Hint. Clipped to the viewport. - StyleSpans and Decorations suppress unchanged frames independently (separate last_* maps): a selection move doesn't force a styling re-send and vice versa. - Deliberately NOT emitted: SearchMatch/SearchMatchActive (no instance search-hit store), CurrentLine (frontend derives from CursorByte; emitting it would breach the contract boundary). - InlineAdornments/BlockAdornments/FoldState remain unproduced by design — no inlay/blame/lens/fold/diff source exists in pmacs yet. Honest stubs (the M11.1 "declared, not yet wired" discipline), not empty messages every frame. Dispatcher updated for SemanticRenderState::new(frontend_id). Lib (1394 crdt / 1239 non-crdt) + integration green on both feature flavors; clippy -D warnings clean on both. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 24 +++ src/daemon.rs | 2 +- src/lsp.rs | 8 +- src/semantic_render.rs | 471 ++++++++++++++++++++++++++++++++++------- 4 files changed, 422 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f884196..584f771 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,30 @@ The first real producer. The instance now projects syntax styling 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. + ## [1.0.0] --- 2026-05-18 First stable release. Builds on the 0.1.0 preview (M1–M6) with the diff --git a/src/daemon.rs b/src/daemon.rs index 71640b2..bfbeac6 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1190,7 +1190,7 @@ fn handle_session_established( if semantic_render { semantic_states.insert( frontend_id, - crate::semantic_render::SemanticRenderState::new(), + crate::semantic_render::SemanticRenderState::new(frontend_id), ); } else { let mut render_state = RenderState::new(initial_size); diff --git a/src/lsp.rs b/src/lsp.rs index 283dbd0..946dcb0 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -1857,7 +1857,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. diff --git a/src/semantic_render.rs b/src/semantic_render.rs index c46615d..ce6b4cd 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -28,7 +28,9 @@ use std::collections::HashMap; use crate::buffer::BufferId; use crate::cell::Style; use crate::editor::EditorState; -use crate::protocol::{ByteRange, InstanceMessage, StyleSpan}; +use crate::protocol::{ + ByteRange, Decoration, DecorationKind, FrontendId, InstanceMessage, StyleSpan, +}; /// The viewport a `semantic_render` frontend last declared. #[derive(Clone, Debug, Eq, PartialEq)] @@ -43,10 +45,16 @@ struct DeclaredViewport { } /// Owns one `semantic_render` session's projection state: the last -/// viewport the frontend declared, and the last `StyleSpans` payload -/// shipped per buffer (for byte-identical-frame suppression). -#[derive(Default)] +/// viewport the frontend declared, and the last `StyleSpans` / +/// `Decorations` payloads shipped per buffer (for byte-identical-frame +/// suppression). 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 @@ -57,13 +65,23 @@ pub struct SemanticRenderState { /// nothing — the steady-state cost between edits is one map /// lookup. True per-span delta encoding is M11.4. last_sent: HashMap)>, + /// Same unchanged-frame suppression for the `Decorations` family + /// (T M11.3), tracked independently of `last_sent` so a styling + /// change does not force a decorations re-send and vice versa. + last_decorations: HashMap)>, } impl SemanticRenderState { - /// Fresh session state: no viewport declared, nothing sent. + /// Fresh session state for frontend `frontend_id`: no viewport + /// declared, nothing sent. #[must_use] - pub fn new() -> Self { - Self::default() + 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 @@ -80,12 +98,20 @@ impl SemanticRenderState { /// Project one frame. /// - /// Returns at most one [`InstanceMessage::StyleSpans`], scoped to - /// the declared viewport. Returns an empty vec when: no viewport - /// has been declared yet; the buffer has no parse view or settled - /// tree; the language has no highlights query; or the scoped span - /// set is byte-identical to the last one shipped at the same - /// generation (the unchanged-frame fast path). + /// 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 { let Some(vp) = self.viewport.clone() else { // Emit nothing before the frontend declares a viewport. @@ -93,29 +119,186 @@ impl SemanticRenderState { }; let generation = buffer_generation(state, vp.buffer_id); - let spans = scoped_style_spans(state, &vp); + let mut out = Vec::new(); + // --- StyleSpans (T M11.2) --- + let spans = scoped_style_spans(state, &vp); // Unchanged-frame suppression (M11.4 replaces this with // per-span delta encoding). A buffer with an empty scoped set // still suppresses correctly: the first empty frame ships once // (clearing any prior styling on the frontend), subsequent // identical empty frames are squelched. - if let Some((last_gen, last_spans)) = self.last_sent.get(&vp.buffer_id) - && *last_gen == generation - && *last_spans == spans - { - return Vec::new(); + let style_unchanged = self + .last_sent + .get(&vp.buffer_id) + .is_some_and(|(g, s)| *g == generation && *s == spans); + if !style_unchanged { + self.last_sent + .insert(vp.buffer_id, (generation, spans.clone())); + out.push(InstanceMessage::StyleSpans { + buffer_id: vp.buffer_id, + generation, + spans, + }); } - self.last_sent - .insert(vp.buffer_id, (generation, spans.clone())); - vec![InstanceMessage::StyleSpans { - buffer_id: vp.buffer_id, - generation, - spans, - }] + // --- Decorations (T M11.3) --- + let decorations = self.scoped_decorations(state, &vp); + let deco_unchanged = self + .last_decorations + .get(&vp.buffer_id) + .is_some_and(|(g, d)| *g == generation && *d == decorations); + if !deco_unchanged { + self.last_decorations + .insert(vp.buffer_id, (generation, decorations.clone())); + out.push(InstanceMessage::Decorations { + buffer_id: vp.buffer_id, + decorations, + }); + } + + 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 { + 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 { + let start = lo.max(vp.visible.start); + let end = hi.min(vp.visible.end); + if end <= start { + return None; + } + Some(ByteRange { start, end }) +} + +/// 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 { + 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 { + 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, @@ -213,87 +396,213 @@ mod tests { 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:?}" + ); + } + } + #[test] fn emits_nothing_before_viewport_declared() { - let mut s = SemanticRenderState::new(); + let mut s = local(); assert!( s.render_frame(&empty_state()).is_empty(), - "no StyleSpans may be emitted before the frontend declares a viewport" + "nothing may be emitted before the frontend declares a viewport" ); } #[test] - fn after_viewport_emits_style_spans_message_then_suppresses() { + fn first_post_viewport_frame_ships_styles_and_decorations_then_suppresses() { let state = empty_state(); - let mut s = SemanticRenderState::new(); - // Pick whatever buffer the fresh editor's active window holds. - let buffer_id = { - let core = state.core.borrow(); - core.active_window().buffer_id - }; + let mut s = local(); + let buffer_id = active_buffer(&state); s.set_viewport(buffer_id, ByteRange { start: 0, end: 4096 }, 0); + // Empty scratch buffer: no syntax spans, no selection, no + // diagnostics — but the first frame ships both messages once + // (each clears any prior frontend state), carrying empty sets. let first = s.render_frame(&state); - assert_eq!(first.len(), 1, "first post-viewport frame emits once"); - match &first[0] { - InstanceMessage::StyleSpans { - buffer_id: b, - generation, - .. - } => { - assert_eq!(*b, buffer_id); - assert_eq!(*generation, 0, "fresh scratch buffer has generation 0"); - } - other => panic!("expected StyleSpans, got {other:?}"), - } + assert_eq!(first.len(), 2, "first frame ships StyleSpans + Decorations"); + assert_semantic_only(&first); + let has = |pred: fn(&InstanceMessage) -> bool| first.iter().any(pred); + assert!(has(|m| matches!(m, InstanceMessage::StyleSpans { generation: 0, .. }))); + assert!(has(|m| matches!(m, InstanceMessage::Decorations { .. }))); - // Nothing changed → byte-identical frame is suppressed. + // Nothing changed → both families suppressed. assert!( s.render_frame(&state).is_empty(), - "an unchanged frame must be suppressed" + "an unchanged frame must be fully suppressed" ); } #[test] - fn viewport_with_zero_width_range_yields_empty_span_set() { + fn selection_projects_as_a_decoration_clipped_to_viewport() { let state = empty_state(); - let mut s = SemanticRenderState::new(); - let buffer_id = { - let core = state.core.borrow(); - core.active_window().buffer_id - }; - // Degenerate viewport: end <= start after clamping. - s.set_viewport(buffer_id, ByteRange { start: 10, end: 10 }, 0); - let msgs = s.render_frame(&state); - // First frame still ships once (clears any prior styling), - // carrying an empty span set. - assert_eq!(msgs.len(), 1); - match &msgs[0] { - InstanceMessage::StyleSpans { spans, .. } => assert!(spans.is_empty()), - other => panic!("expected StyleSpans, got {other:?}"), + let buffer_id = active_buffer(&state); + // Put a selection on LOCAL's active window: anchor 2, cursor + // 5 → region (2, 5). region() compares offsets only, so the + // empty scratch buffer is fine for this projection test. + { + 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: 2 }); + win.cursor = 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 deco = msgs + .iter() + .find_map(|m| match m { + InstanceMessage::Decorations { decorations, .. } => Some(decorations), + _ => None, + }) + .expect("a Decorations message"); + assert_eq!(deco.len(), 1, "exactly the selection decoration"); + assert_eq!(deco[0].kind, DecorationKind::Selection); + // region (2,5) clipped to viewport [3,64) → [3,5). + assert_eq!(deco[0].range, ByteRange { start: 3, end: 5 }); } #[test] - fn semantic_state_default_constructs() { - // The dispatcher relies on `Default`/`new` parity. - let _ = SemanticRenderState::default(); - let _ = SemanticRenderState::new(); + fn diagnostics_project_with_line_col_to_byte_and_severity() { + // Buffer with two short lines so line/col → byte is exercised. + // "abc\nde" → line 0 starts at byte 0, line 1 at byte 4. + let state = empty_state(); + let buffer_id = active_buffer(&state); + { + let mut core = state.core.borrow_mut(); + let reg = core.registry.clone(); + reg.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"); + // The diag store is keyed by file URI; point the editor's + // active file path at one and seed a diagnostic there. + core.file_path = Some(std::path::PathBuf::from("/tmp/m113.rs")); + } + let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m113.rs")); + { + let store = state.lsp_manager.borrow().diag_store(); + let mut g = store.lock().expect("diag store"); + g.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, + }], + ); + } + let mut s = local(); + s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); + + let msgs = s.render_frame(&state); + let deco = msgs + .iter() + .find_map(|m| match m { + InstanceMessage::Decorations { decorations, .. } => Some(decorations), + _ => None, + }) + .expect("a Decorations message"); + assert_eq!(deco.len(), 1); + assert_eq!(deco[0].kind, DecorationKind::DiagnosticWarning); + // line 1 starts at byte 4; cols [0,2) → bytes [4,6). + assert_eq!(deco[0].range, ByteRange { start: 4, end: 6 }); + } + + #[test] + fn styles_and_decorations_suppress_independently() { + // A selection change must re-send Decorations without forcing + // a StyleSpans re-send (and the empty scratch styling stays + // suppressed). + 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 shipped + assert!(s.render_frame(&state).is_empty(), "steady state silent"); + + // Introduce a selection → only Decorations should re-emit. + { + let mut core = state.core.borrow_mut(); + let win = core + .active_window_mut_for(FrontendId::LOCAL) + .expect("LOCAL window"); + win.selection = Some(crate::window::Selection { anchor: 1 }); + win.cursor = 4; + } + let msgs = s.render_frame(&state); + assert_eq!(msgs.len(), 1, "only the changed family re-emits"); + assert!(matches!(msgs[0], InstanceMessage::Decorations { .. })); + } + + #[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() { - // Documents the M11.2 contract: a grid RenderState and a - // SemanticRenderState observe the same EditorState without - // interfering — the dispatcher selects the projection per - // session, not per buffer. + // 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 = SemanticRenderState::new(); - let buffer_id = { - let core = state.core.borrow(); - core.active_window().buffer_id - }; + 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, &[]); @@ -302,12 +611,12 @@ mod tests { matches!(grid_msgs[0], InstanceMessage::CellDelta { .. }), "grid projection still produces CellDelta" ); + assert_semantic_only(&sem_msgs); assert!( - sem_msgs + !sem_msgs .iter() - .all(|m| matches!(m, InstanceMessage::StyleSpans { .. })), - "semantic projection produces only StyleSpans, never CellDelta" + .any(|m| matches!(m, InstanceMessage::CellDelta { .. })), + "semantic projection never produces CellDelta" ); - let _ = FrontendId::LOCAL; // import anchor for future fid-scoped tests } } From 071e79ffee0ebabd31e671719fdb85a98e55fbf2 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 18 May 2026 21:04:49 -0400 Subject: [PATCH 4/6] M11.4: segment diffing for StyleSpans/Decorations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the M11.2/M11.3 coarse whole-payload suppression with a CellDelta-style diff lifted from positional cells to byte-anchored ranges. - protocol.rs: StyleSpans/Decorations refined to { buffer_id, generation, full: bool, segments: Vec<...Segment> }. New StyleSegment{range,spans} / DecorationSegment{range,decorations}. full=true → frontend discards prior state for the buffer; full=false → replace styling only within each segment's range, bytes in no segment keep prior state. Decorations gains generation for parity. Each segment carries ALL current items intersecting its range (clipped), so an unchanged span overlapping a dirty range is reconstructed. ResourceOffer stays an honest stub (no producer). - semantic_render.rs: LastFrame baseline per buffer (viewport region + full item set). full on first frame / viewport-region change; else symmetric-difference the ordered sets, coalesce changed ranges into maximal disjoint dirty intervals, emit one segment per interval with current items clipped to it; suppress when no dirty interval. Independent baselines for styling vs decorations. Byte offsets cascade on edits (an insert shifts later spans), so an incremental post-edit frame dirties [edit, viewport_end) — bounded; no-edit frames (cursor/scroll/selection-only) still cost nothing. 10 semantic_render tests (full-on-first/viewport-change, incremental dirty intervals, independent suppression, unchanged-overlapping reconstruction) + updated protocol round-trip. Lib (1397 crdt / 1242 non-crdt) + integration green both flavors; clippy -D warnings clean both. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 23 ++ src/protocol.rs | 106 +++++++-- src/semantic_render.rs | 512 +++++++++++++++++++++++++++++++---------- 3 files changed, 497 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 584f771..0af5ce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,29 @@ from the editor state pmacs actually has instance-side: 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. + ## [1.0.0] --- 2026-05-18 First stable release. Builds on the 0.1.0 preview (M1–M6) with the diff --git a/src/protocol.rs b/src/protocol.rs index dd87fa9..97fbb1e 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -619,10 +619,29 @@ pub enum InstanceMessage { /// 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. Diffs against the previous - /// frame the way `CellDelta` does today: changed spans only, - /// scoped to the range the frontend last declared via - /// `FrontendEvent::Viewport`. + /// 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 @@ -634,18 +653,31 @@ pub enum InstanceMessage { buffer_id: crate::buffer::BufferId, /// CRDT generation the spans were computed against. generation: u64, - /// Styled byte runs, scoped to the declared viewport. - spans: Vec, + /// `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, }, /// 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, - /// Decoration regions for the declared viewport. - decorations: Vec, + /// 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, }, /// T M11.1 — inlay hints, blame, lens, virtual text. Anchored at /// a single offset with a placement; occupies no document bytes — @@ -756,6 +788,20 @@ pub struct StyleSpan { 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, +} + /// 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. @@ -790,6 +836,17 @@ pub struct Decoration { 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, +} + /// Where an [`InlineAdornment`] sits relative to its anchor offset. #[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub enum AdornmentPlacement { @@ -3844,23 +3901,32 @@ mod tests { InstanceMessage::StyleSpans { buffer_id: bid, generation: 7, - spans: vec![StyleSpan { + full: true, + segments: vec![StyleSegment { range: ByteRange { start: 0, end: 12 }, - style: crate::cell::Style::default(), + spans: vec![StyleSpan { + range: ByteRange { start: 0, end: 12 }, + style: crate::cell::Style::default(), + }], }], }, InstanceMessage::Decorations { buffer_id: bid, - decorations: vec![ - Decoration { - range: ByteRange { start: 3, end: 9 }, - kind: DecorationKind::DiagnosticError, - }, - Decoration { - range: ByteRange { start: 20, end: 20 }, - kind: DecorationKind::CurrentLine, - }, - ], + 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, diff --git a/src/semantic_render.rs b/src/semantic_render.rs index ce6b4cd..7b72d76 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -29,7 +29,8 @@ use crate::buffer::BufferId; use crate::cell::Style; use crate::editor::EditorState; use crate::protocol::{ - ByteRange, Decoration, DecorationKind, FrontendId, InstanceMessage, StyleSpan, + ByteRange, Decoration, DecorationKind, DecorationSegment, FrontendId, InstanceMessage, + StyleSegment, StyleSpan, }; /// The viewport a `semantic_render` frontend last declared. @@ -44,10 +45,20 @@ struct DeclaredViewport { 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 { + visible: ByteRange, + items: Vec, +} + /// Owns one `semantic_render` session's projection state: the last -/// viewport the frontend declared, and the last `StyleSpans` / -/// `Decorations` payloads shipped per buffer (for byte-identical-frame -/// suppression). +/// 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 @@ -60,15 +71,14 @@ pub struct SemanticRenderState { /// bootstraps its rope from `BufferSnapshot`, declares what is on /// screen, and only then receives styling for exactly that range. viewport: Option, - /// Last `(generation, spans)` shipped, keyed by buffer. A frame - /// whose scoped span set and generation match the last send emits - /// nothing — the steady-state cost between edits is one map - /// lookup. True per-span delta encoding is M11.4. - last_sent: HashMap)>, - /// Same unchanged-frame suppression for the `Decorations` family - /// (T M11.3), tracked independently of `last_sent` so a styling - /// change does not force a decorations re-send and vice versa. - last_decorations: HashMap)>, + /// 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>, + /// 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>, } impl SemanticRenderState { @@ -121,40 +131,107 @@ impl SemanticRenderState { let generation = buffer_generation(state, vp.buffer_id); let mut out = Vec::new(); - // --- StyleSpans (T M11.2) --- + // --- StyleSpans (T M11.2 producer, T M11.4 diff) --- let spans = scoped_style_spans(state, &vp); - // Unchanged-frame suppression (M11.4 replaces this with - // per-span delta encoding). A buffer with an empty scoped set - // still suppresses correctly: the first empty frame ships once - // (clearing any prior styling on the frontend), subsequent - // identical empty frames are squelched. - let style_unchanged = self - .last_sent - .get(&vp.buffer_id) - .is_some_and(|(g, s)| *g == generation && *s == spans); - if !style_unchanged { - self.last_sent - .insert(vp.buffer_id, (generation, spans.clone())); + 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, - spans, + 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) --- + // --- Decorations (T M11.3 producer, T M11.4 diff) --- let decorations = self.scoped_decorations(state, &vp); - let deco_unchanged = self - .last_decorations - .get(&vp.buffer_id) - .is_some_and(|(g, d)| *g == generation && *d == decorations); - if !deco_unchanged { - self.last_decorations - .insert(vp.buffer_id, (generation, decorations.clone())); + 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, - decorations, + 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 @@ -251,6 +328,86 @@ fn clip_to_viewport(lo: u64, hi: u64, vp: &DeclaredViewport) -> Option( + prev: &[T], + curr: &[T], + range_of: impl Fn(&T) -> ByteRange, +) -> Vec { + let mut changed: Vec = 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) -> Vec { + ranges.retain(|r| r.end > r.start); + ranges.sort_by_key(|r| (r.start, r.end)); + let mut out: Vec = 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 { + 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 { + 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; @@ -422,6 +579,68 @@ mod tests { } } + /// Find the `Decorations` message and flatten its segments into + /// `(full, all decorations across segments)`. + fn decorations_of(msgs: &[InstanceMessage]) -> Option<(bool, Vec)> { + 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)> { + 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(); @@ -432,21 +651,23 @@ mod tests { } #[test] - fn first_post_viewport_frame_ships_styles_and_decorations_then_suppresses() { + 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 buffer: no syntax spans, no selection, no - // diagnostics — but the first frame ships both messages once - // (each clears any prior frontend state), carrying empty sets. + // 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 has = |pred: fn(&InstanceMessage) -> bool| first.iter().any(pred); - assert!(has(|m| matches!(m, InstanceMessage::StyleSpans { generation: 0, .. }))); - assert!(has(|m| matches!(m, InstanceMessage::Decorations { .. }))); + 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!( @@ -459,115 +680,158 @@ mod tests { fn selection_projects_as_a_decoration_clipped_to_viewport() { let state = empty_state(); let buffer_id = active_buffer(&state); - // Put a selection on LOCAL's active window: anchor 2, cursor - // 5 → region (2, 5). region() compares offsets only, so the - // empty scratch buffer is fine for this projection test. - { - 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: 2 }); - win.cursor = 5; - } + // 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 deco = msgs - .iter() - .find_map(|m| match m { - InstanceMessage::Decorations { decorations, .. } => Some(decorations), - _ => None, - }) - .expect("a Decorations message"); - assert_eq!(deco.len(), 1, "exactly the selection decoration"); - assert_eq!(deco[0].kind, DecorationKind::Selection); + 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!(deco[0].range, ByteRange { start: 3, end: 5 }); + assert_eq!(decos[0].range, ByteRange { start: 3, end: 5 }); } #[test] fn diagnostics_project_with_line_col_to_byte_and_severity() { - // Buffer with two short lines so line/col → byte is exercised. - // "abc\nde" → line 0 starts at byte 0, line 1 at byte 4. + // "abc\nde": line 0 at byte 0, line 1 at byte 4. let state = empty_state(); let buffer_id = active_buffer(&state); - { - let mut core = state.core.borrow_mut(); - let reg = core.registry.clone(); - reg.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"); - // The diag store is keyed by file URI; point the editor's - // active file path at one and seed a diagnostic there. - core.file_path = Some(std::path::PathBuf::from("/tmp/m113.rs")); - } - let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m113.rs")); - { - let store = state.lsp_manager.borrow().diag_store(); - let mut g = store.lock().expect("diag store"); - g.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, - }], - ); - } + seed_diagnostic(&state, buffer_id); let mut s = local(); s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); - let msgs = s.render_frame(&state); - let deco = msgs - .iter() - .find_map(|m| match m { - InstanceMessage::Decorations { decorations, .. } => Some(decorations), - _ => None, - }) - .expect("a Decorations message"); - assert_eq!(deco.len(), 1); - assert_eq!(deco[0].kind, DecorationKind::DiagnosticWarning); + 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!(deco[0].range, ByteRange { start: 4, end: 6 }); + assert_eq!(decos[0].range, ByteRange { start: 4, end: 6 }); } #[test] fn styles_and_decorations_suppress_independently() { - // A selection change must re-send Decorations without forcing - // a StyleSpans re-send (and the empty scratch styling stays - // suppressed). 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 shipped + let _ = s.render_frame(&state); // first frame: both full assert!(s.render_frame(&state).is_empty(), "steady state silent"); - // Introduce a selection → only Decorations should re-emit. - { - let mut core = state.core.borrow_mut(); - let win = core - .active_window_mut_for(FrontendId::LOCAL) - .expect("LOCAL window"); - win.selection = Some(crate::window::Selection { anchor: 1 }); - win.cursor = 4; - } + // 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"); - assert!(matches!(msgs[0], InstanceMessage::Decorations { .. })); + 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 = 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] From 2ca011c368897cb94ea733fa511ed9a98f0c34d1 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 18 May 2026 21:24:36 -0400 Subject: [PATCH 5/6] M11.5: semantic frontend<->instance glue (SemanticClient + e2e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the M11 arc with the consumer side. pmacs has no GUI toolkit, so per the design note's testability strategy the deliverable is the bounded testable glue, not a GPU renderer. - src/semantic_client.rs (crdt-gated): headless SemanticClient composing the BufferMirror rope replica (M10.10) with a tile-based SemanticModel that reconstructs styling/decorations from the full + dirty-segment deltas (M11.4). Emits FrontendEvent::Viewport; 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 tiles self-contained → incremental apply is per-tile replacement with edge-clipping, no cross-span surgery. 7 unit tests. - tests/m11_5_semantic_acceptance.rs: (a) reconstruction-equivalence — incrementally-driven client asserted byte-for-byte identical to a fresh full projection across a scripted viewport/edit/selection sequence incl. a viewport jump (golden discipline, no 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, CellDelta vice versa, validating the M11.2 per-session projection through the socket. Lib (1404 crdt / 1242 non-crdt) + integration green on both feature flavors; clippy -D warnings clean on both. M11 arc complete (M11.1–M11.5). Inline/Block/Fold/ResourceOffer remain honest stubs pending their source features. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 32 ++ src/lib.rs | 6 + src/semantic_client.rs | 532 +++++++++++++++++++++++++++++ tests/m11_5_semantic_acceptance.rs | 328 ++++++++++++++++++ 4 files changed, 898 insertions(+) create mode 100644 src/semantic_client.rs create mode 100644 tests/m11_5_semantic_acceptance.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0af5ce3..b928ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,38 @@ lifted from positional cells to byte-anchored ranges. - `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 diff --git a/src/lib.rs b/src/lib.rs index c2fe253..2ab555b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -90,6 +90,12 @@ 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; diff --git a/src/semantic_client.rs b/src/semantic_client.rs new file mode 100644 index 0000000..dbb6cce --- /dev/null +++ b/src/semantic_client.rs @@ -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; +} + +fn intersect(a: ByteRange, b: ByteRange) -> Option { + 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 { + 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 { + 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 { + range: ByteRange, + items: Vec, +} + +/// 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 { + tiles: Vec>, +} + +// 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 Default for SemanticModel { + fn default() -> Self { + Self { tiles: Vec::new() } + } +} + +impl SemanticModel { + /// 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)]) { + 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) { + let mut next: Vec> = 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 { + 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 { + 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>, + decos: HashMap>, +} + +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)> = 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)> = 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 { + 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 { + 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 { + 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 { + 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 { + 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 = 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 = 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 = 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 = 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![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:?}"), + } + } +} diff --git a/tests/m11_5_semantic_acceptance.rs b/tests/m11_5_semantic_acceptance.rs new file mode 100644 index 0000000..cdda17f --- /dev/null +++ b/tests/m11_5_semantic_acceptance.rs @@ -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 { + 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 { + 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 = 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::(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 = 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" + ); +} From baddd22878798550a88bdabff09b442b8f06a21b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 18 May 2026 21:26:21 -0400 Subject: [PATCH 6/6] docs: land the semantic-frontend-protocol design note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec the M11 arc (M11.1–M11.5) implements; all five CHANGELOG entries reference it. Status header updated from "post-v1.0 design draft, no code" to an implementation map against the arc, so the landed doc is not self-contradictory. Design body unchanged; the "Open questions" remain open by design. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/semantic-frontend-protocol.md | 259 +++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/semantic-frontend-protocol.md diff --git a/docs/semantic-frontend-protocol.md b/docs/semantic-frontend-protocol.md new file mode 100644 index 0000000..33c6167 --- /dev/null +++ b/docs/semantic-frontend-protocol.md @@ -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, // { 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, // { 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, + // { 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, +}, + +/// 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, +}, + +/// 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) | 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.