903 lines
35 KiB
Rust
903 lines
35 KiB
Rust
// 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<T> {
|
|
visible: ByteRange,
|
|
items: Vec<T>,
|
|
}
|
|
|
|
/// 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<DeclaredViewport>,
|
|
/// 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<BufferId, LastFrame<StyleSpan>>,
|
|
/// 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<BufferId, LastFrame<Decoration>>,
|
|
}
|
|
|
|
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<InstanceMessage> {
|
|
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<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.active_buffer_path() {
|
|
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 })
|
|
}
|
|
|
|
/// 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<T: PartialEq>(
|
|
prev: &[T],
|
|
curr: &[T],
|
|
range_of: impl Fn(&T) -> ByteRange,
|
|
) -> Vec<ByteRange> {
|
|
let mut changed: Vec<ByteRange> = 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<ByteRange>) -> Vec<ByteRange> {
|
|
ranges.retain(|r| r.end > r.start);
|
|
ranges.sort_by_key(|r| (r.start, r.end));
|
|
let mut out: Vec<ByteRange> = 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<StyleSpan> {
|
|
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<Decoration> {
|
|
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<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,
|
|
/// 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<StyleSpan> {
|
|
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<Decoration>)> {
|
|
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<ByteRange>)> {
|
|
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.set_buffer_path(buffer_id, 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<ByteRange> = 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"
|
|
);
|
|
}
|
|
}
|