diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec77c1..b928ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,140 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +#### Semantic-frontend protocol scaffolding (M11.1) + +First milestone of the M11 semantic-frontend arc (see +`docs/semantic-frontend-protocol.md`). Wire-format scaffolding only — +no producer or consumer yet; mechanically identical to the M10.5 CRDT +wire declaration. + +- `PROTOCOL_VERSION` bumped 2 → 3; `SUPPORTED_PROTOCOL_VERSIONS` now + `[1, 2, 3]`. The slice-membership handshake keeps v0.1/v1.0 binaries + connecting to v1.1 binaries unchanged. +- New `semantic_render` capability bit on `FrontendCapabilities`, + `InstanceCapabilities`, and `NegotiatedCapabilities` + (`#[serde(default)]`; instance default `false` until the M11.2 + projection seam lands). `negotiate_capabilities` AND-combines it and + enforces the `semantic_render ⇒ crdt_replica` dependency (a semantic + session is also a text replica) — rejected as a + `CapabilityMismatch`, never silently degraded. +- `InstanceMessage` gains the `SemanticFrame` family: `StyleSpans`, + `Decorations`, `InlineAdornments`, `BlockAdornments`, `FoldState`, + `ResourceOffer`; `FrontendEvent` gains `Viewport`. All anchored in + byte offsets — the instance never learns a pixel. +- `PMACS_INSTANCE_SEMANTIC_RENDER` env override mirrors the existing + per-capability test overrides. + +#### Semantic projection seam (M11.2) + +The first real producer. The instance now projects syntax styling to +`semantic_render` sessions without rasterizing to a cell grid. + +- New `SemanticRenderState` (`src/semantic_render.rs`), sibling of + `instance_render::RenderState`: reads the same `EditorState` but + emits `InstanceMessage::StyleSpans` — tree-sitter spans mapped + through the active `Theme`, scoped and clipped to the byte range the + frontend declared via `FrontendEvent::Viewport`. Emits nothing until + a viewport is declared; suppresses byte-identical frames (true + span-granularity diffing is M11.4). +- `StyleSpans.generation` is anchored to `CrdtState::version_scalar()` + — the oplog version vector summed to one monotonic non-decreasing + scalar, letting a frontend discard styling that predates an edit it + already applied optimistically. +- Dispatcher selects the projection **per session**: a semantic + session gets a `SemanticRenderState` and never `CellDelta`/grid + `Cursor` (it lays out locally) but still receives `CursorByte`, + `BufferSnapshot`, `CrdtOp`, and presence. A grid and a semantic + frontend can attach to the same buffer simultaneously. + `FrontendEvent::Viewport` is consumed (routed by authenticated + source, like `CrdtOp`). +- `InstanceCapabilities` default `semantic_render` flipped to + `cfg!(feature = "crdt")` — the "M11.2 enables semantic" moment, + analogous to the M10.8 Day-4 `multi_frontend`/`crdt_replica` flip. + +#### Decorations projection (M11.3) + +`SemanticRenderState` now also projects `InstanceMessage::Decorations` +from the editor state pmacs actually has instance-side: + +- **Selection** — per-window (per-frontend) byte-native state, scoped + to the session's active window for the declared buffer and clipped + to the viewport. `DecorationKind::Selection`. +- **Diagnostics** — the shared `DiagnosticStore`, keyed by the file + URI the LSP glue opened the document under (`lsp::path_to_file_uri` + is now `pub(crate)`, byte-identical to the Lua `file_uri_for`). + LSP `(line, col)` is converted to a byte range against the buffer + source; severity maps to `DiagnosticError`/`Warning`/`Info`/`Hint`. +- `StyleSpans` and `Decorations` suppress unchanged frames + independently — a selection move does not force a styling re-send. +- `Decorations::SearchMatch`/`SearchMatchActive`/`CurrentLine` are not + emitted: pmacs has no instance-side search-hit store, and + current-line is a pure cursor derivation the frontend already owns + via `CursorByte` (emitting it would breach the contract boundary). +- `InlineAdornments`/`BlockAdornments`/`FoldState` remain unproduced + by design — pmacs has no inlay-hint/blame/lens/fold/diff source + yet. The wire variants exist (M11.1); their producers are wired + when those features land. Honest stubs, not fabricated data. + +#### Segment diffing (M11.4) + +Coarse whole-payload re-sends replaced with a `CellDelta`-style diff, +lifted from positional cells to byte-anchored ranges. + +- `StyleSpans`/`Decorations` refined: `{ buffer_id, generation, + full: bool, segments: Vec<…Segment> }`. `full = true` is a resync + (frontend discards prior state for the buffer); `full = false` + ships only the dirty byte regions, each `StyleSegment` / + `DecorationSegment` replacing styling within its range. Bytes in no + segment keep prior state; an unchanged span overlapping a dirty + range is faithfully reconstructed (segments carry *all* current + items intersecting the range, not only changed ones). `Decorations` + also gains `generation` for parity with `StyleSpans`. +- The diff: symmetric difference of the previous/current ordered + item sets, changed ranges coalesced into maximal disjoint dirty + intervals. First frame and any viewport-region change force `full`; + an unchanged frame ships nothing. Byte offsets cascade on edits, so + an incremental frame after an edit dirties `[edit, viewport_end)` — + bounded; no-edit frames (cursor/scroll/selection) stay free. +- `ResourceOffer` remains an honest stub (no resource-bearing + adornment producer exists yet) — same discipline as M11.3. + +#### Semantic frontend↔instance glue (M11.5) + +The arc's consumer side and end-to-end coverage. pmacs has no GUI +toolkit, so — per the design note's testability strategy — the +deliverable is the bounded testable glue, not a GPU renderer. + +- New headless `SemanticClient` (`src/semantic_client.rs`, `crdt`- + gated): composes the `BufferMirror` rope replica (M10.10) with a + tile-based `SemanticModel` that reconstructs styling/decorations + from the `full` + dirty-segment deltas (M11.4). Self-contained: + no terminal, no pixels. Emits `FrontendEvent::Viewport`; exposes + read-back accessors (`text`, `effective_style_at`, + `decoration_kinds_at`, tile ranges). The M11.4 contract (segments + carry every current item intersecting their range) makes a tile + self-contained, so incremental application is a clean per-tile + replacement with edge-clipping, not cross-span surgery. +- `tests/m11_5_semantic_acceptance.rs`: (a) reconstruction- + equivalence — an incrementally-driven client is asserted byte-for- + byte identical to a fresh full projection across a scripted + viewport/edit/selection sequence including a viewport jump (the + golden discipline without a snapshot crate); (b) end-to-end — + a real daemon routes `StyleSpans`/`Decorations` to a semantic + session (after it declares a `Viewport`) and never to a grid + session, and `CellDelta` vice versa, validating the M11.2 + per-session projection through the socket. + +This completes the M11 semantic-frontend arc (M11.1–M11.5): wire + +capability scaffolding, the instance-side projection seam, +decorations, segment diffing, and the consumer-side glue with +end-to-end coverage. `InlineAdornments`/`BlockAdornments`/`FoldState`/ +`ResourceOffer` remain honest stubs pending their source features. + ## [1.0.0] --- 2026-05-18 First stable release. Builds on the 0.1.0 preview (M1–M6) with the 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. 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/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 1c02184..cc9b883 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), } } @@ -823,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(); @@ -841,7 +852,13 @@ fn dispatcher_loop( // loop to the last-dispatched value (Q11: tick-driven render // doesn't update active_frontend in the user-driving sense). let last_dispatched = editor.core.borrow().active_frontend; - let attached_fids: Vec = 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 @@ -936,15 +953,25 @@ fn dispatcher_loop( let _ = ensure_active_buffer_crdt_backed(editor, *fid); } - // T M10.9 — gather other-frontend presences for the - // overlay paint. Reads `last_broadcast` (updated by the - // sweep below); other-frontend snapshots lag by at most - // one tick. Imperceptible at frame-rate cadence. - let other_presences = session_registry.other_presences_for(*fid); - let render_state = render_states - .get_mut(fid) - .expect("render_state present for attached fid"); - let messages = render_state.render_frame(editor, &other_presences); + // Projection selected per session (T M11.2). A semantic + // session produces `StyleSpans` scoped to its declared + // viewport and NEVER `CellDelta` / grid `Cursor` (it lays + // out locally); it still receives `CursorByte` below + // (semantic implies `crdt_replica`) and participates in + // presence. A grid session takes the M5.2 cell path. + let messages = if let Some(sem) = semantic_states.get_mut(fid) { + sem.render_frame(editor) + } else { + // T M10.9 — gather other-frontend presences for the + // overlay paint. Reads `last_broadcast` (updated by + // the sweep below); other-frontend snapshots lag by + // at most one tick. Imperceptible at frame cadence. + let other_presences = session_registry.other_presences_for(*fid); + let render_state = render_states + .get_mut(fid) + .expect("render_state present for attached grid fid"); + render_state.render_frame(editor, &other_presences) + }; // T M10.6 per-frontend presence sweep. The snapshot is // computed from this frontend's view; the sweep then @@ -1032,6 +1059,7 @@ fn dispatcher_loop( // Drop the broken connection. streams.remove(fid); render_states.remove(fid); + semantic_states.remove(fid); term_sizes.remove(fid); session_registry.unregister_session(*fid); editor.core.borrow_mut().unregister_frontend_view(*fid); @@ -1069,6 +1097,7 @@ fn dispatcher_loop( event, editor, &mut render_states, + &mut semantic_states, &mut streams, &mut term_sizes, &mut session_registry, @@ -1082,6 +1111,7 @@ fn dispatcher_loop( event, editor, &mut render_states, + &mut semantic_states, &mut streams, &mut term_sizes, &mut session_registry, @@ -1106,10 +1136,84 @@ fn dispatcher_loop( /// Handle one `DispatcherEvent`. Extracted so the dispatcher loop /// can both timeout-recv and burst-drain via the same code path. +/// T M11.2 — extracted from `handle_dispatcher_event`'s +/// `SessionEstablished` arm (kept the parent under the 100-line +/// clippy ceiling). Registers the frontend's view, bootstraps the +/// `BufferMirror` via `BufferSnapshot` when `crdt_replica`, and +/// selects the per-session projection: a `semantic_render` session +/// gets a `SemanticRenderState` (no grid `RenderState`, no +/// initial-full-grid analogue — it emits nothing until the frontend +/// declares a viewport); every other session keeps the M5.3 +/// force-full-grid grid path. +#[allow(clippy::too_many_arguments)] +fn handle_session_established( + editor: &mut EditorState, + render_states: &mut HashMap, + 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(frontend_id), + ); + } else { + let mut render_state = RenderState::new(initial_size); + render_state.force_full_grid_resync(); + render_states.insert(frontend_id, render_state); + } + streams.insert(frontend_id, write_stream); + term_sizes.insert(frontend_id, initial_size); + + // Stamp active_frontend so the initial render's Lua statusline + // code sees the right fid. + editor.core.borrow_mut().active_frontend = frontend_id; +} + fn handle_dispatcher_event( event: DispatcherEvent, editor: &mut EditorState, render_states: &mut HashMap, + semantic_states: &mut HashMap, streams: &mut HashMap, term_sizes: &mut HashMap, session_registry: &mut SessionRegistry, @@ -1119,48 +1223,20 @@ fn handle_dispatcher_event( frontend_id, session_state, initial_size, - mut write_stream, + write_stream, } => { - // Register the frontend's view (M10.8 Day 3: fresh - // scratch buffer view; future milestones may clone - // LOCAL's view or take an explicit initial-buffer - // argument). - let scratch_view = build_fresh_frontend_view(editor); - editor - .core - .borrow_mut() - .register_frontend_view(frontend_id, scratch_view); - - // T M10.10: bootstrap the new frontend's `BufferMirror` - // by sending one `BufferSnapshot` per CRDT-backed buffer. - // Gated on the negotiated `crdt_replica` capability — - // v0.1 / non-replica frontends never receive the variant - // (postcard would hard-error on the unknown variant; see - // M10.10-FRAMING.md Refinement 3). Ordering: snapshots - // are sent BEFORE any CellDelta flows (the next per-tick - // render is the first CellDelta source), so the mirror - // is initialized before any local-edit path can - // reference it. - let crdt_replica = session_state.negotiated_capabilities.crdt_replica; - if crdt_replica { - send_buffer_snapshots(editor, &mut write_stream); - } - - // Register the session in the registry (presence + - // capability filters). - session_registry.register_session(frontend_id, session_state); - - // Allocate per-frontend RenderState; force initial - // full-grid sync so the first frame paints everything. - let mut render_state = RenderState::new(initial_size); - render_state.force_full_grid_resync(); - render_states.insert(frontend_id, render_state); - streams.insert(frontend_id, write_stream); - term_sizes.insert(frontend_id, initial_size); - - // Stamp active_frontend so the initial render's Lua - // statusline code sees the right fid. - editor.core.borrow_mut().active_frontend = frontend_id; + handle_session_established( + editor, + render_states, + semantic_states, + streams, + term_sizes, + session_registry, + frontend_id, + session_state, + initial_size, + write_stream, + ); } DispatcherEvent::FrontendEvent { source, event } => { match event { @@ -1214,21 +1290,51 @@ fn handle_dispatcher_event( handle_remote_crdt_op(editor, source, buffer_id, op); } } + FrontendEvent::Viewport { + buffer_id, + visible, + generation, + .. + } => { + // T M11.2 — feed the semantic projection the byte + // range the frontend has on screen. Routed by the + // authenticated `source` (the client-supplied + // `frontend_id` field is not trusted, consistent + // with the CrdtOp source-trust rule). A grid + // session never sends this; if one does, there is + // no `SemanticRenderState` to update and it is a + // benign no-op. + if let Some(sem) = semantic_states.get_mut(&source) { + sem.set_viewport(buffer_id, visible, generation); + } + } _ => { let term_size = *term_sizes .get(&source) .expect("term_size present for source"); - let render_state = render_states - .get_mut(&source) - .expect("render_state present for source"); let mut term_size = term_size; - apply_event(editor, event, &mut term_size, render_state); - term_sizes.insert(source, term_size); + if let Some(render_state) = render_states.get_mut(&source) { + apply_event(editor, event, &mut term_size, render_state); + term_sizes.insert(source, term_size); + } else { + // T M11.2 — a semantic (grid-less) session has + // no `RenderState`. Key/Mouse/Paste/Focus + // command handling for semantic frontends is + // M11.5 scope; until then these events are + // dropped rather than panicking the + // dispatcher on the absent grid state. + debug_assert!( + semantic_states.contains_key(&source), + "fid with neither a render_state nor a semantic_state \ + sent a frontend event" + ); + } } } } DispatcherEvent::SessionDetached { frontend_id } => { render_states.remove(&frontend_id); + semantic_states.remove(&frontend_id); streams.remove(&frontend_id); term_sizes.remove(&frontend_id); session_registry.unregister_session(frontend_id); @@ -1757,7 +1863,18 @@ fn apply_event( } FrontendEvent::Paste { .. } | FrontendEvent::FocusGained(_) - | FrontendEvent::FocusLost(_) => { + | FrontendEvent::FocusLost(_) + // T M11.1: the semantic-frontend viewport declaration. Its + // consumer is the instance-side projection seam + // (`SemanticRenderState`, M11.2), which scopes the + // SemanticFrame family to this byte range. M11.1 only + // declares the wire shape; no projection seam exists yet and + // the instance advertises `semantic_render: false`, so + // negotiation rejects any session that would emit this — it + // is unreachable in practice. Dropped silently until M11.2 + // wires the consumer (same "declared, not yet wired" posture + // CrdtOp had between M10.5 and M10.8). + | FrontendEvent::Viewport { .. } => { // v0.1: silently ignored. Future work surfaces these // through Lua hooks (paste-text-fn, focus-changed-hook). } 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/lib.rs b/src/lib.rs index 9668714..2ab555b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -90,6 +90,13 @@ pub mod project; pub mod project_index; pub mod protocol; pub mod rope; +// T M11.5 — the headless semantic consumer composes BufferMirror + +// optimistic (both `crdt`-gated) and is only meaningful on a +// `semantic_render` session, which the negotiation dependency rule +// ties to `crdt_replica`. Gated to match. +#[cfg(feature = "crdt")] +pub mod semantic_client; +pub mod semantic_render; pub mod signature; pub mod socket_path; pub mod syntax; diff --git a/src/lsp.rs b/src/lsp.rs index fd078a4..e3f1e2a 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -2452,7 +2452,13 @@ fn canonicalize_root_for_scope(root: &Path) -> PathBuf { root.canonicalize().unwrap_or_else(|_| root.to_path_buf()) } -fn path_to_file_uri(path: &std::path::Path) -> String { +/// `file://` URI encoder. Byte-identical to +/// `builtin/runtime/lsp.lua`'s `file_uri_for` (same passthrough set), +/// so a URI built here keys into the same `DiagnosticStore` entry the +/// Lua LSP glue opened the document under. T M11.3 reuses this from +/// `crate::semantic_render` for the diagnostics projection — hence +/// `pub(crate)`. +pub(crate) fn path_to_file_uri(path: &std::path::Path) -> String { // Minimal file:// URI encoder: percent-encode anything outside // the LSP-friendly set. Adequate for v0.1 (paths in a typical // project root); a fuller URL crate would be overkill here. 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..97fbb1e 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,114 @@ pub enum InstanceMessage { /// converts to `usize` for the loro API. byte_pos: crate::rope::Position, }, + /// T M11.1 — syntax + face styling over the semantic frontend's + /// current viewport range. `generation` ties the spans to a CRDT + /// version so the frontend can discard styling that predates an + /// edit it has already applied optimistically. Ships **no text** — + /// the frontend holds the rope via the `crdt_replica` machinery + /// and interprets these spans over it. + /// + /// # Diff shape (T M11.4) + /// + /// Mirrors `CellDelta`'s `full_grid` + changed-runs structure, + /// lifted from positional cells to byte-anchored ranges. `full = + /// true` is a resync: the frontend discards all prior styling for + /// `buffer_id` and the `segments` are authoritative for the whole + /// declared viewport (first frame after a `Viewport`, a viewport + /// jump, or a generation discontinuity). `full = false` is + /// incremental: each [`StyleSegment`] replaces styling **only** + /// within its `range`; bytes covered by no segment keep their + /// previously-applied style. A frame whose styling is unchanged + /// ships no `StyleSpans` at all. + /// + /// Because byte offsets cascade on edits (an insert shifts every + /// later span), an incremental frame after an edit dirties + /// `[edit, viewport_end)` — still bounded, and no-edit frames + /// (cursor move, scroll within the declared viewport, selection) + /// cost nothing. Each segment carries *all* current spans + /// intersecting its range (clipped), not only changed ones, so an + /// unchanged span overlapping a dirty range is faithfully + /// reconstructed. + /// + /// Gated on negotiated `semantic_render`; never sent to a grid + /// session (the daemon's per-session outgoing filter — wired with + /// the producer in M11.2 — never emits it there, so postcard's + /// hard-error on unknown variants is mooted exactly as it is for + /// `CursorByte`). + StyleSpans { + /// Buffer these spans interpret. + buffer_id: crate::buffer::BufferId, + /// CRDT generation the spans were computed against. + generation: u64, + /// `true` → discard all prior styling for `buffer_id` first; + /// `segments` are authoritative for the declared viewport. + full: bool, + /// Dirty byte regions and the styling now covering them. + segments: Vec, + }, + /// T M11.1 — diagnostics, search hits, current-line, and any + /// other "this region means something" overlay, as offset ranges + /// plus a kind. Peer selection is **not** here — it stays on the + /// existing `PresenceUpdate` path. Gated on `semantic_render`. + /// + /// T M11.4 — same `full` + segment diff shape as `StyleSpans` + /// (see its docs), and gains `generation` for parity: a frontend + /// wants the CRDT version decorations were computed against for + /// the same optimistic-edit race reason styling does. + Decorations { + /// Buffer these decorations apply to. + buffer_id: crate::buffer::BufferId, + /// CRDT generation the decorations were computed against. + generation: u64, + /// `true` → discard all prior decorations for `buffer_id` + /// first; `segments` are authoritative for the viewport. + full: bool, + /// Dirty byte regions and the decorations now covering them. + segments: Vec, + }, + /// 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 +745,178 @@ pub struct SelectionSnapshot { pub active: crate::rope::Position, } +// --------------------------------------------------------------------------- +// T M11.1 — Semantic-frontend projection types +// +// The payloads of the `InstanceMessage::StyleSpans` … `ResourceOffer` +// family and `FrontendEvent::Viewport`. Everything is anchored in +// **byte offsets** (consistent with `CursorByte`): line/col is a +// frontend rendering concern, CRDT position is replica-internal. The +// instance never learns a pixel — see the contract boundary in +// `docs/semantic-frontend-protocol.md`. +// +// The variant/kind sets here are provisional and co-evolve within the +// M11 arc behind the `semantic_render` capability + protocol v3, +// exactly as the CRDT op shape evolved M10.5→M10.10 behind +// `crdt_replica`. They are not a wire-compat hazard for non-semantic +// sessions: the daemon's per-session outgoing filter (wired with the +// producer in M11.2) never emits the family to a session that didn't +// negotiate `semantic_render`, so postcard's hard-error on unknown +// variants is mooted exactly as it is for `CursorByte`. +// --------------------------------------------------------------------------- + +/// Half-open byte range `[start, end)` into a buffer's rope, matching +/// the rope's own range convention. +#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ByteRange { + /// Inclusive start byte offset. + pub start: u64, + /// Exclusive end byte offset. + pub end: u64, +} + +/// One run of buffer bytes carrying a resolved visual style. The +/// instance is the single syntax/face authority; the frontend lays +/// the style out locally over rope text it already holds. Reuses +/// [`crate::cell::Style`] so the grid and semantic projections share +/// one style vocabulary. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct StyleSpan { + /// Byte range this style covers. + pub range: ByteRange, + /// The resolved style (syntax highlight ∘ faces ∘ theme). + pub style: crate::cell::Style, +} + +/// T M11.4 — one dirty byte region of an `InstanceMessage::StyleSpans` +/// frame and the styling now covering it. The semantic analog of a +/// `CellDelta` changed-run: the frontend clears styling within +/// `range` and applies `spans` (already clipped to `range`). `spans` +/// is every current span intersecting `range`, not only changed ones, +/// so an unchanged span overlapping the dirty region is preserved. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct StyleSegment { + /// The byte region the frontend should clear and repaint. + pub range: ByteRange, + /// Spans intersecting `range`, each clipped to it. + pub spans: Vec, +} + +/// What a [`Decoration`] region *means*. Provisional variant set (see +/// the module-section note above). Peer selection is deliberately +/// absent — it stays on the `PresenceUpdate` path. +#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum DecorationKind { + /// LSP diagnostic, error severity. + DiagnosticError, + /// LSP diagnostic, warning severity. + DiagnosticWarning, + /// LSP diagnostic, information severity. + DiagnosticInfo, + /// LSP diagnostic, hint severity. + DiagnosticHint, + /// The local selection region. + Selection, + /// A non-active search match. + SearchMatch, + /// The currently-focused search match. + SearchMatchActive, + /// The line containing the cursor. + CurrentLine, +} + +/// A byte range tagged with what it means. The frontend decides how +/// to paint each [`DecorationKind`] (squiggle, highlight, gutter +/// mark) — the instance only states the fact. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Decoration { + /// Byte range the decoration covers. + pub range: ByteRange, + /// What the region signifies. + pub kind: DecorationKind, +} + +/// T M11.4 — `StyleSegment`'s analog for the `Decorations` family: +/// one dirty byte region and the decorations now covering it (every +/// current decoration intersecting `range`, clipped to it). +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct DecorationSegment { + /// The byte region the frontend should clear and repaint. + pub range: ByteRange, + /// Decorations intersecting `range`, each clipped to it. + pub decorations: Vec, +} + +/// 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 +1430,34 @@ impl AttachmentHandle { /// accepted by v1.0 binaries; CRDT-only message variants /// (`InstanceMessage::CrdtOp`, `FrontendEvent::CrdtOp`) are filtered /// per-session for v1 negotiated sessions. -pub const PROTOCOL_VERSION: u32 = 2; +/// +/// T M11.1: bumped from 2 to 3. The v1.0 wire (version 2) remains +/// accepted; the semantic-frontend variant family +/// (`InstanceMessage::StyleSpans` … `ResourceOffer`, +/// `FrontendEvent::Viewport`) is filtered per-session for sessions +/// that did not negotiate `semantic_render`. Mechanically identical +/// to the M10.5 bump: the slice-membership handshake check (not +/// strict equality) means v0.1/v1.0 binaries keep connecting +/// unchanged, and the new variants simply existing in the enums is +/// not a wire-compat issue for non-semantic sessions because the +/// daemon never emits them to those sessions. +pub const PROTOCOL_VERSION: u32 = 3; /// T M10.5: the set of protocol versions a v1.0 binary accepts on /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept /// `[1, 2]` so the version asymmetry the §sec:m10-backward-compat /// spec section describes is handled symmetrically on both sides. /// -/// The handshake check is "is the peer's `protocol_version` present in +/// T M11.1: extended to `[1, 2, 3]`. v1.1 binaries accept the v0.1 +/// (1), v1.0 (2), and semantic-frontend (3) wires. The check remains +/// slice membership — "is the peer's `protocol_version` present in /// this slice?" — not strict equality on `PROTOCOL_VERSION`. The /// session's negotiated version (the peer's) is recorded for /// downstream filtering: v1 sessions don't receive /// `InstanceMessage::CrdtOp` / `PresenceUpdate` messages even from -/// a v2 daemon. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[1, 2]; +/// a v3 daemon, and only sessions that negotiated `semantic_render` +/// receive the `SemanticFrame` variant family. +pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[1, 2, 3]; /// T M10.5: predicate for the handshake check. Returns `true` if /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. @@ -1234,6 +1561,21 @@ pub struct InstanceCapabilities { /// broadcast routing for CRDT ops wires up in this milestone. #[serde(default = "default_true")] pub crdt_replica: bool, + /// T M11.1: instance can produce the semantic-frontend variant + /// family (`InstanceMessage::StyleSpans` … `ResourceOffer`) and + /// consume `FrontendEvent::Viewport`. + /// + /// T M11.1 declared the bit position + negotiation mechanics with + /// the default `false` (no producer yet). T M11.2 landed the + /// instance-side projection seam (`SemanticRenderState`) and + /// flipped the default to `cfg!(feature = "crdt")`: the instance + /// now advertises `semantic_render` on CRDT builds. It tracks the + /// `crdt` feature rather than being unconditional because the + /// negotiation dependency rule makes a semantic session + /// necessarily a text replica — a non-CRDT build can host + /// neither. See [`Default`] impl below. + #[serde(default)] + pub semantic_render: bool, } // Clippy in non-CRDT builds notes that `cfg!(feature = "crdt")` @@ -1255,9 +1597,26 @@ impl Default for InstanceCapabilities { // is also a CRDT replica; gating both on the same feature // keeps the daemon's advertised capabilities consistent // with what it can actually do. + // + // T M11.1 declared `semantic_render` defaulting to `false` + // unconditionally — no projection-seam producer existed, so + // advertising it would have been wire-protocol false + // advertising (the M10.5→M10.7 "bits false until the path is + // wired" discipline). + // + // T M11.2 — **the flip**: the instance-side projection seam + // (`SemanticRenderState`, the producer) has landed and the + // dispatcher selects it per session, so the instance now + // advertises `semantic_render`. It tracks `cfg!(feature = + // "crdt")` like `crdt_replica` because the negotiation + // dependency rule makes a semantic session necessarily a + // text replica; a non-CRDT build can host neither. This is + // the "M11.2 enables semantic" moment, exactly analogous to + // the M10.8 Day-4 multi_frontend/crdt_replica flip. Self { multi_frontend: cfg!(feature = "crdt"), crdt_replica: cfg!(feature = "crdt"), + semantic_render: cfg!(feature = "crdt"), } } } @@ -1322,6 +1681,20 @@ pub struct FrontendCapabilities { /// negotiation; M10.5 declares the bit position. #[serde(default)] pub crdt_replica: bool, + /// T M11.1: frontend is a semantic (layout-local) renderer — it + /// consumes the `InstanceMessage::StyleSpans` … `ResourceOffer` + /// family and emits `FrontendEvent::Viewport`. false for v0.1 and + /// v1.0 grid/TUI frontends; a future GPU/GUI frontend opts in. + /// + /// A semantic frontend is *required* to also be a text replica: + /// the semantic frame ships no text, so the frontend must hold + /// the rope locally via the `crdt_replica` machinery. This + /// dependency is enforced in [`negotiate_capabilities`], not just + /// documented — declaring `semantic_render: true` without + /// `crdt_replica: true` is a capability mismatch, never a silent + /// degrade. + #[serde(default)] + pub semantic_render: bool, } /// T M10.7 — the negotiated capability bits for one attached session. @@ -1349,11 +1722,21 @@ pub struct NegotiatedCapabilities { /// `crdt_replica = true`. The daemon's outgoing-message filter for /// `CrdtOp` consults this in M10.8. pub crdt_replica: bool, + /// T M11.1 — session uses the semantic projection: it + /// produces/consumes the `InstanceMessage::StyleSpans` … + /// `ResourceOffer` family and `FrontendEvent::Viewport`. True iff + /// both sides declared `semantic_render = true` *and* the session + /// also negotiated `crdt_replica = true` (a semantic session is a + /// text replica; see [`negotiate_capabilities`]). The daemon's + /// per-session outgoing filter gates the entire semantic family + /// on this bit — wired with the producer in M11.2. + pub semantic_render: bool, } /// T M10.7 — pure-function capability negotiation. /// -/// For each negotiated bit (`multi_frontend`, `crdt_replica`): +/// For each negotiated bit (`multi_frontend`, `crdt_replica`, +/// `semantic_render`): /// /// | Frontend wants | Instance has | Result | /// |----------------|--------------|--------| @@ -1366,7 +1749,23 @@ pub struct NegotiatedCapabilities { /// (returns `Err`). Otherwise the negotiated bits are returned as /// [`NegotiatedCapabilities`]. The `Err` form gathers ALL missing /// bits into one `CapabilityMismatch` — one round-trip carries the -/// complete picture rather than serial rejections. +/// complete picture rather than serial rejections. Missing bits are +/// ordered `multi_frontend`, `crdt_replica`, `semantic_render` for +/// deterministic wire output. +/// +/// # T M11.1 — the `semantic_render ⇒ crdt_replica` dependency +/// +/// A semantic-render session ships no text on the semantic frame; +/// the frontend holds the rope locally via the `crdt_replica` +/// machinery (`BufferSnapshot` to bootstrap, `CrdtOp` to stay live). +/// So `semantic_render` is only coherent on a session that also +/// negotiated `crdt_replica`. When the AND-rule would yield +/// `semantic_render = true` but the session did not also negotiate +/// `crdt_replica = true`, this function rejects with +/// `"semantic_render"` in `missing` rather than silently degrading +/// the session to a text-only replica. The rejected identifier is +/// `"semantic_render"` (the capability whose precondition is unmet), +/// not `"crdt_replica"`. /// /// # Wire-format stability /// @@ -1396,10 +1795,31 @@ pub fn negotiate_capabilities( } (a, b) => a && b, }; + let semantic_render = match (frontend.semantic_render, instance.semantic_render) { + (true, false) => { + missing.push("semantic_render".to_string()); + false + } + (a, b) => a && b, + }; + // T M11.1 — dependency rule. A semantic session is a text replica + // (the semantic frame carries no text). If both sides declared + // `semantic_render` but the session did not also negotiate + // `crdt_replica`, reject rather than silently degrade. Guard + // against a duplicate push: the only path where `semantic_render` + // is already in `missing` is the `(true, false)` arm above, which + // also sets the local `semantic_render` to false, so the + // condition below cannot re-fire for that case — but the explicit + // membership check keeps this robust against future reordering. + if semantic_render && !crdt_replica && !missing.iter().any(|m| m == "semantic_render") { + missing.push("semantic_render".to_string()); + } + let semantic_render = semantic_render && crdt_replica; if missing.is_empty() { Ok(NegotiatedCapabilities { multi_frontend, crdt_replica, + semantic_render, }) } else { Err(GoodbyeReason::CapabilityMismatch { missing }) @@ -2577,23 +2997,27 @@ mod tests { // --- M5.5a handshake & postcard round-trips --- #[test] - fn protocol_version_is_two_for_v10() { - // Pin the value: T M10.5 bumped from 1 to 2. The v1.0 wire - // adds CrdtOp / PresenceUpdate variants; the v1.0 binary - // serves both v1 and v2 sessions per §sec:m10-backward-compat. - assert_eq!(PROTOCOL_VERSION, 2); + fn protocol_version_is_three_for_v11() { + // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / + // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the + // SemanticFrame family + FrontendEvent::Viewport). The v1.1 + // binary serves v1, v2, and v3 sessions — the slice-membership + // handshake makes the relaxation symmetric, exactly as M10.5 + // did for §sec:m10-backward-compat. + assert_eq!(PROTOCOL_VERSION, 3); } #[test] - fn supported_protocol_versions_includes_one_and_two() { - // T M10.5: v1.0 binaries accept both wire versions during the - // handshake. v0.1 binaries (with their strict-equality check) - // accepted only v1; this is the symmetric relaxation that - // makes §sec:m10-backward-compat hold once both binaries ship. + fn supported_protocol_versions_includes_one_two_three() { + // T M10.5: v1.0 binaries accept v1+v2. T M11.1: v1.1 binaries + // accept v1+v2+v3. The check is slice membership, not strict + // equality, so v0.1/v1.0 binaries keep connecting to v1.1 + // binaries unchanged. v4+ is rejected until the next bump. assert!(is_supported_protocol_version(1)); assert!(is_supported_protocol_version(2)); + assert!(is_supported_protocol_version(3)); assert!(!is_supported_protocol_version(0)); - assert!(!is_supported_protocol_version(3)); + assert!(!is_supported_protocol_version(4)); assert!(!is_supported_protocol_version(u32::MAX)); } @@ -2629,6 +3053,7 @@ mod tests { terminal_kind: Some("xterm-256color".into()), multi_frontend: false, crdt_replica: false, + semantic_render: false, }, initial_size: CellSize::new(50, 200), }; @@ -2945,10 +3370,11 @@ mod tests { #[test] fn m10_5_handshake_matrix_versions_outside_range_rejected() { // v1 daemon's strict-equality behavior is documented at the - // v0.1 code level (different binary); v2 daemon's range check - // rejects v3+ until v0.2 ships. + // v0.1 code level (different binary); the v1.1 daemon's range + // check accepts v1/v2/v3 (T M11.1 added v3) and rejects v4+ + // until the next protocol bump. assert!(!is_supported_protocol_version(0)); - assert!(!is_supported_protocol_version(3)); + assert!(!is_supported_protocol_version(4)); assert!(!is_supported_protocol_version(u32::MAX)); } @@ -3157,6 +3583,36 @@ mod tests { InstanceCapabilities { multi_frontend, crdt_replica, + ..InstanceCapabilities::default() + } + } + + /// T M11.1 — caps builder that also sets `semantic_render`, for + /// the semantic-negotiation matrix. The 2-arg `inst_caps` keeps + /// `semantic_render` at its `Default` (`false`) so the existing + /// M10.7 matrix tests are untouched. + fn inst_caps_s( + multi_frontend: bool, + crdt_replica: bool, + semantic_render: bool, + ) -> InstanceCapabilities { + InstanceCapabilities { + multi_frontend, + crdt_replica, + semantic_render, + } + } + + fn front_caps_s( + multi_frontend: bool, + crdt_replica: bool, + semantic_render: bool, + ) -> FrontendCapabilities { + FrontendCapabilities { + multi_frontend, + crdt_replica, + semantic_render, + ..FrontendCapabilities::default() } } @@ -3287,4 +3743,249 @@ mod tests { other => panic!("expected CapabilityMismatch, got {other:?}"), } } + + // T M11.1 — semantic_render negotiation matrix + the + // semantic_render ⇒ crdt_replica dependency rule. + + #[test] + fn negotiate_semantic_render_both_sides_with_crdt() { + // The only success shape: both sides want semantic_render AND + // the session also negotiates crdt_replica (the text-replica + // dependency). semantic_render true implies crdt_replica true. + let res = negotiate_capabilities( + &front_caps_s(true, true, true), + &inst_caps_s(true, true, true), + ) + .expect("ok"); + assert!(res.crdt_replica); + assert!(res.semantic_render); + } + + #[test] + fn negotiate_semantic_render_frontend_silent() { + // Instance offers semantic_render; frontend doesn't ask. The + // subset (no semantic projection) is accepted, no error — + // identical posture to the multi_frontend/crdt_replica + // "frontend silent" case. + let res = negotiate_capabilities( + &front_caps_s(false, false, false), + &inst_caps_s(true, true, true), + ) + .expect("ok"); + assert!(!res.semantic_render); + assert!(!res.crdt_replica); + } + + #[test] + fn negotiate_semantic_render_frontend_wants_instance_lacks() { + // Frontend wants crdt+semantic; instance has crdt but not the + // semantic projection (the M11.1 reality until M11.2 flips + // the instance default). Only semantic_render is missing. + let err = negotiate_capabilities( + &front_caps_s(false, true, true), + &inst_caps_s(false, true, false), + ) + .expect_err("should mismatch"); + match err { + GoodbyeReason::CapabilityMismatch { missing } => { + assert_eq!(missing, vec!["semantic_render".to_string()]); + } + other => panic!("expected CapabilityMismatch, got {other:?}"), + } + } + + #[test] + fn negotiate_semantic_render_requires_crdt_replica_dependency() { + // Both sides declare semantic_render, but the frontend did + // NOT request crdt_replica. The AND-rule alone would yield + // semantic_render=true; the dependency rule rejects instead + // of silently degrading to a text-only replica. The rejected + // identifier is "semantic_render" (the capability whose + // precondition is unmet), not "crdt_replica". + let err = negotiate_capabilities( + &front_caps_s(false, false, true), + &inst_caps_s(false, true, true), + ) + .expect_err("should mismatch"); + match err { + GoodbyeReason::CapabilityMismatch { missing } => { + assert_eq!(missing, vec!["semantic_render".to_string()]); + } + other => panic!("expected CapabilityMismatch, got {other:?}"), + } + } + + #[test] + fn negotiate_semantic_render_dependency_orders_after_crdt_replica() { + // Frontend wants crdt+semantic; instance has the semantic + // projection but lacks crdt. crdt_replica fails the AND-rule + // (true,false) → "crdt_replica"; the dependency rule then + // appends "semantic_render". Deterministic order: + // [crdt_replica, semantic_render]. No duplicate semantic_render. + let err = negotiate_capabilities( + &front_caps_s(false, true, true), + &inst_caps_s(false, false, true), + ) + .expect_err("should mismatch"); + match err { + GoodbyeReason::CapabilityMismatch { missing } => { + assert_eq!( + missing, + vec!["crdt_replica".to_string(), "semantic_render".to_string()] + ); + } + other => panic!("expected CapabilityMismatch, got {other:?}"), + } + } + + #[test] + fn negotiate_ok_semantic_render_always_implies_crdt_replica() { + // Invariant: every successful negotiation with + // semantic_render=true also has crdt_replica=true. Exhaust + // the 2³ declared-bit combinations on each side that the + // helpers can express; any Ok with semantic_render must carry + // crdt_replica. + for fc in [false, true] { + for fr in [false, true] { + for fs in [false, true] { + for ic in [false, true] { + for ir in [false, true] { + for is in [false, true] { + if let Ok(neg) = negotiate_capabilities( + &front_caps_s(fc, fr, fs), + &inst_caps_s(ic, ir, is), + ) && neg.semantic_render + { + assert!( + neg.crdt_replica, + "semantic_render without crdt_replica leaked through \ + negotiation: front=({fc},{fr},{fs}) inst=({ic},{ir},{is})" + ); + } + } + } + } + } + } + } + } + + #[test] + fn negotiate_two_arg_helpers_do_not_negotiate_semantic_render() { + // Regression: the M10.7 matrix uses the 2-arg helpers. After + // the T M11.2 flip the *instance* default is `cfg!(crdt)` + // (true under `--features crdt`), but the *frontend* 2-arg + // helper still defaults `semantic_render` to false — so the + // AND-rule yields `false` and existing M10.7 outcomes are + // unperturbed. (A frontend that wants the semantic projection + // opts in explicitly via the 3-arg helper.) + let res = + negotiate_capabilities(&front_caps(true, true), &inst_caps(true, true)).expect("ok"); + assert!(res.multi_frontend); + assert!(res.crdt_replica); + assert!( + !res.semantic_render, + "frontend that didn't request semantic_render must not negotiate it, \ + regardless of the instance default" + ); + } + + // T M11.1 — postcard round-trips for the SemanticFrame family and + // FrontendEvent::Viewport. Mirrors the M10.x variant round-trip + // tests: encode → decode → structural equality. + + #[test] + fn semantic_frame_family_round_trips_through_postcard() { + let bid = crate::buffer::BufferId::next(); + let msgs = vec![ + InstanceMessage::StyleSpans { + buffer_id: bid, + generation: 7, + full: true, + segments: vec![StyleSegment { + range: ByteRange { start: 0, end: 12 }, + spans: vec![StyleSpan { + range: ByteRange { start: 0, end: 12 }, + style: crate::cell::Style::default(), + }], + }], + }, + InstanceMessage::Decorations { + buffer_id: bid, + generation: 7, + full: false, + segments: vec![DecorationSegment { + range: ByteRange { start: 3, end: 20 }, + decorations: vec![ + Decoration { + range: ByteRange { start: 3, end: 9 }, + kind: DecorationKind::DiagnosticError, + }, + Decoration { + range: ByteRange { start: 20, end: 20 }, + kind: DecorationKind::CurrentLine, + }, + ], + }], + }, + InstanceMessage::InlineAdornments { + buffer_id: bid, + items: vec![InlineAdornment { + at: 42, + placement: AdornmentPlacement::EndOfLine, + content: AdornmentContent::Text { + text: "→ i32".to_string(), + style: crate::cell::Style::default(), + }, + }], + }, + InstanceMessage::BlockAdornments { + buffer_id: bid, + items: vec![BlockAdornment { + at: 64, + replaces: Some(ByteRange { start: 64, end: 256 }), + content: AdornmentContent::Resource { handle: 1 }, + }], + }, + InstanceMessage::FoldState { + buffer_id: bid, + folds: vec![ByteRange { start: 100, end: 400 }], + }, + InstanceMessage::ResourceOffer { + handle: 1, + mime: "image/png".to_string(), + body: ResourceBody::Inline(vec![0x89, b'P', b'N', b'G']), + }, + InstanceMessage::ResourceOffer { + handle: 2, + mime: "image/svg+xml".to_string(), + body: ResourceBody::Uri("file:///tmp/blame.svg".to_string()), + }, + ]; + for msg in msgs { + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + assert_eq!(msg, decoded); + } + } + + #[test] + fn frontend_event_viewport_round_trips_through_postcard() { + let ev = FrontendEvent::Viewport { + frontend_id: FrontendId(4), + buffer_id: crate::buffer::BufferId::next(), + visible: ByteRange { + start: 1_024, + end: 4_096, + }, + generation: 99, + }; + let bytes = postcard::to_allocvec(&ev).expect("encode"); + let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode"); + assert_eq!(ev, decoded); + // The contract-boundary invariant, asserted structurally: + // frontend_id() must resolve for the new variant (it is part + // of the per-frontend routing alternation). + assert_eq!(decoded.frontend_id(), FrontendId(4)); + } } 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/src/semantic_render.rs b/src/semantic_render.rs new file mode 100644 index 0000000..7b72d76 --- /dev/null +++ b/src/semantic_render.rs @@ -0,0 +1,886 @@ +// semantic_render.rs --- Instance-side semantic projection (T M11.2). + +//! The semantic projection seam. +//! +//! [`crate::instance_render::RenderState`] rasterizes the editor to a +//! cell grid and ships [`InstanceMessage::CellDelta`]. `SemanticRenderState` +//! is its sibling for `semantic_render` sessions: it reads the same +//! [`EditorState`] but exits the pipeline *earlier* — it emits the +//! structured byte-range styling the cell painter would otherwise have +//! consumed (tree-sitter spans from [`crate::syntax`] mapped through +//! the active [`crate::highlight::Theme`]), without the grid-packing +//! step. The frontend lays the styling out locally over rope text it +//! already holds via its `crdt_replica` `BufferMirror`. +//! +//! Contract boundary (see `docs/semantic-frontend-protocol.md`): the +//! instance never learns a pixel. The only spatial fact it consumes is +//! the buffer byte range the frontend declared on screen via +//! [`crate::protocol::FrontendEvent::Viewport`]; styling is scoped to +//! that range so a 100k-line file's styling is never shipped wholesale. +//! +//! M11.2 scope: `StyleSpans` only. `Decorations` / `InlineAdornments` / +//! `BlockAdornments` / `FoldState` / `ResourceOffer` are M11.3; true +//! span-granularity diffing (this module currently suppresses only +//! byte-identical frames) is M11.4. + +use std::collections::HashMap; + +use crate::buffer::BufferId; +use crate::cell::Style; +use crate::editor::EditorState; +use crate::protocol::{ + ByteRange, Decoration, DecorationKind, DecorationSegment, FrontendId, InstanceMessage, + StyleSegment, StyleSpan, +}; + +/// The viewport a `semantic_render` frontend last declared. +#[derive(Clone, Debug, Eq, PartialEq)] +struct DeclaredViewport { + buffer_id: BufferId, + visible: ByteRange, + /// The CRDT generation the frontend computed `visible` against. + /// Recorded for the M11.4 "ignore a viewport that races a + /// not-yet-applied edit" refinement; M11.2 always honors the most + /// recent declaration verbatim. + frontend_generation: u64, +} + +/// The diff baseline for one family on one buffer: the +/// declared-viewport region the set was computed for, and the full +/// scoped item set last shipped. The next frame diffs against +/// `items`; `visible` changing (or no entry) forces a `full` resync. +/// The frame's `generation` is recomputed each tick and carried on +/// the wire, so it is not retained here. +struct LastFrame { + visible: ByteRange, + items: Vec, +} + +/// Owns one `semantic_render` session's projection state: the last +/// viewport the frontend declared, and the diff baseline per buffer +/// for the `StyleSpans` and `Decorations` families. +pub struct SemanticRenderState { + /// The session this projection serves. Selection is per-window + /// (per-frontend) state, so the decoration projection needs the + /// fid to resolve *this* session's active window via + /// `active_window_for`. Styling and diagnostics are per-buffer and + /// do not consult it. + frontend_id: FrontendId, + /// `None` until the frontend's first [`Self::set_viewport`]. While + /// `None`, [`Self::render_frame`] emits nothing: the frontend + /// bootstraps its rope from `BufferSnapshot`, declares what is on + /// screen, and only then receives styling for exactly that range. + viewport: Option, + /// 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 { + /// Fresh session state for frontend `frontend_id`: no viewport + /// declared, nothing sent. + #[must_use] + pub fn new(frontend_id: FrontendId) -> Self { + Self { + frontend_id, + viewport: None, + last_sent: HashMap::new(), + last_decorations: HashMap::new(), + } + } + + /// Record the frontend's declared on-screen byte range. Called by + /// the dispatcher when it receives + /// [`crate::protocol::FrontendEvent::Viewport`]. Replaces any + /// prior declaration wholesale — the latest viewport wins. + pub fn set_viewport(&mut self, buffer_id: BufferId, visible: ByteRange, generation: u64) { + self.viewport = Some(DeclaredViewport { + buffer_id, + visible, + frontend_generation: generation, + }); + } + + /// Project one frame. + /// + /// Returns up to two messages — an [`InstanceMessage::StyleSpans`] + /// (T M11.2) and an [`InstanceMessage::Decorations`] (T M11.3) — + /// each scoped to the declared viewport and each suppressed + /// independently when byte-identical to its last send at the same + /// generation. Returns an empty vec before the frontend declares a + /// viewport. + /// + /// `InlineAdornments` / `BlockAdornments` / `FoldState` are + /// deliberately *not* produced: pmacs has no instance-side inlay- + /// hint / blame / lens / fold / diff source yet. The wire variants + /// exist (T M11.1); their producers are wired when those features + /// land — the same "declared, not yet wired" discipline M11.1 + /// applied to the whole family. Emitting empty messages every + /// frame would be waste, not honesty. + pub fn render_frame(&mut self, state: &EditorState) -> Vec { + let Some(vp) = self.viewport.clone() else { + // Emit nothing before the frontend declares a viewport. + return Vec::new(); + }; + + let generation = buffer_generation(state, vp.buffer_id); + let mut out = Vec::new(); + + // --- StyleSpans (T M11.2 producer, T M11.4 diff) --- + let spans = scoped_style_spans(state, &vp); + let prev = self.last_sent.get(&vp.buffer_id); + // Resync when there is no baseline, or the declared viewport + // region moved (the scoping window changed, so prior styling + // is no longer positioned correctly). + let full = prev.is_none_or(|p| p.visible != vp.visible); + if full { + // The first frame for this buffer/viewport. One segment + // covering the declared viewport carries the whole scoped + // set (possibly empty → frontend clears the viewport). + self.last_sent.insert( + vp.buffer_id, + LastFrame { + visible: vp.visible, + items: spans.clone(), + }, + ); + out.push(InstanceMessage::StyleSpans { + buffer_id: vp.buffer_id, + generation, + full: true, + segments: vec![StyleSegment { + range: vp.visible, + spans, + }], + }); + } else { + let prev = prev.expect("checked is_none_or above"); + let intervals = changed_intervals(&prev.items, &spans, |s| s.range); + if !intervals.is_empty() { + let segments = intervals + .into_iter() + .map(|range| StyleSegment { + range, + spans: clip_style_spans(range, &spans), + }) + .collect(); + self.last_sent.insert( + vp.buffer_id, + LastFrame { + visible: vp.visible, + items: spans, + }, + ); + out.push(InstanceMessage::StyleSpans { + buffer_id: vp.buffer_id, + generation, + full: false, + segments, + }); + } + // No dirty interval → styling unchanged → emit nothing. + } + + // --- Decorations (T M11.3 producer, T M11.4 diff) --- + let decorations = self.scoped_decorations(state, &vp); + let prev = self.last_decorations.get(&vp.buffer_id); + let full = prev.is_none_or(|p| p.visible != vp.visible); + if full { + self.last_decorations.insert( + vp.buffer_id, + LastFrame { + visible: vp.visible, + items: decorations.clone(), + }, + ); + out.push(InstanceMessage::Decorations { + buffer_id: vp.buffer_id, + generation, + full: true, + segments: vec![DecorationSegment { + range: vp.visible, + decorations, + }], + }); + } else { + let prev = prev.expect("checked is_none_or above"); + let intervals = changed_intervals(&prev.items, &decorations, |d| d.range); + if !intervals.is_empty() { + let segments = intervals + .into_iter() + .map(|range| DecorationSegment { + range, + decorations: clip_decorations(range, &decorations), + }) + .collect(); + self.last_decorations.insert( + vp.buffer_id, + LastFrame { + visible: vp.visible, + items: decorations, + }, + ); + out.push(InstanceMessage::Decorations { + buffer_id: vp.buffer_id, + generation, + full: false, + segments, + }); + } + } + + out + } + + /// Project the [`Decoration`] set intersecting the declared + /// viewport: the session's selection (instance-authoritative, + /// byte-native) and LSP diagnostics (line/col → byte, severity → + /// kind). Search-hit and current-line decorations are + /// deliberately absent: pmacs has no instance-side search-hit + /// store, and current-line is a pure cursor derivation the + /// frontend already owns (it has `CursorByte`) — emitting it would + /// couple a visual-motion concern to the instance, against the + /// contract boundary. + fn scoped_decorations( + &self, + state: &EditorState, + vp: &DeclaredViewport, + ) -> Vec { + 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 }) +} + +/// T M11.4 — the dirty byte intervals between two ordered item sets. +/// +/// Items are byte-anchored (`range_of` extracts the range). The +/// symmetric difference (items in exactly one set, by `==`) bounds +/// every byte whose covering set changed; its ranges are coalesced +/// into maximal disjoint intervals — the segments the frontend will +/// clear and repaint. Empty result ⇒ unchanged ⇒ the caller emits +/// nothing. +/// +/// O(n·m) membership scans: a screenful is a few hundred items, far +/// cheaper than re-shipping the whole viewport every frame, and only +/// runs when the fast `prev == curr` slice check (caller side, via +/// the order-stable producers) would have failed anyway. +fn changed_intervals( + 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; + 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, +/// 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() + } + + fn local() -> SemanticRenderState { + // FrontendId::LOCAL always has a registered FrontendView + // (EditorCore invariant), so `active_window_for(LOCAL)` — the + // selection projection's lookup — resolves in a fresh editor. + SemanticRenderState::new(FrontendId::LOCAL) + } + + fn active_buffer(state: &EditorState) -> BufferId { + state.core.borrow().active_window().buffer_id + } + + /// All `InstanceMessage` variants the semantic projection may + /// emit are `StyleSpans` or `Decorations` — never `CellDelta`, + /// grid `Cursor`, or the not-yet-wired adornment/fold families. + fn assert_semantic_only(msgs: &[InstanceMessage]) { + for m in msgs { + assert!( + matches!( + m, + InstanceMessage::StyleSpans { .. } | InstanceMessage::Decorations { .. } + ), + "semantic projection emitted an unexpected variant: {m:?}" + ); + } + } + + /// Find the `Decorations` message and flatten its segments into + /// `(full, all decorations across segments)`. + fn decorations_of(msgs: &[InstanceMessage]) -> Option<(bool, Vec)> { + 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(); + assert!( + s.render_frame(&empty_state()).is_empty(), + "nothing may be emitted before the frontend declares a viewport" + ); + } + + #[test] + fn first_post_viewport_frame_is_full_for_both_then_suppresses() { + let state = empty_state(); + let mut s = local(); + let buffer_id = active_buffer(&state); + s.set_viewport(buffer_id, ByteRange { start: 0, end: 4096 }, 0); + + // Empty scratch: no spans, no selection, no diagnostics — but + // the first frame is a `full` resync for both families (the + // frontend clears its viewport), carrying empty segments. + let first = s.render_frame(&state); + assert_eq!(first.len(), 2, "first frame ships StyleSpans + Decorations"); + assert_semantic_only(&first); + let (style_full, _) = style_segments(&first).expect("StyleSpans present"); + let (deco_full, decos) = decorations_of(&first).expect("Decorations present"); + assert!(style_full, "first styling frame must be full"); + assert!(deco_full, "first decorations frame must be full"); + assert!(decos.is_empty(), "empty scratch has no decorations"); + + // Nothing changed → both families suppressed. + assert!( + s.render_frame(&state).is_empty(), + "an unchanged frame must be fully suppressed" + ); + } + + #[test] + fn selection_projects_as_a_decoration_clipped_to_viewport() { + let state = empty_state(); + let buffer_id = active_buffer(&state); + // region (2,5) on LOCAL's window; region() compares offsets + // only, so the empty scratch buffer is fine here. + set_selection(&state, 2, 5); + let mut s = local(); + s.set_viewport(buffer_id, ByteRange { start: 3, end: 64 }, 0); + + let msgs = s.render_frame(&state); + assert_semantic_only(&msgs); + let (full, decos) = decorations_of(&msgs).expect("a Decorations message"); + assert!(full, "first frame is a full resync"); + assert_eq!(decos.len(), 1, "exactly the selection decoration"); + assert_eq!(decos[0].kind, DecorationKind::Selection); + // region (2,5) clipped to viewport [3,64) → [3,5). + assert_eq!(decos[0].range, ByteRange { start: 3, end: 5 }); + } + + #[test] + fn diagnostics_project_with_line_col_to_byte_and_severity() { + // "abc\nde": line 0 at byte 0, line 1 at byte 4. + let state = empty_state(); + let buffer_id = active_buffer(&state); + seed_diagnostic(&state, buffer_id); + let mut s = local(); + s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); + + let (_full, decos) = + decorations_of(&s.render_frame(&state)).expect("a Decorations message"); + assert_eq!(decos.len(), 1); + assert_eq!(decos[0].kind, DecorationKind::DiagnosticWarning); + // line 1 starts at byte 4; cols [0,2) → bytes [4,6). + assert_eq!(decos[0].range, ByteRange { start: 4, end: 6 }); + } + + #[test] + fn styles_and_decorations_suppress_independently() { + let state = empty_state(); + let buffer_id = active_buffer(&state); + let mut s = local(); + s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); + let _ = s.render_frame(&state); // first frame: both full + assert!(s.render_frame(&state).is_empty(), "steady state silent"); + + // A selection appears → only Decorations re-emits, and as an + // incremental (full = false) frame since the viewport region + // did not move. + set_selection(&state, 1, 4); + let msgs = s.render_frame(&state); + assert_eq!(msgs.len(), 1, "only the changed family re-emits"); + let (full, decos) = decorations_of(&msgs).expect("Decorations re-emitted"); + assert!(!full, "viewport unchanged → incremental, not full"); + assert_eq!(decos.len(), 1); + assert_eq!(decos[0].kind, DecorationKind::Selection); + } + + #[test] + fn full_resync_on_viewport_region_change() { + let state = empty_state(); + let buffer_id = active_buffer(&state); + let mut s = local(); + s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); + let _ = s.render_frame(&state); // full + assert!(s.render_frame(&state).is_empty(), "unchanged → silent"); + + // Declaring a different on-screen range forces a full resync: + // prior styling/decorations are positioned for the old window. + s.set_viewport(buffer_id, ByteRange { start: 200, end: 264 }, 0); + let msgs = s.render_frame(&state); + let (style_full, _) = style_segments(&msgs).expect("StyleSpans"); + let (deco_full, _) = decorations_of(&msgs).expect("Decorations"); + assert!(style_full && deco_full, "viewport jump must be a full resync"); + } + + #[test] + fn incremental_decoration_change_ships_only_dirty_intervals() { + let state = empty_state(); + let buffer_id = active_buffer(&state); + let mut s = local(); + s.set_viewport(buffer_id, ByteRange { start: 0, end: 256 }, 0); + set_selection(&state, 10, 12); + let _ = s.render_frame(&state); // full: selection [10,12) + assert!(s.render_frame(&state).is_empty()); + + // Move the selection far away. The symmetric difference is the + // old range [10,12) (removed) and the new [40,42) (added); + // they are disjoint and non-adjacent → two segments. + set_selection(&state, 40, 42); + let msgs = s.render_frame(&state); + let deco_msg = msgs + .iter() + .find_map(|m| match m { + InstanceMessage::Decorations { full, segments, .. } => Some((*full, segments)), + _ => None, + }) + .expect("Decorations"); + assert!(!deco_msg.0, "incremental"); + let ranges: Vec = deco_msg.1.iter().map(|s| s.range).collect(); + assert_eq!( + ranges, + vec![ + ByteRange { start: 10, end: 12 }, + ByteRange { start: 40, end: 42 } + ], + "two disjoint dirty intervals: old (cleared) + new" + ); + // The [10,12) segment carries no decorations (selection moved + // away → frontend clears it); [40,42) carries the new one. + let s1 = &deco_msg.1[0]; + assert!(s1.decorations.is_empty(), "old selection range cleared"); + let s2 = &deco_msg.1[1]; + assert_eq!(s2.decorations.len(), 1); + assert_eq!(s2.decorations[0].kind, DecorationKind::Selection); + } + + #[test] + fn unchanged_decoration_overlapping_a_dirty_interval_is_reconstructed() { + // A diagnostic at [4,6) never changes; the selection moves to + // overlap it. The dirty segment must still carry the (clipped) + // diagnostic so the frontend, replacing styling within the + // range, faithfully reconstructs the unchanged decoration. + let state = empty_state(); + let buffer_id = active_buffer(&state); + seed_diagnostic(&state, buffer_id); // Warning [4,6) + let mut s = local(); + s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); + set_selection(&state, 20, 22); + let _ = s.render_frame(&state); // full: Sel[20,22) + Warn[4,6) + assert!(s.render_frame(&state).is_empty()); + + // Selection moves to [5,7), overlapping the diagnostic. + set_selection(&state, 5, 7); + let msgs = s.render_frame(&state); + let (_full, segs) = msgs + .iter() + .find_map(|m| match m { + InstanceMessage::Decorations { full, segments, .. } => Some((*full, segments)), + _ => None, + }) + .expect("Decorations"); + // The segment covering [5,7) must include the unchanged, + // overlapping diagnostic (clipped into the dirty range), + // not just the moved selection. + let overlapping = segs + .iter() + .find(|s| s.range.start <= 5 && s.range.end >= 6) + .expect("a segment covering the diagnostic's bytes"); + assert!( + overlapping + .decorations + .iter() + .any(|d| d.kind == DecorationKind::DiagnosticWarning), + "unchanged overlapping diagnostic must be reconstructed in the dirty segment" + ); + } + + #[test] + fn adornment_and_fold_families_are_never_emitted() { + // M11.3 honest-stub contract: InlineAdornments / BlockAdornments + // / FoldState have no instance-side source yet, so the + // projection never produces them (not even empty ones). + let state = empty_state(); + let buffer_id = active_buffer(&state); + let mut s = local(); + s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); + for _ in 0..3 { + for m in s.render_frame(&state) { + assert!( + !matches!( + m, + InstanceMessage::InlineAdornments { .. } + | InstanceMessage::BlockAdornments { .. } + | InstanceMessage::FoldState { .. } + ), + "a not-yet-wired adornment/fold family was emitted: {m:?}" + ); + } + } + } + + #[test] + fn sibling_of_render_state_reads_same_editor_state() { + // The dispatcher selects the projection per session, not per + // buffer: a grid RenderState and a SemanticRenderState observe + // the same EditorState without interfering. + let state = empty_state(); + let mut grid = RenderState::new(CellSize::new(24, 80)); + let mut sem = local(); + let buffer_id = active_buffer(&state); + sem.set_viewport(buffer_id, ByteRange { start: 0, end: 80 }, 0); + + let grid_msgs = grid.render_frame(&state, &[]); + let sem_msgs = sem.render_frame(&state); + assert!( + matches!(grid_msgs[0], InstanceMessage::CellDelta { .. }), + "grid projection still produces CellDelta" + ); + assert_semantic_only(&sem_msgs); + assert!( + !sem_msgs + .iter() + .any(|m| matches!(m, InstanceMessage::CellDelta { .. })), + "semantic projection never produces CellDelta" + ); + } +} 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/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" + ); +} 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, } }