M11.3: Decorations projection (selection + diagnostics)

SemanticRenderState now also projects InstanceMessage::Decorations
from the instance-side state pmacs actually has.

- Selection: per-window byte-native state via active_window_for(fid)
  (SemanticRenderState now carries the session FrontendId), gated to
  the declared buffer and clipped to the viewport →
  DecorationKind::Selection.
- Diagnostics: the shared DiagnosticStore keyed by file URI. Made
  lsp::path_to_file_uri pub(crate) (byte-identical to the Lua
  file_uri_for) so the projection reproduces the exact store key from
  core.file_path. LSP (line,col) -> byte via a line-start scan
  against the buffer source; severity -> DiagnosticError/Warning/
  Info/Hint. Clipped to the viewport.
- StyleSpans and Decorations suppress unchanged frames independently
  (separate last_* maps): a selection move doesn't force a styling
  re-send and vice versa.
- Deliberately NOT emitted: SearchMatch/SearchMatchActive (no
  instance search-hit store), CurrentLine (frontend derives from
  CursorByte; emitting it would breach the contract boundary).
- InlineAdornments/BlockAdornments/FoldState remain unproduced by
  design — no inlay/blame/lens/fold/diff source exists in pmacs yet.
  Honest stubs (the M11.1 "declared, not yet wired" discipline), not
  empty messages every frame.

Dispatcher updated for SemanticRenderState::new(frontend_id). Lib
(1394 crdt / 1239 non-crdt) + integration green on both feature
flavors; clippy -D warnings clean on both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-18 20:47:18 -04:00
parent 4ba6fd7bb8
commit efa8f6f8ed
4 changed files with 422 additions and 83 deletions

View File

@ -60,6 +60,30 @@ The first real producer. The instance now projects syntax styling to
`cfg!(feature = "crdt")` — the "M11.2 enables semantic" moment,
analogous to the M10.8 Day-4 `multi_frontend`/`crdt_replica` flip.
#### Decorations projection (M11.3)
`SemanticRenderState` now also projects `InstanceMessage::Decorations`
from the editor state pmacs actually has instance-side:
- **Selection** — per-window (per-frontend) byte-native state, scoped
to the session's active window for the declared buffer and clipped
to the viewport. `DecorationKind::Selection`.
- **Diagnostics** — the shared `DiagnosticStore`, keyed by the file
URI the LSP glue opened the document under (`lsp::path_to_file_uri`
is now `pub(crate)`, byte-identical to the Lua `file_uri_for`).
LSP `(line, col)` is converted to a byte range against the buffer
source; severity maps to `DiagnosticError`/`Warning`/`Info`/`Hint`.
- `StyleSpans` and `Decorations` suppress unchanged frames
independently — a selection move does not force a styling re-send.
- `Decorations::SearchMatch`/`SearchMatchActive`/`CurrentLine` are not
emitted: pmacs has no instance-side search-hit store, and
current-line is a pure cursor derivation the frontend already owns
via `CursorByte` (emitting it would breach the contract boundary).
- `InlineAdornments`/`BlockAdornments`/`FoldState` remain unproduced
by design — pmacs has no inlay-hint/blame/lens/fold/diff source
yet. The wire variants exist (M11.1); their producers are wired
when those features land. Honest stubs, not fabricated data.
## [1.0.0] --- 2026-05-18
First stable release. Builds on the 0.1.0 preview (M1M6) with the

View File

@ -1190,7 +1190,7 @@ fn handle_session_established(
if semantic_render {
semantic_states.insert(
frontend_id,
crate::semantic_render::SemanticRenderState::new(),
crate::semantic_render::SemanticRenderState::new(frontend_id),
);
} else {
let mut render_state = RenderState::new(initial_size);

View File

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

View File

@ -28,7 +28,9 @@ use std::collections::HashMap;
use crate::buffer::BufferId;
use crate::cell::Style;
use crate::editor::EditorState;
use crate::protocol::{ByteRange, InstanceMessage, StyleSpan};
use crate::protocol::{
ByteRange, Decoration, DecorationKind, FrontendId, InstanceMessage, StyleSpan,
};
/// The viewport a `semantic_render` frontend last declared.
#[derive(Clone, Debug, Eq, PartialEq)]
@ -43,10 +45,16 @@ struct DeclaredViewport {
}
/// Owns one `semantic_render` session's projection state: the last
/// viewport the frontend declared, and the last `StyleSpans` payload
/// shipped per buffer (for byte-identical-frame suppression).
#[derive(Default)]
/// viewport the frontend declared, and the last `StyleSpans` /
/// `Decorations` payloads shipped per buffer (for byte-identical-frame
/// suppression).
pub struct SemanticRenderState {
/// The session this projection serves. Selection is per-window
/// (per-frontend) state, so the decoration projection needs the
/// fid to resolve *this* session's active window via
/// `active_window_for`. Styling and diagnostics are per-buffer and
/// do not consult it.
frontend_id: FrontendId,
/// `None` until the frontend's first [`Self::set_viewport`]. While
/// `None`, [`Self::render_frame`] emits nothing: the frontend
/// bootstraps its rope from `BufferSnapshot`, declares what is on
@ -57,13 +65,23 @@ pub struct SemanticRenderState {
/// nothing — the steady-state cost between edits is one map
/// lookup. True per-span delta encoding is M11.4.
last_sent: HashMap<BufferId, (u64, Vec<StyleSpan>)>,
/// Same unchanged-frame suppression for the `Decorations` family
/// (T M11.3), tracked independently of `last_sent` so a styling
/// change does not force a decorations re-send and vice versa.
last_decorations: HashMap<BufferId, (u64, Vec<Decoration>)>,
}
impl SemanticRenderState {
/// Fresh session state: no viewport declared, nothing sent.
/// Fresh session state for frontend `frontend_id`: no viewport
/// declared, nothing sent.
#[must_use]
pub fn new() -> Self {
Self::default()
pub fn new(frontend_id: FrontendId) -> Self {
Self {
frontend_id,
viewport: None,
last_sent: HashMap::new(),
last_decorations: HashMap::new(),
}
}
/// Record the frontend's declared on-screen byte range. Called by
@ -80,12 +98,20 @@ impl SemanticRenderState {
/// Project one frame.
///
/// Returns at most one [`InstanceMessage::StyleSpans`], scoped to
/// the declared viewport. Returns an empty vec when: no viewport
/// has been declared yet; the buffer has no parse view or settled
/// tree; the language has no highlights query; or the scoped span
/// set is byte-identical to the last one shipped at the same
/// generation (the unchanged-frame fast path).
/// Returns up to two messages — an [`InstanceMessage::StyleSpans`]
/// (T M11.2) and an [`InstanceMessage::Decorations`] (T M11.3) —
/// each scoped to the declared viewport and each suppressed
/// independently when byte-identical to its last send at the same
/// generation. Returns an empty vec before the frontend declares a
/// viewport.
///
/// `InlineAdornments` / `BlockAdornments` / `FoldState` are
/// deliberately *not* produced: pmacs has no instance-side inlay-
/// hint / blame / lens / fold / diff source yet. The wire variants
/// exist (T M11.1); their producers are wired when those features
/// land — the same "declared, not yet wired" discipline M11.1
/// applied to the whole family. Emitting empty messages every
/// frame would be waste, not honesty.
pub fn render_frame(&mut self, state: &EditorState) -> Vec<InstanceMessage> {
let Some(vp) = self.viewport.clone() else {
// Emit nothing before the frontend declares a viewport.
@ -93,29 +119,186 @@ impl SemanticRenderState {
};
let generation = buffer_generation(state, vp.buffer_id);
let spans = scoped_style_spans(state, &vp);
let mut out = Vec::new();
// --- StyleSpans (T M11.2) ---
let spans = scoped_style_spans(state, &vp);
// Unchanged-frame suppression (M11.4 replaces this with
// per-span delta encoding). A buffer with an empty scoped set
// still suppresses correctly: the first empty frame ships once
// (clearing any prior styling on the frontend), subsequent
// identical empty frames are squelched.
if let Some((last_gen, last_spans)) = self.last_sent.get(&vp.buffer_id)
&& *last_gen == generation
&& *last_spans == spans
{
return Vec::new();
let style_unchanged = self
.last_sent
.get(&vp.buffer_id)
.is_some_and(|(g, s)| *g == generation && *s == spans);
if !style_unchanged {
self.last_sent
.insert(vp.buffer_id, (generation, spans.clone()));
out.push(InstanceMessage::StyleSpans {
buffer_id: vp.buffer_id,
generation,
spans,
});
}
self.last_sent
.insert(vp.buffer_id, (generation, spans.clone()));
vec![InstanceMessage::StyleSpans {
buffer_id: vp.buffer_id,
generation,
spans,
}]
// --- Decorations (T M11.3) ---
let decorations = self.scoped_decorations(state, &vp);
let deco_unchanged = self
.last_decorations
.get(&vp.buffer_id)
.is_some_and(|(g, d)| *g == generation && *d == decorations);
if !deco_unchanged {
self.last_decorations
.insert(vp.buffer_id, (generation, decorations.clone()));
out.push(InstanceMessage::Decorations {
buffer_id: vp.buffer_id,
decorations,
});
}
out
}
/// Project the [`Decoration`] set intersecting the declared
/// viewport: the session's selection (instance-authoritative,
/// byte-native) and LSP diagnostics (line/col → byte, severity →
/// kind). Search-hit and current-line decorations are
/// deliberately absent: pmacs has no instance-side search-hit
/// store, and current-line is a pure cursor derivation the
/// frontend already owns (it has `CursorByte`) — emitting it would
/// couple a visual-motion concern to the instance, against the
/// contract boundary.
fn scoped_decorations(
&self,
state: &EditorState,
vp: &DeclaredViewport,
) -> Vec<Decoration> {
let core = state.core.borrow();
let mut out = Vec::new();
// Selection — per-window (per-frontend) state, already byte
// offsets. Only this session's active window for the declared
// buffer contributes.
if let Some(win) = core.active_window_for(self.frontend_id)
&& win.buffer_id == vp.buffer_id
&& let Some((lo, hi)) = win.region()
&& let Some(range) = clip_to_viewport(lo, hi, vp)
{
out.push(Decoration {
range,
kind: DecorationKind::Selection,
});
}
// Diagnostics — keyed in the shared store by the file URI the
// Lua LSP glue opened the document under. `core.file_path` is
// the editor's active file path; encoding it with the shared
// `path_to_file_uri` reproduces that exact key (the Lua
// `file_uri_for` is byte-identical). A buffer with no file
// path, or no diagnostics under its URI, contributes nothing.
if let Some(path) = core.file_path.as_ref() {
let uri = crate::lsp::path_to_file_uri(path);
let diags = {
let store = state.lsp_manager.borrow().diag_store();
let guard = store.lock().expect("diag store mutex poisoned");
guard.for_uri(&uri).to_vec()
};
if !diags.is_empty() {
let registry = core.registry.clone();
let reg = registry.borrow();
if let Ok(buf) = reg.get(vp.buffer_id) {
let source = buffer_source_bytes(buf);
let line_starts = line_start_offsets(&source);
for d in &diags {
let lo = line_col_to_byte(
&line_starts,
source.len() as u64,
d.start_line,
d.start_col,
);
let hi = line_col_to_byte(
&line_starts,
source.len() as u64,
d.end_line,
d.end_col,
);
if let Some(range) = clip_to_viewport(lo, hi, vp) {
out.push(Decoration {
range,
kind: severity_to_kind(d.severity),
});
}
}
}
}
}
out
}
}
/// Intersect `[lo, hi)` with the declared viewport (itself clamped to
/// the source length is the caller's concern for styling; for
/// decorations we clamp against the viewport only). `None` when the
/// intersection is empty or degenerate.
fn clip_to_viewport(lo: u64, hi: u64, vp: &DeclaredViewport) -> Option<ByteRange> {
let start = lo.max(vp.visible.start);
let end = hi.min(vp.visible.end);
if end <= start {
return None;
}
Some(ByteRange { start, end })
}
/// Map an LSP diagnostic severity onto the wire decoration kind.
fn severity_to_kind(sev: crate::diag::DiagnosticSeverity) -> DecorationKind {
use crate::diag::DiagnosticSeverity as S;
match sev {
S::Error => DecorationKind::DiagnosticError,
S::Warning => DecorationKind::DiagnosticWarning,
S::Information => DecorationKind::DiagnosticInfo,
S::Hint => DecorationKind::DiagnosticHint,
}
}
/// Snapshot a buffer's bytes (refcount-cheap rope slice, mirroring
/// `diag.rs`'s render-time snapshot).
fn buffer_source_bytes(buf: &crate::buffer::Buffer) -> Vec<u8> {
let len = buf.len();
let mut bytes = vec![0u8; len as usize];
if !bytes.is_empty() {
buf.snapshot_rope().slice(0, len, &mut bytes);
}
bytes
}
/// Byte offset of the start of each line (index 0 = byte 0; one entry
/// per line, where a line is a maximal run ended by `\n`).
fn line_start_offsets(source: &[u8]) -> Vec<u64> {
let mut starts = vec![0u64];
for (i, b) in source.iter().enumerate() {
if *b == b'\n' {
starts.push(i as u64 + 1);
}
}
starts
}
/// Translate an LSP `(line, col)` to a byte offset. pmacs v0.1 treats
/// the LSP column as a byte offset within the line (see
/// `crate::diag::Diagnostic`'s field docs); we clamp to the line's
/// end and the source length so a stale diagnostic from before an
/// edit can never index out of range.
fn line_col_to_byte(line_starts: &[u64], source_len: u64, line: u32, col: u32) -> u64 {
let li = line as usize;
let Some(&line_start) = line_starts.get(li) else {
return source_len;
};
let line_end = line_starts
.get(li + 1)
.map_or(source_len, |&next| next.saturating_sub(1));
(line_start + u64::from(col)).min(line_end).min(source_len)
}
/// Compute the styled byte runs intersecting the declared viewport,
@ -213,87 +396,213 @@ mod tests {
EditorState::new()
}
fn local() -> SemanticRenderState {
// FrontendId::LOCAL always has a registered FrontendView
// (EditorCore invariant), so `active_window_for(LOCAL)` — the
// selection projection's lookup — resolves in a fresh editor.
SemanticRenderState::new(FrontendId::LOCAL)
}
fn active_buffer(state: &EditorState) -> BufferId {
state.core.borrow().active_window().buffer_id
}
/// All `InstanceMessage` variants the semantic projection may
/// emit are `StyleSpans` or `Decorations` — never `CellDelta`,
/// grid `Cursor`, or the not-yet-wired adornment/fold families.
fn assert_semantic_only(msgs: &[InstanceMessage]) {
for m in msgs {
assert!(
matches!(
m,
InstanceMessage::StyleSpans { .. } | InstanceMessage::Decorations { .. }
),
"semantic projection emitted an unexpected variant: {m:?}"
);
}
}
#[test]
fn emits_nothing_before_viewport_declared() {
let mut s = SemanticRenderState::new();
let mut s = local();
assert!(
s.render_frame(&empty_state()).is_empty(),
"no StyleSpans may be emitted before the frontend declares a viewport"
"nothing may be emitted before the frontend declares a viewport"
);
}
#[test]
fn after_viewport_emits_style_spans_message_then_suppresses() {
fn first_post_viewport_frame_ships_styles_and_decorations_then_suppresses() {
let state = empty_state();
let mut s = SemanticRenderState::new();
// Pick whatever buffer the fresh editor's active window holds.
let buffer_id = {
let core = state.core.borrow();
core.active_window().buffer_id
};
let mut s = local();
let buffer_id = active_buffer(&state);
s.set_viewport(buffer_id, ByteRange { start: 0, end: 4096 }, 0);
// Empty scratch buffer: no syntax spans, no selection, no
// diagnostics — but the first frame ships both messages once
// (each clears any prior frontend state), carrying empty sets.
let first = s.render_frame(&state);
assert_eq!(first.len(), 1, "first post-viewport frame emits once");
match &first[0] {
InstanceMessage::StyleSpans {
buffer_id: b,
generation,
..
} => {
assert_eq!(*b, buffer_id);
assert_eq!(*generation, 0, "fresh scratch buffer has generation 0");
}
other => panic!("expected StyleSpans, got {other:?}"),
}
assert_eq!(first.len(), 2, "first frame ships StyleSpans + Decorations");
assert_semantic_only(&first);
let has = |pred: fn(&InstanceMessage) -> bool| first.iter().any(pred);
assert!(has(|m| matches!(m, InstanceMessage::StyleSpans { generation: 0, .. })));
assert!(has(|m| matches!(m, InstanceMessage::Decorations { .. })));
// Nothing changed → byte-identical frame is suppressed.
// Nothing changed → both families suppressed.
assert!(
s.render_frame(&state).is_empty(),
"an unchanged frame must be suppressed"
"an unchanged frame must be fully suppressed"
);
}
#[test]
fn viewport_with_zero_width_range_yields_empty_span_set() {
fn selection_projects_as_a_decoration_clipped_to_viewport() {
let state = empty_state();
let mut s = SemanticRenderState::new();
let buffer_id = {
let core = state.core.borrow();
core.active_window().buffer_id
};
// Degenerate viewport: end <= start after clamping.
s.set_viewport(buffer_id, ByteRange { start: 10, end: 10 }, 0);
let msgs = s.render_frame(&state);
// First frame still ships once (clears any prior styling),
// carrying an empty span set.
assert_eq!(msgs.len(), 1);
match &msgs[0] {
InstanceMessage::StyleSpans { spans, .. } => assert!(spans.is_empty()),
other => panic!("expected StyleSpans, got {other:?}"),
let buffer_id = active_buffer(&state);
// Put a selection on LOCAL's active window: anchor 2, cursor
// 5 → region (2, 5). region() compares offsets only, so the
// empty scratch buffer is fine for this projection test.
{
let mut core = state.core.borrow_mut();
let win = core
.active_window_mut_for(FrontendId::LOCAL)
.expect("LOCAL always has a window");
win.selection = Some(crate::window::Selection { anchor: 2 });
win.cursor = 5;
}
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 3, end: 64 }, 0);
let msgs = s.render_frame(&state);
assert_semantic_only(&msgs);
let deco = msgs
.iter()
.find_map(|m| match m {
InstanceMessage::Decorations { decorations, .. } => Some(decorations),
_ => None,
})
.expect("a Decorations message");
assert_eq!(deco.len(), 1, "exactly the selection decoration");
assert_eq!(deco[0].kind, DecorationKind::Selection);
// region (2,5) clipped to viewport [3,64) → [3,5).
assert_eq!(deco[0].range, ByteRange { start: 3, end: 5 });
}
#[test]
fn semantic_state_default_constructs() {
// The dispatcher relies on `Default`/`new` parity.
let _ = SemanticRenderState::default();
let _ = SemanticRenderState::new();
fn diagnostics_project_with_line_col_to_byte_and_severity() {
// Buffer with two short lines so line/col → byte is exercised.
// "abc\nde" → line 0 starts at byte 0, line 1 at byte 4.
let state = empty_state();
let buffer_id = active_buffer(&state);
{
let mut core = state.core.borrow_mut();
let reg = core.registry.clone();
reg.borrow_mut()
.get_mut(buffer_id)
.expect("active buffer")
.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"abc\nde",
})
.expect("seed buffer text");
// The diag store is keyed by file URI; point the editor's
// active file path at one and seed a diagnostic there.
core.file_path = Some(std::path::PathBuf::from("/tmp/m113.rs"));
}
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m113.rs"));
{
let store = state.lsp_manager.borrow().diag_store();
let mut g = store.lock().expect("diag store");
g.set(
&uri,
vec![crate::diag::Diagnostic {
start_line: 1,
start_col: 0,
end_line: 1,
end_col: 2,
severity: crate::diag::DiagnosticSeverity::Warning,
message: "x".into(),
source: None,
code: None,
}],
);
}
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let msgs = s.render_frame(&state);
let deco = msgs
.iter()
.find_map(|m| match m {
InstanceMessage::Decorations { decorations, .. } => Some(decorations),
_ => None,
})
.expect("a Decorations message");
assert_eq!(deco.len(), 1);
assert_eq!(deco[0].kind, DecorationKind::DiagnosticWarning);
// line 1 starts at byte 4; cols [0,2) → bytes [4,6).
assert_eq!(deco[0].range, ByteRange { start: 4, end: 6 });
}
#[test]
fn styles_and_decorations_suppress_independently() {
// A selection change must re-send Decorations without forcing
// a StyleSpans re-send (and the empty scratch styling stays
// suppressed).
let state = empty_state();
let buffer_id = active_buffer(&state);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let _ = s.render_frame(&state); // first frame: both shipped
assert!(s.render_frame(&state).is_empty(), "steady state silent");
// Introduce a selection → only Decorations should re-emit.
{
let mut core = state.core.borrow_mut();
let win = core
.active_window_mut_for(FrontendId::LOCAL)
.expect("LOCAL window");
win.selection = Some(crate::window::Selection { anchor: 1 });
win.cursor = 4;
}
let msgs = s.render_frame(&state);
assert_eq!(msgs.len(), 1, "only the changed family re-emits");
assert!(matches!(msgs[0], InstanceMessage::Decorations { .. }));
}
#[test]
fn adornment_and_fold_families_are_never_emitted() {
// M11.3 honest-stub contract: InlineAdornments / BlockAdornments
// / FoldState have no instance-side source yet, so the
// projection never produces them (not even empty ones).
let state = empty_state();
let buffer_id = active_buffer(&state);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
for _ in 0..3 {
for m in s.render_frame(&state) {
assert!(
!matches!(
m,
InstanceMessage::InlineAdornments { .. }
| InstanceMessage::BlockAdornments { .. }
| InstanceMessage::FoldState { .. }
),
"a not-yet-wired adornment/fold family was emitted: {m:?}"
);
}
}
}
#[test]
fn sibling_of_render_state_reads_same_editor_state() {
// Documents the M11.2 contract: a grid RenderState and a
// SemanticRenderState observe the same EditorState without
// interfering — the dispatcher selects the projection per
// session, not per buffer.
// The dispatcher selects the projection per session, not per
// buffer: a grid RenderState and a SemanticRenderState observe
// the same EditorState without interfering.
let state = empty_state();
let mut grid = RenderState::new(CellSize::new(24, 80));
let mut sem = SemanticRenderState::new();
let buffer_id = {
let core = state.core.borrow();
core.active_window().buffer_id
};
let mut sem = local();
let buffer_id = active_buffer(&state);
sem.set_viewport(buffer_id, ByteRange { start: 0, end: 80 }, 0);
let grid_msgs = grid.render_frame(&state, &[]);
@ -302,12 +611,12 @@ mod tests {
matches!(grid_msgs[0], InstanceMessage::CellDelta { .. }),
"grid projection still produces CellDelta"
);
assert_semantic_only(&sem_msgs);
assert!(
sem_msgs
!sem_msgs
.iter()
.all(|m| matches!(m, InstanceMessage::StyleSpans { .. })),
"semantic projection produces only StyleSpans, never CellDelta"
.any(|m| matches!(m, InstanceMessage::CellDelta { .. })),
"semantic projection never produces CellDelta"
);
let _ = FrontendId::LOCAL; // import anchor for future fid-scoped tests
}
}