M11.5: semantic frontend<->instance glue (SemanticClient + e2e)
Completes the M11 arc with the consumer side. pmacs has no GUI toolkit, so per the design note's testability strategy the deliverable is the bounded testable glue, not a GPU renderer. - src/semantic_client.rs (crdt-gated): headless SemanticClient composing the BufferMirror rope replica (M10.10) with a tile-based SemanticModel that reconstructs styling/decorations from the full + dirty-segment deltas (M11.4). Emits FrontendEvent::Viewport; read-back accessors (text / effective_style_at / decoration_kinds_at / tile ranges). The M11.4 contract (segments carry every current item intersecting their range) makes tiles self-contained → incremental apply is per-tile replacement with edge-clipping, no cross-span surgery. 7 unit tests. - tests/m11_5_semantic_acceptance.rs: (a) reconstruction-equivalence — incrementally-driven client asserted byte-for-byte identical to a fresh full projection across a scripted viewport/edit/selection sequence incl. a viewport jump (golden discipline, no snapshot crate); (b) end-to-end — a real daemon routes StyleSpans/ Decorations to a semantic session (after it declares a Viewport) and never to a grid session, CellDelta vice versa, validating the M11.2 per-session projection through the socket. Lib (1404 crdt / 1242 non-crdt) + integration green on both feature flavors; clippy -D warnings clean on both. M11 arc complete (M11.1–M11.5). Inline/Block/Fold/ResourceOffer remain honest stubs pending their source features. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
071e79ffee
commit
2ca011c368
32
CHANGELOG.md
32
CHANGELOG.md
|
|
@ -107,6 +107,38 @@ lifted from positional cells to byte-anchored ranges.
|
|||
- `ResourceOffer` remains an honest stub (no resource-bearing
|
||||
adornment producer exists yet) — same discipline as M11.3.
|
||||
|
||||
#### Semantic frontend↔instance glue (M11.5)
|
||||
|
||||
The arc's consumer side and end-to-end coverage. pmacs has no GUI
|
||||
toolkit, so — per the design note's testability strategy — the
|
||||
deliverable is the bounded testable glue, not a GPU renderer.
|
||||
|
||||
- New headless `SemanticClient` (`src/semantic_client.rs`, `crdt`-
|
||||
gated): composes the `BufferMirror` rope replica (M10.10) with a
|
||||
tile-based `SemanticModel` that reconstructs styling/decorations
|
||||
from the `full` + dirty-segment deltas (M11.4). Self-contained:
|
||||
no terminal, no pixels. Emits `FrontendEvent::Viewport`; exposes
|
||||
read-back accessors (`text`, `effective_style_at`,
|
||||
`decoration_kinds_at`, tile ranges). The M11.4 contract (segments
|
||||
carry every current item intersecting their range) makes a tile
|
||||
self-contained, so incremental application is a clean per-tile
|
||||
replacement with edge-clipping, not cross-span surgery.
|
||||
- `tests/m11_5_semantic_acceptance.rs`: (a) reconstruction-
|
||||
equivalence — an incrementally-driven client is asserted byte-for-
|
||||
byte identical to a fresh full projection across a scripted
|
||||
viewport/edit/selection sequence including a viewport jump (the
|
||||
golden discipline without a snapshot crate); (b) end-to-end —
|
||||
a real daemon routes `StyleSpans`/`Decorations` to a semantic
|
||||
session (after it declares a `Viewport`) and never to a grid
|
||||
session, and `CellDelta` vice versa, validating the M11.2
|
||||
per-session projection through the socket.
|
||||
|
||||
This completes the M11 semantic-frontend arc (M11.1–M11.5): wire +
|
||||
capability scaffolding, the instance-side projection seam,
|
||||
decorations, segment diffing, and the consumer-side glue with
|
||||
end-to-end coverage. `InlineAdornments`/`BlockAdornments`/`FoldState`/
|
||||
`ResourceOffer` remain honest stubs pending their source features.
|
||||
|
||||
## [1.0.0] --- 2026-05-18
|
||||
|
||||
First stable release. Builds on the 0.1.0 preview (M1–M6) with the
|
||||
|
|
|
|||
|
|
@ -90,6 +90,12 @@ pub mod project;
|
|||
pub mod project_index;
|
||||
pub mod protocol;
|
||||
pub mod rope;
|
||||
// T M11.5 — the headless semantic consumer composes BufferMirror +
|
||||
// optimistic (both `crdt`-gated) and is only meaningful on a
|
||||
// `semantic_render` session, which the negotiation dependency rule
|
||||
// ties to `crdt_replica`. Gated to match.
|
||||
#[cfg(feature = "crdt")]
|
||||
pub mod semantic_client;
|
||||
pub mod semantic_render;
|
||||
pub mod signature;
|
||||
pub mod socket_path;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,532 @@
|
|||
// semantic_client.rs --- Headless consumer of the SemanticFrame wire (T M11.5).
|
||||
|
||||
//! The frontend↔instance glue for the semantic projection.
|
||||
//!
|
||||
//! `docs/semantic-frontend-protocol.md` deliberately moves rendering
|
||||
//! correctness (shaping, wrap, hit-testing) into a GPU frontend the
|
||||
//! instance test harness cannot exercise, and bounds the *testable*
|
||||
//! surface to "the frontend↔instance glue, not all rendering."
|
||||
//! `SemanticClient` is exactly that glue, made headless and
|
||||
//! self-contained: no terminal, no GPU, no pixels.
|
||||
//!
|
||||
//! It composes the two replica layers a `semantic_render` session
|
||||
//! needs:
|
||||
//!
|
||||
//! - [`BufferMirror`] — the rope replica (M10.10). The semantic frame
|
||||
//! ships *no text*; the client holds the document locally via
|
||||
//! `BufferSnapshot` + `CrdtOp`, exactly as the grid TUI does.
|
||||
//! - A [`SemanticModel`] per family — the *interpretation* layer:
|
||||
//! byte-anchored styling / decorations, reconstructed from the
|
||||
//! `full` + dirty-segment deltas (M11.4).
|
||||
//!
|
||||
//! The client also produces the one frontend→instance message the
|
||||
//! protocol adds — [`FrontendEvent::Viewport`] — declaring the byte
|
||||
//! range it has "on screen" so the instance scopes its projection.
|
||||
//!
|
||||
//! Read-back accessors (`text`, `effective_style_at`,
|
||||
//! `decoration_kinds_at`) exist so a test can assert the
|
||||
//! reconstruction equals the instance's intent — the
|
||||
//! "reconstruction-equivalence" golden discipline (no snapshot crate;
|
||||
//! matches the repo's explicit-assertion style).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::buffer::BufferId;
|
||||
use crate::buffer_mirror::BufferMirror;
|
||||
use crate::cell::Style;
|
||||
use crate::overlay::merge_styles;
|
||||
use crate::protocol::{
|
||||
ByteRange, Decoration, DecorationKind, DecorationSegment, FrontendEvent, FrontendId,
|
||||
InstanceMessage, StyleSegment, StyleSpan,
|
||||
};
|
||||
|
||||
/// An item the model can restrict to a sub-range. `range` is where it
|
||||
/// applies; `clipped` is the item narrowed to `bounds` (or `None`
|
||||
/// when disjoint). The semantic frame's items are byte-anchored, so
|
||||
/// both families implement this uniformly.
|
||||
trait Clip: Clone {
|
||||
fn range(&self) -> ByteRange;
|
||||
fn clipped(&self, bounds: ByteRange) -> Option<Self>;
|
||||
}
|
||||
|
||||
fn intersect(a: ByteRange, b: ByteRange) -> Option<ByteRange> {
|
||||
let start = a.start.max(b.start);
|
||||
let end = a.end.min(b.end);
|
||||
(end > start).then_some(ByteRange { start, end })
|
||||
}
|
||||
|
||||
impl Clip for StyleSpan {
|
||||
fn range(&self) -> ByteRange {
|
||||
self.range
|
||||
}
|
||||
fn clipped(&self, bounds: ByteRange) -> Option<Self> {
|
||||
intersect(self.range, bounds).map(|range| Self {
|
||||
range,
|
||||
style: self.style,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Clip for Decoration {
|
||||
fn range(&self) -> ByteRange {
|
||||
self.range
|
||||
}
|
||||
fn clipped(&self, bounds: ByteRange) -> Option<Self> {
|
||||
intersect(self.range, bounds).map(|range| Self {
|
||||
range,
|
||||
kind: self.kind,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One reconstructed dirty region. The M11.4 contract — *each segment
|
||||
/// carries every current item intersecting its range* — makes a tile
|
||||
/// self-contained: rendering any byte in `range` consults only this
|
||||
/// tile's `items`, never a neighbour's. That is what lets incremental
|
||||
/// application be a clean per-tile replacement instead of fragile
|
||||
/// cross-span surgery.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct Tile<T> {
|
||||
range: ByteRange,
|
||||
items: Vec<T>,
|
||||
}
|
||||
|
||||
/// One family's reconstructed view of one buffer: disjoint tiles
|
||||
/// ordered by start. Bytes covered by no tile have no styling /
|
||||
/// decoration (default), exactly as the instance intends for regions
|
||||
/// outside the declared viewport.
|
||||
struct SemanticModel<T> {
|
||||
tiles: Vec<Tile<T>>,
|
||||
}
|
||||
|
||||
// Manual `Default` — the derive would wrongly require `T: Default`
|
||||
// (a `StyleSpan`/`Decoration` has no meaningful default); an empty
|
||||
// model is just no tiles regardless of `T`.
|
||||
impl<T> Default for SemanticModel<T> {
|
||||
fn default() -> Self {
|
||||
Self { tiles: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clip> SemanticModel<T> {
|
||||
/// Apply one frame. `full` discards everything first (resync);
|
||||
/// otherwise each segment replaces only its own byte range —
|
||||
/// tiles straddling a segment are split, keeping the parts
|
||||
/// outside it (clipped), and the segment's items become the new
|
||||
/// tile for the region.
|
||||
fn apply(&mut self, full: bool, segments: &[(ByteRange, Vec<T>)]) {
|
||||
if full {
|
||||
self.tiles = segments
|
||||
.iter()
|
||||
.map(|(range, items)| Tile {
|
||||
range: *range,
|
||||
items: items.clone(),
|
||||
})
|
||||
.collect();
|
||||
} else {
|
||||
for (range, items) in segments {
|
||||
self.replace_region(*range, items.clone());
|
||||
}
|
||||
}
|
||||
self.tiles.sort_by_key(|t| (t.range.start, t.range.end));
|
||||
}
|
||||
|
||||
fn replace_region(&mut self, region: ByteRange, items: Vec<T>) {
|
||||
let mut next: Vec<Tile<T>> = Vec::with_capacity(self.tiles.len() + 1);
|
||||
for t in std::mem::take(&mut self.tiles) {
|
||||
if intersect(t.range, region).is_none() {
|
||||
next.push(t);
|
||||
continue;
|
||||
}
|
||||
// Keep the parts of `t` outside `region`, each carrying
|
||||
// only the items that survive the narrower range.
|
||||
if t.range.start < region.start {
|
||||
let left = ByteRange {
|
||||
start: t.range.start,
|
||||
end: region.start,
|
||||
};
|
||||
next.push(Tile {
|
||||
range: left,
|
||||
items: t.items.iter().filter_map(|i| i.clipped(left)).collect(),
|
||||
});
|
||||
}
|
||||
if t.range.end > region.end {
|
||||
let right = ByteRange {
|
||||
start: region.end,
|
||||
end: t.range.end,
|
||||
};
|
||||
next.push(Tile {
|
||||
range: right,
|
||||
items: t.items.iter().filter_map(|i| i.clipped(right)).collect(),
|
||||
});
|
||||
}
|
||||
// The overlapped middle is dropped — `items` re-supplies it.
|
||||
}
|
||||
next.push(Tile {
|
||||
range: region,
|
||||
items,
|
||||
});
|
||||
self.tiles = next;
|
||||
}
|
||||
|
||||
/// Items covering `byte`, in instance order (the order they were
|
||||
/// shipped — wider-first for styling, so a fold via
|
||||
/// [`merge_styles`] reproduces the grid path's layering).
|
||||
fn items_at(&self, byte: u64) -> impl Iterator<Item = &T> {
|
||||
self.tiles
|
||||
.iter()
|
||||
.find(|t| t.range.start <= byte && byte < t.range.end)
|
||||
.into_iter()
|
||||
.flat_map(move |t| {
|
||||
t.items
|
||||
.iter()
|
||||
.filter(move |i| i.range().start <= byte && byte < i.range().end)
|
||||
})
|
||||
}
|
||||
|
||||
fn tile_ranges(&self) -> Vec<ByteRange> {
|
||||
self.tiles.iter().map(|t| t.range).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A headless `semantic_render` session: rope replica + the styling
|
||||
/// and decoration interpretation layers, plus the `Viewport` event it
|
||||
/// emits. Drive it by feeding every [`InstanceMessage`] through
|
||||
/// [`Self::apply`]; read it back through the accessors.
|
||||
pub struct SemanticClient {
|
||||
frontend_id: FrontendId,
|
||||
mirror: BufferMirror,
|
||||
styles: HashMap<BufferId, SemanticModel<StyleSpan>>,
|
||||
decos: HashMap<BufferId, SemanticModel<Decoration>>,
|
||||
}
|
||||
|
||||
impl SemanticClient {
|
||||
/// Construct a client for the session assigned `frontend_id`
|
||||
/// (the id the daemon stamped in `Hello`).
|
||||
#[must_use]
|
||||
pub fn new(frontend_id: FrontendId) -> Self {
|
||||
Self {
|
||||
frontend_id,
|
||||
mirror: BufferMirror::new(frontend_id),
|
||||
styles: HashMap::new(),
|
||||
decos: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The session's assigned frontend id.
|
||||
#[must_use]
|
||||
pub fn frontend_id(&self) -> FrontendId {
|
||||
self.frontend_id
|
||||
}
|
||||
|
||||
/// Build the [`FrontendEvent::Viewport`] declaring `visible` for
|
||||
/// `buffer_id`. The caller writes it to the daemon; the instance
|
||||
/// scopes its projection to this range. `generation` is the CRDT
|
||||
/// version the frontend computed the range against (M11.4 records
|
||||
/// it for the future viewport-race refinement).
|
||||
#[must_use]
|
||||
pub fn viewport_event(
|
||||
&self,
|
||||
buffer_id: BufferId,
|
||||
visible: ByteRange,
|
||||
generation: u64,
|
||||
) -> FrontendEvent {
|
||||
FrontendEvent::Viewport {
|
||||
frontend_id: self.frontend_id,
|
||||
buffer_id,
|
||||
visible,
|
||||
generation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Route one instance message into the replica/interpretation
|
||||
/// layers. Unrelated variants (grid `CellDelta`/`Cursor`,
|
||||
/// presence, and the not-yet-produced adornment/fold/resource
|
||||
/// families) are ignored — a semantic session lays out locally
|
||||
/// and never consumes the grid projection.
|
||||
pub fn apply(&mut self, msg: &InstanceMessage) {
|
||||
match msg {
|
||||
InstanceMessage::BufferSnapshot {
|
||||
buffer_id,
|
||||
crdt_snapshot,
|
||||
} => {
|
||||
// `AlreadyInitialized` means a duplicate bootstrap for
|
||||
// a buffer we already mirror — benign for a consumer.
|
||||
let _ = self.mirror.init_from_snapshot(*buffer_id, crdt_snapshot);
|
||||
}
|
||||
InstanceMessage::CrdtOp { buffer_id, op } => {
|
||||
// A pure consumer never edits, so it is never the
|
||||
// op's source — no echo to filter (the daemon also
|
||||
// excludes the sender). Drop a non-applying op
|
||||
// silently, as the test Observer does.
|
||||
let _ = self.mirror.apply_remote_op(*buffer_id, &op.bytes);
|
||||
}
|
||||
InstanceMessage::CursorByte {
|
||||
buffer_id,
|
||||
byte_pos,
|
||||
} => {
|
||||
self.mirror
|
||||
.set_cursor_byte_pos(*buffer_id, *byte_pos as usize);
|
||||
}
|
||||
InstanceMessage::StyleSpans {
|
||||
buffer_id,
|
||||
full,
|
||||
segments,
|
||||
..
|
||||
} => {
|
||||
let segs: Vec<(ByteRange, Vec<StyleSpan>)> = segments
|
||||
.iter()
|
||||
.map(|s: &StyleSegment| (s.range, s.spans.clone()))
|
||||
.collect();
|
||||
self.styles
|
||||
.entry(*buffer_id)
|
||||
.or_default()
|
||||
.apply(*full, &segs);
|
||||
}
|
||||
InstanceMessage::Decorations {
|
||||
buffer_id,
|
||||
full,
|
||||
segments,
|
||||
..
|
||||
} => {
|
||||
let segs: Vec<(ByteRange, Vec<Decoration>)> = segments
|
||||
.iter()
|
||||
.map(|s: &DecorationSegment| (s.range, s.decorations.clone()))
|
||||
.collect();
|
||||
self.decos
|
||||
.entry(*buffer_id)
|
||||
.or_default()
|
||||
.apply(*full, &segs);
|
||||
}
|
||||
// Grid projection, presence, and the honest-stub families
|
||||
// (InlineAdornments / BlockAdornments / FoldState /
|
||||
// ResourceOffer) — a semantic session does not consume
|
||||
// these. ModeLine / Signal / Goodbye are session control,
|
||||
// handled by the attach loop, not the model.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The reconstructed document text for `buffer_id` (the rope
|
||||
/// replica materialized), or `None` if not yet bootstrapped.
|
||||
#[must_use]
|
||||
pub fn text(&self, buffer_id: BufferId) -> Option<String> {
|
||||
self.mirror.materialize(buffer_id)
|
||||
}
|
||||
|
||||
/// Whether the rope replica for `buffer_id` has been bootstrapped.
|
||||
#[must_use]
|
||||
pub fn is_ready(&self, buffer_id: BufferId) -> bool {
|
||||
self.mirror.is_ready(buffer_id)
|
||||
}
|
||||
|
||||
/// The cursor byte position the instance last reported.
|
||||
#[must_use]
|
||||
pub fn cursor_byte_pos(&self, buffer_id: BufferId) -> Option<usize> {
|
||||
self.mirror.cursor_byte_pos(buffer_id)
|
||||
}
|
||||
|
||||
/// The effective style at `byte`: every reconstructed span
|
||||
/// covering it, folded via [`merge_styles`] in instance order.
|
||||
/// `Style::default()` when nothing covers it (outside the
|
||||
/// declared viewport, or no styling there).
|
||||
#[must_use]
|
||||
pub fn effective_style_at(&self, buffer_id: BufferId, byte: u64) -> Style {
|
||||
self.styles.get(&buffer_id).map_or_else(Style::default, |m| {
|
||||
m.items_at(byte)
|
||||
.fold(Style::default(), |acc, s| merge_styles(acc, s.style))
|
||||
})
|
||||
}
|
||||
|
||||
/// The decoration kinds covering `byte`, in instance order
|
||||
/// (duplicates preserved — a byte can carry, e.g., both a
|
||||
/// selection and a diagnostic).
|
||||
#[must_use]
|
||||
pub fn decoration_kinds_at(&self, buffer_id: BufferId, byte: u64) -> Vec<DecorationKind> {
|
||||
self.decos.get(&buffer_id).map_or_else(Vec::new, |m| {
|
||||
m.items_at(byte).map(|d| d.kind).collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Reconstructed styling tile ranges for `buffer_id` — for
|
||||
/// invariant assertions (disjointness, in-viewport bounds).
|
||||
#[must_use]
|
||||
pub fn style_tile_ranges(&self, buffer_id: BufferId) -> Vec<ByteRange> {
|
||||
self.styles
|
||||
.get(&buffer_id)
|
||||
.map(SemanticModel::tile_ranges)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Reconstructed decoration tile ranges for `buffer_id`.
|
||||
#[must_use]
|
||||
pub fn decoration_tile_ranges(&self, buffer_id: BufferId) -> Vec<ByteRange> {
|
||||
self.decos
|
||||
.get(&buffer_id)
|
||||
.map(SemanticModel::tile_ranges)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn br(start: u64, end: u64) -> ByteRange {
|
||||
ByteRange { start, end }
|
||||
}
|
||||
|
||||
fn styled(fg_bold: bool) -> Style {
|
||||
Style {
|
||||
bold: fg_bold,
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn span(start: u64, end: u64, bold: bool) -> StyleSpan {
|
||||
StyleSpan {
|
||||
range: br(start, end),
|
||||
style: styled(bold),
|
||||
}
|
||||
}
|
||||
|
||||
fn deco(start: u64, end: u64, kind: DecorationKind) -> Decoration {
|
||||
Decoration {
|
||||
range: br(start, end),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_frame_replaces_the_whole_model() {
|
||||
let mut m: SemanticModel<StyleSpan> = SemanticModel::default();
|
||||
m.apply(true, &[(br(0, 10), vec![span(2, 5, true)])]);
|
||||
assert_eq!(m.tile_ranges(), vec![br(0, 10)]);
|
||||
// A second full frame discards the first entirely.
|
||||
m.apply(true, &[(br(0, 4), vec![span(0, 4, false)])]);
|
||||
assert_eq!(m.tile_ranges(), vec![br(0, 4)]);
|
||||
assert_eq!(m.items_at(2).count(), 1);
|
||||
assert!(m.items_at(8).next().is_none(), "byte 8 no longer covered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_segment_splits_a_straddling_tile_and_keeps_the_edges() {
|
||||
let mut m: SemanticModel<StyleSpan> = SemanticModel::default();
|
||||
// One wide tile spanning [0,30) with a span over [0,30).
|
||||
m.apply(true, &[(br(0, 30), vec![span(0, 30, true)])]);
|
||||
// A dirty segment repaints the middle [10,20).
|
||||
m.apply(false, &[(br(10, 20), vec![span(10, 20, false)])]);
|
||||
// Edges [0,10) and [20,30) survive (clipped), middle replaced.
|
||||
assert_eq!(
|
||||
m.tile_ranges(),
|
||||
vec![br(0, 10), br(10, 20), br(20, 30)],
|
||||
"straddling tile split into left edge / new middle / right edge"
|
||||
);
|
||||
// Edge styling preserved (bold); middle replaced (not bold).
|
||||
assert!(m.items_at(5).next().unwrap().style.bold);
|
||||
assert!(!m.items_at(15).next().unwrap().style.bold);
|
||||
assert!(m.items_at(25).next().unwrap().style.bold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_outside_all_tiles_have_default_style() {
|
||||
let c = SemanticClient::new(FrontendId(7));
|
||||
let b = BufferId::next();
|
||||
assert_eq!(c.effective_style_at(b, 3), Style::default());
|
||||
assert!(c.decoration_kinds_at(b, 3).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_spans_fold_in_order_via_merge_styles() {
|
||||
let mut m: SemanticModel<StyleSpan> = SemanticModel::default();
|
||||
// Wider span (bold) then a nested non-bold span — instance
|
||||
// ships wider-first; merge_styles overlays in that order.
|
||||
let wide = StyleSpan {
|
||||
range: br(0, 10),
|
||||
style: Style {
|
||||
bold: true,
|
||||
..Style::default()
|
||||
},
|
||||
};
|
||||
let inner = StyleSpan {
|
||||
range: br(4, 6),
|
||||
style: Style {
|
||||
italic: true,
|
||||
..Style::default()
|
||||
},
|
||||
};
|
||||
m.apply(true, &[(br(0, 10), vec![wide, inner])]);
|
||||
let folded = m
|
||||
.items_at(5)
|
||||
.fold(Style::default(), |acc, s| merge_styles(acc, s.style));
|
||||
assert!(folded.bold && folded.italic, "both layers apply at byte 5");
|
||||
let only_wide = m
|
||||
.items_at(1)
|
||||
.fold(Style::default(), |acc, s| merge_styles(acc, s.style));
|
||||
assert!(only_wide.bold && !only_wide.italic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoration_model_tracks_kinds_at_byte() {
|
||||
let mut m: SemanticModel<Decoration> = SemanticModel::default();
|
||||
m.apply(
|
||||
true,
|
||||
&[(
|
||||
br(0, 20),
|
||||
vec![
|
||||
deco(2, 8, DecorationKind::Selection),
|
||||
deco(5, 6, DecorationKind::DiagnosticError),
|
||||
],
|
||||
)],
|
||||
);
|
||||
let at5: Vec<_> = m.items_at(5).map(|d| d.kind).collect();
|
||||
assert_eq!(
|
||||
at5,
|
||||
vec![DecorationKind::Selection, DecorationKind::DiagnosticError]
|
||||
);
|
||||
assert_eq!(
|
||||
m.items_at(3).map(|d| d.kind).collect::<Vec<_>>(),
|
||||
vec![DecorationKind::Selection]
|
||||
);
|
||||
assert!(m.items_at(15).next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_ignores_grid_and_stub_families() {
|
||||
let mut c = SemanticClient::new(FrontendId(2));
|
||||
let b = BufferId::next();
|
||||
// None of these should panic or affect the model.
|
||||
c.apply(&InstanceMessage::Cursor(None));
|
||||
c.apply(&InstanceMessage::FoldState {
|
||||
buffer_id: b,
|
||||
folds: vec![br(0, 1)],
|
||||
});
|
||||
c.apply(&InstanceMessage::ResourceOffer {
|
||||
handle: 1,
|
||||
mime: "image/png".into(),
|
||||
body: crate::protocol::ResourceBody::Inline(vec![1, 2]),
|
||||
});
|
||||
assert!(c.style_tile_ranges(b).is_empty());
|
||||
assert!(c.text(b).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_event_carries_the_sessions_fid() {
|
||||
let c = SemanticClient::new(FrontendId(9));
|
||||
let b = BufferId::next();
|
||||
match c.viewport_event(b, br(0, 64), 3) {
|
||||
FrontendEvent::Viewport {
|
||||
frontend_id,
|
||||
buffer_id,
|
||||
visible,
|
||||
generation,
|
||||
} => {
|
||||
assert_eq!(frontend_id, FrontendId(9));
|
||||
assert_eq!(buffer_id, b);
|
||||
assert_eq!(visible, br(0, 64));
|
||||
assert_eq!(generation, 3);
|
||||
}
|
||||
other => panic!("expected Viewport, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
// m11_5_semantic_acceptance.rs --- M11.5 acceptance: the semantic frontend↔instance glue.
|
||||
|
||||
//! T M11.5 acceptance suite for the semantic-frontend arc.
|
||||
//!
|
||||
//! Two paths, both exercising the headless [`SemanticClient`] — the
|
||||
//! frontend↔instance glue the design note names as the bounded
|
||||
//! testable surface (`docs/semantic-frontend-protocol.md`,
|
||||
//! "Testability strategy"):
|
||||
//!
|
||||
//! - **Reconstruction-equivalence (instance-side, deterministic).**
|
||||
//! Drive a [`SemanticRenderState`] through a scripted sequence of
|
||||
//! viewport declarations and editor mutations, feed every emitted
|
||||
//! message into a `SemanticClient`, and assert the client's
|
||||
//! incrementally-reconstructed view is byte-for-byte identical to a
|
||||
//! *fresh full* projection of the same instant (the oracle). This
|
||||
//! is the golden discipline without a snapshot crate: the property
|
||||
//! asserted is "incremental ≡ from-scratch", which no incidental
|
||||
//! wire-shape churn can falsely pass.
|
||||
//!
|
||||
//! - **End-to-end daemon filter.** A real daemon, a semantic session
|
||||
//! (negotiating `semantic_render`, declaring a `Viewport`) and a
|
||||
//! grid session: prove the M11.2 per-session projection actually
|
||||
//! routes `StyleSpans`/`Decorations` to the semantic session and
|
||||
//! never to the grid one, and `CellDelta` vice versa.
|
||||
|
||||
#![cfg(feature = "crdt")]
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pmacs::buffer::BufferId;
|
||||
use pmacs::cell::CellSize;
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::protocol::{
|
||||
AttachRequest, ByteRange, FrontendCapabilities, FrontendEvent, FrontendId, Hello,
|
||||
InstanceMessage,
|
||||
};
|
||||
use pmacs::semantic_client::SemanticClient;
|
||||
use pmacs::semantic_render::SemanticRenderState;
|
||||
use pmacs::transport::{read_message, write_message};
|
||||
|
||||
mod common;
|
||||
use common::daemon::{TestDaemon, build_default_caps};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Part A — reconstruction-equivalence (instance-side, no daemon)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LOCAL: FrontendId = FrontendId::LOCAL;
|
||||
|
||||
fn active_buffer(state: &EditorState) -> BufferId {
|
||||
state.core.borrow().active_window().buffer_id
|
||||
}
|
||||
|
||||
fn set_selection(state: &EditorState, anchor: u64, cursor: u64) {
|
||||
let mut core = state.core.borrow_mut();
|
||||
let win = core
|
||||
.active_window_mut_for(LOCAL)
|
||||
.expect("LOCAL always has a window");
|
||||
win.selection = Some(pmacs::window::Selection { anchor });
|
||||
win.cursor = cursor;
|
||||
}
|
||||
|
||||
/// The authoritative reconstruction for this instant: a fresh
|
||||
/// `SemanticRenderState` emits a `full` first frame carrying the
|
||||
/// complete current scoped set; a fresh client consuming only that is
|
||||
/// the oracle the incrementally-driven client must match.
|
||||
fn oracle(state: &EditorState, buffer_id: BufferId, vp: ByteRange) -> SemanticClient {
|
||||
let mut o = SemanticRenderState::new(LOCAL);
|
||||
o.set_viewport(buffer_id, vp, 0);
|
||||
let mut oc = SemanticClient::new(LOCAL);
|
||||
for m in &o.render_frame(state) {
|
||||
oc.apply(m);
|
||||
}
|
||||
oc
|
||||
}
|
||||
|
||||
fn assert_equiv(client: &SemanticClient, state: &EditorState, buffer_id: BufferId, vp: ByteRange) {
|
||||
let oc = oracle(state, buffer_id, vp);
|
||||
for b in vp.start..vp.end {
|
||||
assert_eq!(
|
||||
client.decoration_kinds_at(buffer_id, b),
|
||||
oc.decoration_kinds_at(buffer_id, b),
|
||||
"decoration mismatch at byte {b}"
|
||||
);
|
||||
assert_eq!(
|
||||
client.effective_style_at(buffer_id, b),
|
||||
oc.effective_style_at(buffer_id, b),
|
||||
"style mismatch at byte {b}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn decorations_full(msgs: &[InstanceMessage]) -> Option<bool> {
|
||||
msgs.iter().find_map(|m| match m {
|
||||
InstanceMessage::Decorations { full, .. } => Some(*full),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn has_style_spans(msgs: &[InstanceMessage]) -> bool {
|
||||
msgs.iter()
|
||||
.any(|m| matches!(m, InstanceMessage::StyleSpans { .. }))
|
||||
}
|
||||
|
||||
fn generation_of(msgs: &[InstanceMessage]) -> Option<u64> {
|
||||
msgs.iter().find_map(|m| match m {
|
||||
InstanceMessage::StyleSpans { generation, .. }
|
||||
| InstanceMessage::Decorations { generation, .. } => Some(*generation),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_disjoint_within(ranges: &[ByteRange], vp: ByteRange) {
|
||||
let mut sorted = ranges.to_vec();
|
||||
sorted.sort_by_key(|r| (r.start, r.end));
|
||||
let mut prev_end = vp.start;
|
||||
for r in &sorted {
|
||||
assert!(
|
||||
r.start >= vp.start && r.end <= vp.end,
|
||||
"tile {r:?} escapes the declared viewport {vp:?}"
|
||||
);
|
||||
assert!(
|
||||
r.start >= prev_end,
|
||||
"tiles overlap: {r:?} starts before previous end {prev_end}"
|
||||
);
|
||||
prev_end = r.end;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_reconstruction_equals_fresh_full_projection() {
|
||||
let state = EditorState::new();
|
||||
let buffer_id = active_buffer(&state);
|
||||
let vp1 = ByteRange { start: 0, end: 64 };
|
||||
|
||||
let mut sem = SemanticRenderState::new(LOCAL);
|
||||
sem.set_viewport(buffer_id, vp1, 0);
|
||||
let mut client = SemanticClient::new(LOCAL);
|
||||
let mut generations: Vec<u64> = Vec::new();
|
||||
|
||||
// Frame 1 — first frame: a full resync for both families (empty
|
||||
// scratch, no selection → empty segments).
|
||||
let f1 = sem.render_frame(&state);
|
||||
assert_eq!(decorations_full(&f1), Some(true), "first frame full");
|
||||
assert!(has_style_spans(&f1), "first frame ships StyleSpans too");
|
||||
if let Some(g) = generation_of(&f1) {
|
||||
generations.push(g);
|
||||
}
|
||||
for m in &f1 {
|
||||
client.apply(m);
|
||||
}
|
||||
assert_equiv(&client, &state, buffer_id, vp1);
|
||||
|
||||
// Unchanged → fully silent.
|
||||
assert!(
|
||||
sem.render_frame(&state).is_empty(),
|
||||
"an unchanged frame emits nothing"
|
||||
);
|
||||
|
||||
// A selection appears → Decorations re-emits incrementally
|
||||
// (viewport region unchanged), styling stays suppressed.
|
||||
set_selection(&state, 2, 5);
|
||||
let f2 = sem.render_frame(&state);
|
||||
assert_eq!(decorations_full(&f2), Some(false), "incremental, not full");
|
||||
assert!(!has_style_spans(&f2), "styling unchanged → not re-sent");
|
||||
if let Some(g) = generation_of(&f2) {
|
||||
generations.push(g);
|
||||
}
|
||||
for m in &f2 {
|
||||
client.apply(m);
|
||||
}
|
||||
assert_equiv(&client, &state, buffer_id, vp1);
|
||||
|
||||
// Selection jumps far away → two disjoint dirty intervals (old
|
||||
// cleared, new painted). The client must reconstruct both.
|
||||
set_selection(&state, 40, 42);
|
||||
let f3 = sem.render_frame(&state);
|
||||
for m in &f3 {
|
||||
client.apply(m);
|
||||
}
|
||||
if let Some(g) = generation_of(&f3) {
|
||||
generations.push(g);
|
||||
}
|
||||
assert_equiv(&client, &state, buffer_id, vp1);
|
||||
assert_disjoint_within(&client.decoration_tile_ranges(buffer_id), vp1);
|
||||
|
||||
// Viewport region moves → a full resync. The selection at
|
||||
// [40,42) is outside the new window, so the reconstruction is
|
||||
// empty there — but only if the client correctly discarded the
|
||||
// old viewport's tiles on the `full` frame.
|
||||
let vp2 = ByteRange {
|
||||
start: 100,
|
||||
end: 200,
|
||||
};
|
||||
sem.set_viewport(buffer_id, vp2, 0);
|
||||
let f4 = sem.render_frame(&state);
|
||||
assert_eq!(
|
||||
decorations_full(&f4),
|
||||
Some(true),
|
||||
"viewport jump forces a full resync"
|
||||
);
|
||||
for m in &f4 {
|
||||
client.apply(m);
|
||||
}
|
||||
assert_equiv(&client, &state, buffer_id, vp2);
|
||||
|
||||
// Generation is monotonic non-decreasing across the run.
|
||||
for w in generations.windows(2) {
|
||||
assert!(w[1] >= w[0], "generation went backwards: {generations:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Part B — end-to-end daemon: per-session projection routing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn semantic_caps() -> FrontendCapabilities {
|
||||
// semantic_render requires crdt_replica (negotiation dependency
|
||||
// rule); a semantic session is also a text replica.
|
||||
FrontendCapabilities {
|
||||
multi_frontend: true,
|
||||
crdt_replica: true,
|
||||
semantic_render: true,
|
||||
..build_default_caps()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read messages until `deadline`, classifying what arrives. Returns
|
||||
/// `(saw_cell_delta, saw_semantic, first_buffer_id)`.
|
||||
fn drain_kinds(
|
||||
stream: &mut std::os::unix::net::UnixStream,
|
||||
deadline: Instant,
|
||||
mut on_snapshot: impl FnMut(BufferId),
|
||||
) -> (bool, bool) {
|
||||
let mut saw_cell = false;
|
||||
let mut saw_semantic = false;
|
||||
while Instant::now() < deadline {
|
||||
match read_message::<InstanceMessage>(stream) {
|
||||
Ok(InstanceMessage::CellDelta { .. }) => saw_cell = true,
|
||||
Ok(InstanceMessage::StyleSpans { .. } | InstanceMessage::Decorations { .. }) => {
|
||||
saw_semantic = true;
|
||||
}
|
||||
Ok(InstanceMessage::BufferSnapshot { buffer_id, .. }) => on_snapshot(buffer_id),
|
||||
// Other variants are irrelevant here; `Err` is a
|
||||
// read-timeout slice — both just keep polling.
|
||||
Ok(_) | Err(_) => {}
|
||||
}
|
||||
}
|
||||
(saw_cell, saw_semantic)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_routes_semantic_family_to_semantic_session_only() {
|
||||
let daemon = TestDaemon::spawn();
|
||||
|
||||
// --- Semantic session ---
|
||||
let mut sem = daemon.connect();
|
||||
sem.set_read_timeout(Some(Duration::from_millis(250)))
|
||||
.unwrap();
|
||||
let hello: Hello = read_message(&mut sem).expect("semantic read Hello");
|
||||
let sem_fid = hello.assigned_frontend_id;
|
||||
write_message(
|
||||
&mut sem,
|
||||
&AttachRequest {
|
||||
protocol_version: hello.protocol_version,
|
||||
frontend_capabilities: semantic_caps(),
|
||||
initial_size: CellSize::new(24, 80),
|
||||
},
|
||||
)
|
||||
.expect("semantic write AttachRequest");
|
||||
|
||||
// Learn a buffer id from the bootstrap snapshot, then declare a
|
||||
// viewport — the daemon emits nothing semantic until it does
|
||||
// (M11.2), so this also exercises the Viewport intercept e2e.
|
||||
let mut buf: Option<BufferId> = None;
|
||||
let by = Instant::now() + Duration::from_secs(5);
|
||||
let _ = drain_kinds(&mut sem, Instant::now() + Duration::from_secs(2), |b| {
|
||||
buf.get_or_insert(b);
|
||||
});
|
||||
let buffer_id = buf.expect("semantic session received a BufferSnapshot");
|
||||
write_message(
|
||||
&mut sem,
|
||||
&FrontendEvent::Viewport {
|
||||
frontend_id: sem_fid,
|
||||
buffer_id,
|
||||
visible: ByteRange {
|
||||
start: 0,
|
||||
end: 4096,
|
||||
},
|
||||
generation: 0,
|
||||
},
|
||||
)
|
||||
.expect("semantic write Viewport");
|
||||
let (sem_saw_cell, sem_saw_semantic) = drain_kinds(&mut sem, by, |_| {});
|
||||
assert!(
|
||||
sem_saw_semantic,
|
||||
"semantic session must receive StyleSpans/Decorations after declaring a viewport"
|
||||
);
|
||||
assert!(
|
||||
!sem_saw_cell,
|
||||
"semantic session must NOT receive grid CellDelta (it lays out locally)"
|
||||
);
|
||||
|
||||
// --- Grid session (same daemon) ---
|
||||
let mut grid = daemon.connect();
|
||||
grid.set_read_timeout(Some(Duration::from_millis(250)))
|
||||
.unwrap();
|
||||
let ghello: Hello = read_message(&mut grid).expect("grid read Hello");
|
||||
write_message(
|
||||
&mut grid,
|
||||
&AttachRequest {
|
||||
protocol_version: ghello.protocol_version,
|
||||
frontend_capabilities: build_default_caps(),
|
||||
initial_size: CellSize::new(24, 80),
|
||||
},
|
||||
)
|
||||
.expect("grid write AttachRequest");
|
||||
let (grid_saw_cell, grid_saw_semantic) =
|
||||
drain_kinds(&mut grid, Instant::now() + Duration::from_secs(3), |_| {});
|
||||
assert!(
|
||||
grid_saw_cell,
|
||||
"grid session must receive CellDelta (the M5 projection)"
|
||||
);
|
||||
assert!(
|
||||
!grid_saw_semantic,
|
||||
"grid session must NOT receive the semantic family"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue