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