2076 lines
80 KiB
Rust
2076 lines
80 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, mapped through the active [`crate::highlight::Theme`],
|
||
//! without the grid-packing step. Styling has one authority per
|
||
//! language (policy A): tree-sitter spans from [`crate::syntax`] for
|
||
//! grammar-backed languages, LSP semantic tokens
|
||
//! ([`crate::lsp::LspManager::semantic_style_context`]) for languages
|
||
//! with no bundled grammar (C/C++, …). 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.
|
||
//!
|
||
//! Produced families: `StyleSpans` (M11.2; dual authority per above)
|
||
//! and `Decorations` (M11.3), both span-granularity diffed (M11.4);
|
||
//! `InlineAdornments` (Step 3, from the LSP inlay-hint store,
|
||
//! M11.2-level suppression); `FileStyleSummary` (resolving Open Q#2 —
|
||
//! per-line dominant style for a minimap, generation-keyed).
|
||
//! `BlockAdornments` / `FoldState` / `ResourceOffer` remain wire-
|
||
//! declared but unproduced.
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use crate::buffer::BufferId;
|
||
use crate::cell::Style;
|
||
use crate::editor::EditorState;
|
||
use crate::protocol::{
|
||
AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment,
|
||
FrontendId, InlineAdornment, 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, the full
|
||
/// scoped item set last shipped, and the CRDT generation that set
|
||
/// was computed against. The next frame diffs against `items`;
|
||
/// `visible` changing (or no entry) forces a `full` resync, and
|
||
/// `generation` changing also forces a `full` — see T M11.7.
|
||
///
|
||
/// **T M11.7 — `generation`-tracked full-resync.** Without this,
|
||
/// edits broke the consumer's incremental-update contract:
|
||
/// `changed_intervals` only ships dirty-range items on `full=false`
|
||
/// frames, but after a text-shift the frontend's cached spans
|
||
/// (indexed by *pre-edit* byte positions) need to be replaced
|
||
/// wholesale — the post-edit positions have shifted under them.
|
||
/// Forcing `full=true` on every generation transition makes the
|
||
/// next emission a `replace_*` on the frontend side, which is the
|
||
/// correct behavior. Cost: one extra full-viewport ship per edit;
|
||
/// negligible on a local Unix socket and bounded by the viewport
|
||
/// size.
|
||
struct LastFrame<T> {
|
||
visible: ByteRange,
|
||
items: Vec<T>,
|
||
generation: u64,
|
||
}
|
||
|
||
/// 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>>,
|
||
/// `InlineAdornments` baseline (T M11 producer arc, Step 3). The
|
||
/// wire variant carries no `generation`/`full`/`segments`, so
|
||
/// unlike the two families above this is only M11.2-level
|
||
/// suppression: a whole-set re-send on any change, nothing when
|
||
/// byte-identical. `LastFrame::items` reuse keeps the shape
|
||
/// uniform even though no segment diffing applies.
|
||
last_adornments: HashMap<BufferId, LastFrame<InlineAdornment>>,
|
||
/// `FileStyleSummary` baseline (post-M11 minimap producer,
|
||
/// resolving design-note Open Q#2). The whole-file dominant-style
|
||
/// summary is expensive to compute on a 100k-line file, so the
|
||
/// producer short-circuits on the last sent CRDT generation: a
|
||
/// buffer at the same generation re-uses what the frontend
|
||
/// already has and emits nothing. First emission happens on the
|
||
/// first frame for a buffer; further emissions only after edits.
|
||
last_summary: HashMap<BufferId, u64>,
|
||
}
|
||
|
||
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(),
|
||
last_adornments: HashMap::new(),
|
||
last_summary: 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 three messages — [`InstanceMessage::StyleSpans`]
|
||
/// (T M11.2), [`InstanceMessage::Decorations`] (T M11.3), and
|
||
/// [`InstanceMessage::InlineAdornments`] (Step 3, from the LSP
|
||
/// inlay-hint store) — each scoped to the declared viewport and
|
||
/// each suppressed independently when byte-identical to its last
|
||
/// send. Returns an empty vec before the frontend declares a
|
||
/// viewport.
|
||
///
|
||
/// `BlockAdornments` / `FoldState` are still deliberately *not*
|
||
/// produced: pmacs has no instance-side blame / lens / fold / diff
|
||
/// source yet. Their wire variants exist (T M11.1); their
|
||
/// producers wire in when those features land — the same
|
||
/// "declared, not yet wired" discipline. Emitting an empty message
|
||
/// every frame would be waste, not honesty, so `InlineAdornments` is
|
||
/// suppressed both when unchanged and when there is simply nothing
|
||
/// to say (no hints, no prior non-empty send).
|
||
#[allow(clippy::too_many_lines)]
|
||
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, the declared viewport
|
||
// region moved (scoping window changed), OR the CRDT
|
||
// generation advanced (T M11.7: text edits shift byte
|
||
// positions, so prior spans are stale and incremental
|
||
// updates can't restore the full viewport — see
|
||
// `LastFrame`'s doc comment).
|
||
let full = prev.is_none_or(|p| p.visible != vp.visible || p.generation != generation);
|
||
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(),
|
||
generation,
|
||
},
|
||
);
|
||
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,
|
||
generation,
|
||
},
|
||
);
|
||
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 || p.generation != generation);
|
||
if full {
|
||
self.last_decorations.insert(
|
||
vp.buffer_id,
|
||
LastFrame {
|
||
visible: vp.visible,
|
||
items: decorations.clone(),
|
||
generation,
|
||
},
|
||
);
|
||
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,
|
||
generation,
|
||
},
|
||
);
|
||
out.push(InstanceMessage::Decorations {
|
||
buffer_id: vp.buffer_id,
|
||
generation,
|
||
full: false,
|
||
segments,
|
||
});
|
||
}
|
||
}
|
||
|
||
// --- InlineAdornments (Step 3 producer) ---
|
||
out.extend(self.inline_adornments_msg(state, &vp));
|
||
// --- FileStyleSummary (minimap producer; Open Q#2) ---
|
||
out.extend(self.file_style_summary_msg(state, vp.buffer_id, generation));
|
||
out
|
||
}
|
||
|
||
/// The `InlineAdornments` message for this frame, or `None` when
|
||
/// nothing should be sent. The wire variant has no
|
||
/// `generation`/`full`/`segments`, so this is M11.2-level
|
||
/// suppression only: the whole scoped set re-sends on any change,
|
||
/// nothing when byte-identical, and never an empty frame when
|
||
/// there is simply nothing to say. Updates the baseline on send.
|
||
fn inline_adornments_msg(
|
||
&mut self,
|
||
state: &EditorState,
|
||
vp: &DeclaredViewport,
|
||
) -> Option<InstanceMessage> {
|
||
let adornments = scoped_inline_adornments(state, vp);
|
||
let should_emit = match self.last_adornments.get(&vp.buffer_id) {
|
||
// First sight of this buffer: speak only if there is
|
||
// something to show — no empty-frame spam.
|
||
None => !adornments.is_empty(),
|
||
// Re-send on a real change. `empty → empty` conveys
|
||
// nothing and is suppressed; `non-empty → empty` *is* a
|
||
// change worth sending so the frontend clears its overlay.
|
||
Some(p) => {
|
||
(p.visible != vp.visible || p.items != adornments)
|
||
&& !(adornments.is_empty() && p.items.is_empty())
|
||
}
|
||
};
|
||
if !should_emit {
|
||
return None;
|
||
}
|
||
// Adornments use the same `LastFrame` struct as
|
||
// StyleSpans / Decorations, so we populate `generation` for
|
||
// consistency. Adornments' diff predicate doesn't consult
|
||
// it — the whole-set comparison at line 310 catches post-
|
||
// edit position shifts directly — but tracking it keeps
|
||
// the struct shape uniform.
|
||
let generation = buffer_generation(state, vp.buffer_id);
|
||
self.last_adornments.insert(
|
||
vp.buffer_id,
|
||
LastFrame {
|
||
visible: vp.visible,
|
||
items: adornments.clone(),
|
||
generation,
|
||
},
|
||
);
|
||
Some(InstanceMessage::InlineAdornments {
|
||
buffer_id: vp.buffer_id,
|
||
items: adornments,
|
||
})
|
||
}
|
||
|
||
/// The `FileStyleSummary` message for this frame, or `None`. The
|
||
/// summary is keyed on CRDT `generation`: a buffer with an
|
||
/// unchanged generation re-uses the cached summary and emits
|
||
/// nothing. The first frame for a buffer always emits (the
|
||
/// frontend needs the baseline). Updates the baseline on send.
|
||
fn file_style_summary_msg(
|
||
&mut self,
|
||
state: &EditorState,
|
||
buffer_id: BufferId,
|
||
generation: u64,
|
||
) -> Option<InstanceMessage> {
|
||
if self.last_summary.get(&buffer_id).copied() == Some(generation) {
|
||
return None;
|
||
}
|
||
let lines = scoped_file_summary(state, buffer_id);
|
||
self.last_summary.insert(buffer_id, generation);
|
||
Some(InstanceMessage::FileStyleSummary {
|
||
buffer_id,
|
||
generation,
|
||
lines,
|
||
})
|
||
}
|
||
|
||
/// 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. The URI is derived
|
||
// from `vp.buffer_id` (see [`buffer_file_uri`]'s docs for why
|
||
// not from `core.active_buffer_path()`).
|
||
//
|
||
// T M11.8 — skip emission while the store is stale (the
|
||
// document has been edited since the last `publishDiagnostics`
|
||
// absorption). Without this, the producer ships diagnostics
|
||
// whose byte positions point at pre-edit text — the visible
|
||
// wrong-position color artifact session-5 validation surfaced.
|
||
// The LSP layer's `did_change_full` marks the URI stale; the
|
||
// next `publishDiagnostics` absorbs and clears the flag.
|
||
if let Some(uri) = buffer_file_uri(&core, vp.buffer_id) {
|
||
let (diags, is_stale) = {
|
||
let store = state.lsp_manager.borrow().diag_store();
|
||
let guard = store.lock().expect("diag store mutex poisoned");
|
||
(guard.for_uri(&uri).to_vec(), guard.is_stale(&uri))
|
||
};
|
||
if !is_stale && !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
|
||
}
|
||
}
|
||
|
||
/// Project the LSP inlay-hint set intersecting the declared viewport
|
||
/// into [`InlineAdornment`]s. Mirrors the diagnostics half of
|
||
/// `scoped_decorations`: same path → URI → store (`for_uri`) lookup.
|
||
/// Step 0 established inlay-hint columns are already pmacs byte
|
||
/// offsets by the time they reach the store (the absorb path's
|
||
/// `inbound_converted` rewrites the `Position`-shaped
|
||
/// `InlayHint.position`), so `line_col_to_byte` — which treats the
|
||
/// column as a byte offset — is exact here, no per-server encoding
|
||
/// needed (unlike semantic-token styling). Takes no `self`: the
|
||
/// session id is irrelevant (inlay hints are per-buffer, not
|
||
/// per-window like selection), mirroring `scoped_style_spans`.
|
||
///
|
||
/// Every hint is `AtOffset` (inlay hints are inline by definition)
|
||
/// carrying `Text` with the (padding-applied) label and the default
|
||
/// style — the instance has no inlay-specific theme face yet, and a
|
||
/// fabricated one would be dishonest. Stale store entries are
|
||
/// suppressed the same way stale diagnostics / semantic tokens are:
|
||
/// a zero-width hint anchored to pre-edit text is still a byte range
|
||
/// bug, even though it has no source-byte width of its own.
|
||
fn scoped_inline_adornments(state: &EditorState, vp: &DeclaredViewport) -> Vec<InlineAdornment> {
|
||
let core = state.core.borrow();
|
||
let Some(uri) = buffer_file_uri(&core, vp.buffer_id) else {
|
||
return Vec::new();
|
||
};
|
||
let hints = {
|
||
let store = state.lsp_manager.borrow().inlay_hint_store();
|
||
let guard = store.lock().expect("inlay-hint store mutex poisoned");
|
||
if guard.is_stale(&uri) {
|
||
return Vec::new();
|
||
}
|
||
match guard.for_uri(&uri) {
|
||
Some(resp) => resp.hints.clone(),
|
||
None => return Vec::new(),
|
||
}
|
||
};
|
||
if hints.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let registry = core.registry.clone();
|
||
let reg = registry.borrow();
|
||
let Ok(buf) = reg.get(vp.buffer_id) else {
|
||
return Vec::new();
|
||
};
|
||
let source = buffer_source_bytes(buf);
|
||
let source_len = source.len() as u64;
|
||
let line_starts = line_start_offsets(&source);
|
||
let vis_start = vp.visible.start.min(source_len);
|
||
let vis_end = vp.visible.end.min(source_len);
|
||
|
||
let mut out = Vec::new();
|
||
for h in &hints {
|
||
let at = line_col_to_byte(&line_starts, source_len, h.line, h.col);
|
||
// An inlay hint occupies no bytes; include it when its anchor
|
||
// lies within the declared viewport (half-open).
|
||
if at < vis_start || at >= vis_end {
|
||
continue;
|
||
}
|
||
let mut text = String::new();
|
||
if h.padding_left {
|
||
text.push(' ');
|
||
}
|
||
text.push_str(&h.label);
|
||
if h.padding_right {
|
||
text.push(' ');
|
||
}
|
||
out.push(InlineAdornment {
|
||
at,
|
||
placement: AdornmentPlacement::AtOffset,
|
||
content: AdornmentContent::Text {
|
||
text,
|
||
style: Style::default(),
|
||
},
|
||
});
|
||
}
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// Resolve `buffer_id` → `file://…` URI, using the same encoding the
|
||
/// Lua side uses in `file_uri_for` so the result is byte-identical to
|
||
/// the diag-store / inlay-store / semantic-token-store keys.
|
||
///
|
||
/// **Bug-fix anchor (post-session-5 finding):** the producer used to
|
||
/// derive this URI from `core.active_buffer_path()` — the *editor's*
|
||
/// active buffer, not the buffer the frame is projecting. In multi-
|
||
/// frontend setups (TUI + `pmacs-gpu`) those diverge: each frontend
|
||
/// has its own active window, and when the daemon renders frontend B's
|
||
/// frame it temporarily flips `active_frontend` to B but B's active
|
||
/// buffer may be a scratch buffer with no file path. The diag /
|
||
/// inlay / semantic-token lookups would then run against the wrong
|
||
/// URI (or `None`) and return empty, so frontend B got no
|
||
/// `Decorations` / `InlineAdornments` / LSP-driven `StyleSpans`.
|
||
/// Routing the URI through `vp.buffer_id` fixes the multi-frontend
|
||
/// case without disturbing the single-frontend one
|
||
/// (`active_buffer_id == vp.buffer_id` there, so the resolved path is
|
||
/// the same).
|
||
fn buffer_file_uri(core: &crate::editor_core::EditorCore, buffer_id: BufferId) -> Option<String> {
|
||
let reg = core.registry.borrow();
|
||
let buf = reg.get(buffer_id).ok()?;
|
||
let path = buf.file_path()?;
|
||
Some(crate::lsp::path_to_file_uri(path))
|
||
}
|
||
|
||
/// 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> {
|
||
// Policy A — per-language styling authority. A grammar-backed
|
||
// language (the registry hands out a view only for those) is
|
||
// styled *solely* by tree-sitter; a language with no bundled
|
||
// grammar (C/C++, …) is styled *solely* by LSP semantic tokens.
|
||
// Never both: this is why the no-view branch hands off to the LSP
|
||
// producer while a grammar-backed buffer whose parse isn't ready
|
||
// yet returns empty rather than briefly borrowing LSP styling
|
||
// (which would flicker two authorities on one buffer).
|
||
let Some(handle) = state.syntax_registry.view(vp.buffer_id) else {
|
||
return lsp_scoped_style_spans(state, vp);
|
||
};
|
||
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
|
||
}
|
||
|
||
/// Policy-A fallback for languages with no bundled tree-sitter
|
||
/// grammar (C/C++, …): project the LSP semantic-token store into the
|
||
/// same `StyleSpan` shape the tree-sitter path emits, so the existing
|
||
/// M11.4 diff pipeline (`render_frame`) consumes it unchanged and the
|
||
/// frontend never learns which producer fed it. The instance stays
|
||
/// the single styling authority.
|
||
///
|
||
/// `SemanticToken` `start`/`length` are LSP encoding units (UTF-16
|
||
/// for clangd's default) and — unlike inlay hints — are *not*
|
||
/// byte-rewritten upstream, so this converts them per line via the
|
||
/// owning server's negotiated encoding
|
||
/// ([`crate::lsp::LspManager::semantic_style_context`]). Tokens are
|
||
/// single-line by the LSP grammar, so per-line conversion is exact.
|
||
fn lsp_scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec<StyleSpan> {
|
||
let core = state.core.borrow();
|
||
let Some(uri) = buffer_file_uri(&core, vp.buffer_id) else {
|
||
return Vec::new();
|
||
};
|
||
|
||
// Styling context (encoding + legend) and the token set resolve
|
||
// the *same* server for `uri` (both via `for_uri`'s lowest-id
|
||
// rule), so they describe one coherent source.
|
||
let mgr = state.lsp_manager.borrow();
|
||
let Some(ctx) = mgr.semantic_style_context(&uri) else {
|
||
return Vec::new();
|
||
};
|
||
let tokens = {
|
||
let store = mgr.semantic_token_store();
|
||
let guard = store.lock().expect("semantic-token store mutex poisoned");
|
||
if guard.is_stale(&uri) {
|
||
return Vec::new();
|
||
}
|
||
match guard.for_uri(&uri) {
|
||
Some((_, resp)) => resp.tokens.clone(),
|
||
None => return Vec::new(),
|
||
}
|
||
};
|
||
drop(mgr);
|
||
|
||
let registry = core.registry.clone();
|
||
let reg = registry.borrow();
|
||
let Ok(buf) = reg.get(vp.buffer_id) else {
|
||
return Vec::new();
|
||
};
|
||
let source = buffer_source_bytes(buf);
|
||
let source_len = 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 line_starts = line_start_offsets(&source);
|
||
|
||
let theme = state
|
||
.syntax_registry
|
||
.theme()
|
||
.lock()
|
||
.expect("theme mutex poisoned")
|
||
.clone();
|
||
|
||
let mut out = Vec::new();
|
||
for t in &tokens {
|
||
let li = t.line as usize;
|
||
let Some(&ls) = line_starts.get(li) else {
|
||
continue; // Token line past EOF (stale response) — skip.
|
||
};
|
||
let le = line_starts
|
||
.get(li + 1)
|
||
.map_or(source_len, |&n| n.saturating_sub(1));
|
||
let Ok(line_text) = std::str::from_utf8(&source[ls as usize..le as usize]) else {
|
||
continue; // Non-UTF-8 line — cannot do encoded conversion.
|
||
};
|
||
let start_b = ls + crate::lsp::char_to_byte(line_text, t.start, ctx.encoding) as u64;
|
||
let end_char = t.start.saturating_add(t.length);
|
||
let end_b = ls + crate::lsp::char_to_byte(line_text, end_char, ctx.encoding) as u64;
|
||
let s = start_b.max(vis_start);
|
||
let e = end_b.min(vis_end);
|
||
if e <= s {
|
||
continue; // Empty, or no overlap with the viewport.
|
||
}
|
||
let Some(name) = ctx
|
||
.legend
|
||
.as_ref()
|
||
.and_then(|lg| lg.type_name(t.token_type))
|
||
else {
|
||
continue; // No legend / unknown type ⇒ cannot name a style.
|
||
};
|
||
let style = theme.lookup(name);
|
||
if style == Style::default() {
|
||
continue; // Nothing to render — skip the wire byte (parity
|
||
// with the tree-sitter path's default-style drop).
|
||
}
|
||
out.push(StyleSpan {
|
||
range: ByteRange { start: s, end: e },
|
||
style,
|
||
});
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Compute the per-line dominant style summary for the whole buffer:
|
||
/// one [`Style`] per source line, in line order. The "dominant" style
|
||
/// for a line is the one covering the most bytes among the styled
|
||
/// runs (`scoped_style_spans` for the full buffer); a line with no
|
||
/// styled runs takes [`Style::default`]. Reuses [`scoped_style_spans`]
|
||
/// so the policy-A authority choice (tree-sitter for grammar-backed
|
||
/// languages, LSP semantic tokens otherwise) is inherited automatically.
|
||
///
|
||
/// `O(spans × lines)` in the worst case; the caller short-circuits on
|
||
/// unchanged CRDT generation so this only runs on first sight of a
|
||
/// buffer or after an edit, not per frame.
|
||
fn scoped_file_summary(state: &EditorState, buffer_id: BufferId) -> Vec<Style> {
|
||
let source = {
|
||
let core = state.core.borrow();
|
||
let registry = core.registry.clone();
|
||
let reg = registry.borrow();
|
||
match reg.get(buffer_id) {
|
||
Ok(buf) => buffer_source_bytes(buf),
|
||
Err(_) => return Vec::new(),
|
||
}
|
||
};
|
||
let source_len = source.len() as u64;
|
||
let line_starts = line_start_offsets(&source);
|
||
let line_count = line_starts.len();
|
||
let mut out = vec![Style::default(); line_count];
|
||
|
||
// Reuse the existing producer with a whole-buffer "viewport". The
|
||
// clip is then a no-op and `scoped_style_spans` yields every
|
||
// styled run; policy A's authority pick (tree-sitter / LSP) is
|
||
// therefore identical to the per-frame styling.
|
||
let vp_all = DeclaredViewport {
|
||
buffer_id,
|
||
visible: ByteRange {
|
||
start: 0,
|
||
end: source_len,
|
||
},
|
||
frontend_generation: 0,
|
||
};
|
||
let spans = scoped_style_spans(state, &vp_all);
|
||
if spans.is_empty() {
|
||
return out;
|
||
}
|
||
|
||
// Per-line dominant style: tally bytes per style and pick the
|
||
// winner. `Style` doesn't implement `Hash`, so a small linear-scan
|
||
// tally is fine — the number of distinct styles per line is
|
||
// bounded by the theme's vocabulary (single digits in practice).
|
||
for (li, line_dominant) in out.iter_mut().enumerate() {
|
||
let line_start = line_starts[li];
|
||
// Same trailing-newline semantics as `line_col_to_byte`: the
|
||
// line's byte range excludes the `\n` (`next - 1`), and the
|
||
// trailing line (no next entry) runs to EOF.
|
||
let line_end = line_starts
|
||
.get(li + 1)
|
||
.map_or(source_len, |&next| next.saturating_sub(1));
|
||
let mut tally: Vec<(Style, u64)> = Vec::new();
|
||
for sp in &spans {
|
||
let lo = sp.range.start.max(line_start);
|
||
let hi = sp.range.end.min(line_end);
|
||
if hi > lo {
|
||
let bytes = hi - lo;
|
||
if let Some(entry) = tally.iter_mut().find(|(s, _)| *s == sp.style) {
|
||
entry.1 += bytes;
|
||
} else {
|
||
tally.push((sp.style, bytes));
|
||
}
|
||
}
|
||
}
|
||
if let Some(winner) = tally.into_iter().max_by_key(|(_, c)| *c) {
|
||
*line_dominant = winner.0;
|
||
}
|
||
}
|
||
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`, `Decorations`, `InlineAdornments`, or
|
||
/// `FileStyleSummary` — never `CellDelta`, grid `Cursor`, or the
|
||
/// still-unwired `BlockAdornments` / `FoldState` families.
|
||
fn assert_semantic_only(msgs: &[InstanceMessage]) {
|
||
for m in msgs {
|
||
assert!(
|
||
matches!(
|
||
m,
|
||
InstanceMessage::StyleSpans { .. }
|
||
| InstanceMessage::Decorations { .. }
|
||
| InstanceMessage::InlineAdornments { .. }
|
||
| InstanceMessage::FileStyleSummary { .. }
|
||
),
|
||
"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 diffable families
|
||
// (the frontend clears its viewport), carrying empty segments.
|
||
// FileStyleSummary also emits on the first frame for this buffer
|
||
// (post-M11 minimap producer, generation-keyed).
|
||
let first = s.render_frame(&state);
|
||
assert_eq!(
|
||
first.len(),
|
||
3,
|
||
"first frame ships StyleSpans + Decorations + FileStyleSummary"
|
||
);
|
||
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 });
|
||
}
|
||
|
||
/// T M11.8 regression: when the diag store's entry for the URI
|
||
/// is marked stale (an edit has been issued since the last
|
||
/// `publishDiagnostics`), the producer must suppress diagnostic
|
||
/// emission so the frontend doesn't paint colors at pre-edit
|
||
/// byte positions over post-edit text. Closes the bet-#1
|
||
/// surface that session-5 validation exposed.
|
||
#[test]
|
||
fn diagnostics_suppressed_while_diag_store_stale() {
|
||
let state = empty_state();
|
||
let buffer_id = active_buffer(&state);
|
||
seed_diagnostic(&state, buffer_id);
|
||
|
||
// Mark the diag store stale for the seeded URI. The producer
|
||
// should now emit zero diagnostic decorations.
|
||
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m114.rs"));
|
||
state
|
||
.lsp_manager
|
||
.borrow()
|
||
.diag_store()
|
||
.lock()
|
||
.expect("diag store")
|
||
.mark_stale(uri.clone());
|
||
|
||
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!(
|
||
decos.iter().all(|d| !matches!(
|
||
d.kind,
|
||
DecorationKind::DiagnosticError
|
||
| DecorationKind::DiagnosticWarning
|
||
| DecorationKind::DiagnosticInfo
|
||
| DecorationKind::DiagnosticHint
|
||
)),
|
||
"stale diag store ⇒ no diagnostic decorations emitted; got {decos:?}"
|
||
);
|
||
|
||
// Once a fresh `set` clears the stale flag, the decoration
|
||
// re-appears on the next render frame.
|
||
state
|
||
.lsp_manager
|
||
.borrow()
|
||
.diag_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,
|
||
}],
|
||
);
|
||
|
||
let (_full, decos) =
|
||
decorations_of(&s.render_frame(&state)).expect("a Decorations message");
|
||
assert!(
|
||
decos
|
||
.iter()
|
||
.any(|d| d.kind == DecorationKind::DiagnosticWarning),
|
||
"fresh set ⇒ stale flag cleared ⇒ decoration re-emitted; got {decos:?}"
|
||
);
|
||
}
|
||
|
||
/// Regression: in a multi-frontend setup the editor's *active*
|
||
/// buffer (set by `core.active_buffer_id()`, derived from the
|
||
/// active frontend's view) can differ from the buffer a given
|
||
/// frontend's `SemanticRenderState` is projecting. The producer
|
||
/// used to derive the diagnostic URI from `active_buffer_path()`,
|
||
/// which returned the wrong path (or `None`) in that case and
|
||
/// emitted empty decorations — the session-5 manual-validation
|
||
/// finding. Fix routes the URI lookup through `vp.buffer_id` via
|
||
/// `buffer_file_uri`.
|
||
#[test]
|
||
fn decorations_use_vp_buffer_not_active_buffer() {
|
||
let state = empty_state();
|
||
// The default LOCAL frontend's active buffer is a scratch
|
||
// with no file path. We seed text + a file path on a buffer
|
||
// that is NOT the active one, then project that buffer via
|
||
// a separate `SemanticRenderState`. Pre-fix: the producer
|
||
// looks up `active_buffer_path()` (the scratch, no path) and
|
||
// gets `None`, emitting empty decorations. Post-fix: it
|
||
// resolves the URI from `vp.buffer_id`, finds the diagnostic.
|
||
let scratch_id = active_buffer(&state);
|
||
|
||
// Create a second buffer with a real file path + a diagnostic.
|
||
let file_id = {
|
||
let core = state.core.borrow();
|
||
core.registry
|
||
.borrow_mut()
|
||
.create_from_bytes("secondary".to_owned(), b"abc\nde")
|
||
};
|
||
{
|
||
let mut core = state.core.borrow_mut();
|
||
core.set_buffer_path(file_id, Some(std::path::PathBuf::from("/tmp/multi-fid.rs")));
|
||
}
|
||
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/multi-fid.rs"));
|
||
state
|
||
.lsp_manager
|
||
.borrow()
|
||
.diag_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::Error,
|
||
message: "boom".into(),
|
||
source: None,
|
||
code: None,
|
||
}],
|
||
);
|
||
|
||
// Sanity: active buffer is still the scratch, not `file_id`.
|
||
assert_eq!(scratch_id, active_buffer(&state));
|
||
assert_ne!(scratch_id, file_id);
|
||
|
||
// Project the file buffer through the semantic renderer.
|
||
let mut s = local();
|
||
s.set_viewport(file_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,
|
||
"decoration must surface for the projected buffer even when it's not the editor's active buffer"
|
||
);
|
||
assert_eq!(decos[0].kind, DecorationKind::DiagnosticError);
|
||
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"
|
||
);
|
||
}
|
||
|
||
/// T M11.7 regression: a CRDT generation transition forces a
|
||
/// `full=true` resync on the next frame for both `StyleSpans`
|
||
/// and `Decorations`, even when the declared viewport is
|
||
/// unchanged. Without this, an edit at byte position N would
|
||
/// leave the frontend with stale spans/decorations indexed at
|
||
/// pre-edit byte positions while only a small dirty-range
|
||
/// increment ships — which violates the incremental contract
|
||
/// (the increment expects the frontend to retain non-dirty
|
||
/// items). The bet-#1 surface for `pmacs-gpu`.
|
||
#[cfg(feature = "crdt")]
|
||
#[test]
|
||
fn full_resync_on_generation_transition() {
|
||
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); // initial full
|
||
assert!(
|
||
s.render_frame(&state).is_empty(),
|
||
"unchanged frame → silent"
|
||
);
|
||
|
||
// Upgrade the buffer to CRDT-backed so `buffer_generation`
|
||
// tracks edits — without this, version_scalar stays 0 for a
|
||
// v0.1-style buffer and the producer can't detect transitions.
|
||
// Then bump the buffer's generation by editing it.
|
||
{
|
||
let core = state.core.borrow();
|
||
let mut reg = core.registry.borrow_mut();
|
||
let buf = reg.get_mut(buffer_id).expect("active buffer");
|
||
buf.upgrade_to_crdt(1).expect("upgrade to crdt");
|
||
buf.apply_edit(crate::buffer::EditOp::Insert {
|
||
pos: 0,
|
||
bytes: b"x",
|
||
})
|
||
.expect("buffer edit");
|
||
}
|
||
|
||
// Generation transitioned → next frame must be full for both
|
||
// diff-shaped families.
|
||
let msgs = s.render_frame(&state);
|
||
let (style_full, _) = style_segments(&msgs).expect("StyleSpans re-emitted");
|
||
let (deco_full, _) = decorations_of(&msgs).expect("Decorations re-emitted");
|
||
assert!(
|
||
style_full,
|
||
"post-edit StyleSpans must be full=true (positions shifted)"
|
||
);
|
||
assert!(
|
||
deco_full,
|
||
"post-edit Decorations must be full=true (positions shifted)"
|
||
);
|
||
}
|
||
|
||
#[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 block_adornments_and_fold_state_still_never_emitted() {
|
||
// BlockAdornments / FoldState have no instance-side source
|
||
// yet, so the projection never produces them (not even empty).
|
||
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::BlockAdornments { .. } | InstanceMessage::FoldState { .. }
|
||
),
|
||
"a still-unwired block/fold family was emitted: {m:?}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn inline_adornments_not_emitted_without_hints() {
|
||
// Step 3: InlineAdornments IS wired, but a buffer with no LSP
|
||
// inlay-hint store entry must still never emit an (empty)
|
||
// InlineAdornments frame — no empty-frame spam.
|
||
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 {
|
||
assert!(
|
||
!s.render_frame(&state)
|
||
.iter()
|
||
.any(|m| matches!(m, InstanceMessage::InlineAdornments { .. })),
|
||
"no inlay hints ⇒ no InlineAdornments message"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[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"
|
||
);
|
||
}
|
||
|
||
// --- Step 2: LSP-semantic-token styling authority (policy A) ---
|
||
|
||
fn tok(line: u32, start: u32, length: u32) -> crate::semantic_tokens::SemanticToken {
|
||
crate::semantic_tokens::SemanticToken {
|
||
line,
|
||
start,
|
||
length,
|
||
token_type: 0, // index 0 in the seeded legend → "kw"
|
||
token_modifiers: 0,
|
||
}
|
||
}
|
||
|
||
/// Overwrite the semantic-token store entry for the test client
|
||
/// `sid` on the `/tmp/x.cpp` URI.
|
||
fn set_tokens(
|
||
state: &EditorState,
|
||
sid: crate::lsp::LspServerId,
|
||
tokens: Vec<crate::semantic_tokens::SemanticToken>,
|
||
) {
|
||
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/x.cpp"));
|
||
let store = state.lsp_manager.borrow().semantic_token_store();
|
||
store.lock().expect("sem token store").set(
|
||
crate::semantic_tokens::SemanticTokenKey::new(sid.raw().to_string(), uri),
|
||
crate::semantic_tokens::SemanticTokensResponse {
|
||
tokens,
|
||
result_id: None,
|
||
raw: Vec::new(),
|
||
},
|
||
);
|
||
}
|
||
|
||
/// Seed a grammar-less (`.cpp`) buffer plus an Initialized test
|
||
/// LSP client advertising a one-entry legend (`["kw"]`, UTF-16),
|
||
/// a theme face for `kw`, and `tokens` in the store. Returns the
|
||
/// client id so a test can re-seed the same `(server, uri)`.
|
||
fn seed_lsp_style(
|
||
state: &EditorState,
|
||
buffer_id: BufferId,
|
||
text: &[u8],
|
||
tokens: Vec<crate::semantic_tokens::SemanticToken>,
|
||
) -> crate::lsp::LspServerId {
|
||
{
|
||
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: text,
|
||
})
|
||
.expect("seed buffer text");
|
||
// `.cpp` has no bundled tree-sitter grammar → the registry
|
||
// yields no view → policy A routes to the LSP producer.
|
||
core.set_buffer_path(buffer_id, Some(std::path::PathBuf::from("/tmp/x.cpp")));
|
||
}
|
||
state.syntax_registry.theme().lock().expect("theme").insert(
|
||
"kw",
|
||
crate::cell::Style {
|
||
bold: true,
|
||
..crate::cell::Style::default()
|
||
},
|
||
);
|
||
let sid = state
|
||
.lsp_manager
|
||
.borrow_mut()
|
||
.insert_initialized_test_client(
|
||
serde_json::json!({
|
||
"semanticTokensProvider": {
|
||
"legend": { "tokenTypes": ["kw"], "tokenModifiers": [] }
|
||
}
|
||
}),
|
||
crate::lsp::PositionEncoding::Utf16,
|
||
);
|
||
set_tokens(state, sid, tokens);
|
||
sid
|
||
}
|
||
|
||
#[test]
|
||
fn cpp_style_comes_from_lsp_when_no_tree_sitter_grammar() {
|
||
let state = empty_state();
|
||
let mut s = local();
|
||
let bid = active_buffer(&state);
|
||
// "int" on line 0, bytes [0,3).
|
||
seed_lsp_style(&state, bid, b"int x;\n", vec![tok(0, 0, 3)]);
|
||
s.set_viewport(
|
||
bid,
|
||
ByteRange {
|
||
start: 0,
|
||
end: 4096,
|
||
},
|
||
0,
|
||
);
|
||
|
||
let msgs = s.render_frame(&state);
|
||
assert_semantic_only(&msgs);
|
||
let spans = msgs
|
||
.iter()
|
||
.find_map(|m| match m {
|
||
InstanceMessage::StyleSpans { full, segments, .. } => {
|
||
assert!(*full, "first frame is a full resync");
|
||
Some(
|
||
segments
|
||
.iter()
|
||
.flat_map(|seg| seg.spans.clone())
|
||
.collect::<Vec<_>>(),
|
||
)
|
||
}
|
||
_ => None,
|
||
})
|
||
.expect("StyleSpans emitted from the LSP authority");
|
||
assert_eq!(spans.len(), 1, "the one LSP token → one span");
|
||
assert_eq!(spans[0].range, ByteRange { start: 0, end: 3 });
|
||
assert!(
|
||
spans[0].style.bold,
|
||
"token_type resolved through legend → theme face"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn lsp_style_suppressed_when_unchanged() {
|
||
let state = empty_state();
|
||
let mut s = local();
|
||
let bid = active_buffer(&state);
|
||
seed_lsp_style(&state, bid, b"int x;\n", vec![tok(0, 0, 3)]);
|
||
s.set_viewport(
|
||
bid,
|
||
ByteRange {
|
||
start: 0,
|
||
end: 4096,
|
||
},
|
||
0,
|
||
);
|
||
|
||
let _ = s.render_frame(&state); // full baseline
|
||
let again = s.render_frame(&state);
|
||
assert!(
|
||
!again
|
||
.iter()
|
||
.any(|m| matches!(m, InstanceMessage::StyleSpans { .. })),
|
||
"unchanged LSP styling reuses the M11.4 byte-identical suppression"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn lsp_style_incremental_on_token_change() {
|
||
let state = empty_state();
|
||
let mut s = local();
|
||
let bid = active_buffer(&state);
|
||
let sid = seed_lsp_style(&state, bid, b"int x;\n", vec![tok(0, 0, 3)]);
|
||
s.set_viewport(
|
||
bid,
|
||
ByteRange {
|
||
start: 0,
|
||
end: 4096,
|
||
},
|
||
0,
|
||
);
|
||
let _ = s.render_frame(&state); // full baseline
|
||
|
||
// Token now covers a different range ([4,5) = "x").
|
||
set_tokens(&state, sid, vec![tok(0, 4, 1)]);
|
||
let delta = s.render_frame(&state);
|
||
let (full, _) = style_segments(&delta).expect("StyleSpans re-emitted");
|
||
assert!(!full, "a changed token set ships an incremental frame");
|
||
}
|
||
|
||
#[test]
|
||
fn lsp_style_empty_when_no_tokens_for_grammarless_buffer() {
|
||
let state = empty_state();
|
||
let mut s = local();
|
||
let bid = active_buffer(&state);
|
||
{
|
||
let mut core = state.core.borrow_mut();
|
||
core.registry
|
||
.clone()
|
||
.borrow_mut()
|
||
.get_mut(bid)
|
||
.expect("active buffer")
|
||
.apply_edit(crate::buffer::EditOp::Insert {
|
||
pos: 0,
|
||
bytes: b"int x;\n",
|
||
})
|
||
.expect("seed text");
|
||
core.set_buffer_path(bid, Some(std::path::PathBuf::from("/tmp/x.cpp")));
|
||
}
|
||
s.set_viewport(
|
||
bid,
|
||
ByteRange {
|
||
start: 0,
|
||
end: 4096,
|
||
},
|
||
0,
|
||
);
|
||
|
||
let msgs = s.render_frame(&state);
|
||
// Still a full resync (the frontend must be told "nothing
|
||
// here"), but with no spans — no LSP store, no panic, honest
|
||
// empty rather than a fabricated style.
|
||
let styled = msgs.iter().find_map(|m| match m {
|
||
InstanceMessage::StyleSpans { full, segments, .. } => Some((*full, segments.clone())),
|
||
_ => None,
|
||
});
|
||
let (full, segments) = styled.expect("a full StyleSpans resync");
|
||
assert!(full);
|
||
assert!(
|
||
segments.iter().all(|sg| sg.spans.is_empty()),
|
||
"grammar-less buffer with no LSP tokens ⇒ zero spans"
|
||
);
|
||
}
|
||
|
||
// --- Step 3: InlineAdornments from the LSP inlay-hint store ---
|
||
|
||
fn inlay_uri() -> String {
|
||
crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/h.rs"))
|
||
}
|
||
|
||
/// Seed the inlay-hint store. Keyed by `(server, uri)`; `for_uri`
|
||
/// picks the lowest server, so a fixed `"1"` is fine. No LSP
|
||
/// client needed — the inlay path never consults
|
||
/// `semantic_style_context` (cols are already byte offsets, Step 0).
|
||
fn set_inlay_store(state: &EditorState, uri: &str, hints: Vec<crate::inlay_hint::InlayHint>) {
|
||
let store = state.lsp_manager.borrow().inlay_hint_store();
|
||
store.lock().expect("inlay store").set(
|
||
crate::inlay_hint::InlayHintKey::new("1", uri),
|
||
crate::inlay_hint::InlayHintResponse { hints },
|
||
);
|
||
}
|
||
|
||
/// Seed buffer text + path and an inlay-hint store entry.
|
||
fn seed_inlay(
|
||
state: &EditorState,
|
||
buffer_id: BufferId,
|
||
hints: Vec<crate::inlay_hint::InlayHint>,
|
||
) {
|
||
{
|
||
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"let x = f();\n",
|
||
})
|
||
.expect("seed buffer text");
|
||
core.set_buffer_path(buffer_id, Some(std::path::PathBuf::from("/tmp/h.rs")));
|
||
}
|
||
set_inlay_store(state, &inlay_uri(), hints);
|
||
}
|
||
|
||
fn hint(line: u32, col: u32, label: &str) -> crate::inlay_hint::InlayHint {
|
||
crate::inlay_hint::InlayHint {
|
||
line,
|
||
col,
|
||
label: label.into(),
|
||
kind: None,
|
||
padding_left: false,
|
||
padding_right: true,
|
||
tooltip: None,
|
||
}
|
||
}
|
||
|
||
fn adornments_of(msgs: &[InstanceMessage]) -> Option<Vec<InlineAdornment>> {
|
||
msgs.iter().find_map(|m| match m {
|
||
InstanceMessage::InlineAdornments { items, .. } => Some(items.clone()),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn inlay_hints_project_as_inline_adornments_clipped_to_viewport() {
|
||
let state = empty_state();
|
||
let mut s = local();
|
||
let bid = active_buffer(&state);
|
||
// `: i32` after `x` (col 5, in-viewport) and a hint past the
|
||
// declared viewport that must be excluded.
|
||
seed_inlay(&state, bid, vec![hint(0, 5, ": i32"), hint(0, 11, "OUT")]);
|
||
s.set_viewport(bid, ByteRange { start: 0, end: 8 }, 0);
|
||
|
||
let msgs = s.render_frame(&state);
|
||
assert_semantic_only(&msgs);
|
||
let items = adornments_of(&msgs).expect("InlineAdornments emitted");
|
||
assert_eq!(items.len(), 1, "the out-of-viewport hint is clipped");
|
||
let a = &items[0];
|
||
assert_eq!(a.at, 5);
|
||
assert_eq!(a.placement, AdornmentPlacement::AtOffset);
|
||
match &a.content {
|
||
AdornmentContent::Text { text, style } => {
|
||
assert_eq!(text, ": i32 ", "padding_right adds a trailing space");
|
||
assert_eq!(*style, Style::default());
|
||
}
|
||
AdornmentContent::Resource { .. } => panic!("expected Text, got Resource"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn inline_adornments_suppressed_then_resync_on_change() {
|
||
let state = empty_state();
|
||
let mut s = local();
|
||
let bid = active_buffer(&state);
|
||
seed_inlay(&state, bid, vec![hint(0, 5, ": i32")]);
|
||
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
|
||
|
||
assert!(
|
||
adornments_of(&s.render_frame(&state)).is_some(),
|
||
"first frame ships the adornments"
|
||
);
|
||
assert!(
|
||
adornments_of(&s.render_frame(&state)).is_none(),
|
||
"byte-identical next frame is suppressed"
|
||
);
|
||
|
||
// Hint set changes → whole-set re-send (no segment diffing on
|
||
// this wire variant).
|
||
seed_inlay(&state, bid, vec![hint(0, 5, ": String")]);
|
||
let again = adornments_of(&s.render_frame(&state)).expect("re-sent on change");
|
||
match &again[0].content {
|
||
AdornmentContent::Text { text, .. } => assert_eq!(text, ": String "),
|
||
AdornmentContent::Resource { .. } => panic!("expected Text, got Resource"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn inline_adornments_emit_empty_clear_while_inlay_store_stale() {
|
||
let state = empty_state();
|
||
let mut s = local();
|
||
let bid = active_buffer(&state);
|
||
seed_inlay(&state, bid, vec![hint(0, 5, ": i32")]);
|
||
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
|
||
|
||
assert!(
|
||
adornments_of(&s.render_frame(&state)).is_some(),
|
||
"first frame ships the adornments"
|
||
);
|
||
|
||
let uri = inlay_uri();
|
||
let store = state.lsp_manager.borrow().inlay_hint_store();
|
||
store.lock().expect("inlay store").mark_stale(uri.clone());
|
||
|
||
let clear =
|
||
adornments_of(&s.render_frame(&state)).expect("stale transition clears adornments");
|
||
assert!(
|
||
clear.is_empty(),
|
||
"stale hints must clear the frontend's cached virtual text"
|
||
);
|
||
assert!(
|
||
adornments_of(&s.render_frame(&state)).is_none(),
|
||
"unchanged stale-empty state is suppressed after the clear"
|
||
);
|
||
|
||
set_inlay_store(&state, &uri, vec![hint(0, 5, ": i32")]);
|
||
let refreshed = adornments_of(&s.render_frame(&state)).expect("fresh hints re-emit");
|
||
assert_eq!(refreshed.len(), 1);
|
||
assert_eq!(refreshed[0].at, 5);
|
||
}
|
||
|
||
#[cfg(feature = "crdt")]
|
||
#[test]
|
||
fn session8_temporal_probe_sustained_edits_clear_stale_inlays_until_refresh() {
|
||
let state = empty_state();
|
||
let mut s = local();
|
||
let bid = active_buffer(&state);
|
||
let path = std::path::PathBuf::from("/tmp/session8-inlay.txt");
|
||
let uri = crate::lsp::path_to_file_uri(&path);
|
||
|
||
{
|
||
let mut core = state.core.borrow_mut();
|
||
let mut reg = core.registry.borrow_mut();
|
||
let buf = reg.get_mut(bid).expect("active buffer");
|
||
buf.apply_edit(crate::buffer::EditOp::Insert {
|
||
pos: 0,
|
||
bytes: b"let x = f();\n",
|
||
})
|
||
.expect("seed buffer text");
|
||
buf.upgrade_to_crdt(1).expect("upgrade to crdt");
|
||
drop(reg);
|
||
core.set_buffer_path(bid, Some(path));
|
||
}
|
||
|
||
set_inlay_store(&state, &uri, vec![hint(0, 5, ": i32")]);
|
||
s.set_viewport(
|
||
bid,
|
||
ByteRange {
|
||
start: 0,
|
||
end: 4096,
|
||
},
|
||
0,
|
||
);
|
||
assert!(
|
||
adornments_of(&s.render_frame(&state)).is_some(),
|
||
"baseline emits the fresh inlay hint"
|
||
);
|
||
|
||
let mut clear_frames = 0;
|
||
let mut full_style_frames = 0;
|
||
let mut full_deco_frames = 0;
|
||
for _ in 0..1000 {
|
||
{
|
||
let core = state.core.borrow();
|
||
let mut reg = core.registry.borrow_mut();
|
||
let buf = reg.get_mut(bid).expect("active buffer");
|
||
buf.apply_edit(crate::buffer::EditOp::Insert {
|
||
pos: 0,
|
||
bytes: b"x",
|
||
})
|
||
.expect("typing edit");
|
||
}
|
||
let store = state.lsp_manager.borrow().inlay_hint_store();
|
||
store.lock().expect("inlay store").mark_stale(uri.clone());
|
||
|
||
let msgs = s.render_frame(&state);
|
||
if let Some((full, _)) = style_segments(&msgs)
|
||
&& full
|
||
{
|
||
full_style_frames += 1;
|
||
}
|
||
if let Some((full, _)) = decorations_of(&msgs)
|
||
&& full
|
||
{
|
||
full_deco_frames += 1;
|
||
}
|
||
if let Some(items) = adornments_of(&msgs) {
|
||
assert!(
|
||
items.is_empty(),
|
||
"stale inlay hints must not render during sustained typing"
|
||
);
|
||
clear_frames += 1;
|
||
}
|
||
}
|
||
|
||
assert_eq!(
|
||
clear_frames, 1,
|
||
"first stale frame clears cached hints; later stale frames stay silent"
|
||
);
|
||
assert_eq!(
|
||
full_style_frames, 1000,
|
||
"each CRDT generation transition forces a StyleSpans full resync"
|
||
);
|
||
assert_eq!(
|
||
full_deco_frames, 1000,
|
||
"each CRDT generation transition forces a Decorations full resync"
|
||
);
|
||
|
||
set_inlay_store(&state, &uri, vec![hint(0, 1005, ": i32")]);
|
||
let refreshed = adornments_of(&s.render_frame(&state)).expect("fresh hints re-emit");
|
||
assert_eq!(refreshed.len(), 1);
|
||
assert_eq!(refreshed[0].at, 1005);
|
||
}
|
||
|
||
// --- M1: FileStyleSummary (minimap producer, Open Q#2) ---
|
||
|
||
fn summary_of(msgs: &[InstanceMessage]) -> Option<(u64, Vec<Style>)> {
|
||
msgs.iter().find_map(|m| match m {
|
||
InstanceMessage::FileStyleSummary {
|
||
generation, lines, ..
|
||
} => Some((*generation, lines.clone())),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn file_style_summary_emitted_on_first_frame_and_suppressed_thereafter() {
|
||
let state = empty_state();
|
||
let mut s = local();
|
||
let bid = active_buffer(&state);
|
||
// Tiny buffer with no LSP / grammar setup → no styled runs →
|
||
// per-line dominant style stays `Style::default()`. The point
|
||
// of this test is the emit / suppress lifecycle, not content.
|
||
{
|
||
let core = state.core.borrow_mut();
|
||
core.registry
|
||
.clone()
|
||
.borrow_mut()
|
||
.get_mut(bid)
|
||
.expect("active buffer")
|
||
.apply_edit(crate::buffer::EditOp::Insert {
|
||
pos: 0,
|
||
bytes: b"a\nb\nc\n",
|
||
})
|
||
.expect("seed");
|
||
}
|
||
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
|
||
|
||
let first = s.render_frame(&state);
|
||
assert_semantic_only(&first);
|
||
let (_gen, lines) = summary_of(&first).expect("first frame ships a summary");
|
||
// 4 lines: "a", "b", "c", "" (trailing empty after final \n).
|
||
assert_eq!(lines.len(), 4);
|
||
assert!(
|
||
lines.iter().all(|s| *s == Style::default()),
|
||
"no styled runs ⇒ every line takes the default style"
|
||
);
|
||
|
||
// Same generation → no re-emission.
|
||
let again = s.render_frame(&state);
|
||
assert!(
|
||
summary_of(&again).is_none(),
|
||
"unchanged generation suppresses the summary"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn file_style_summary_dominant_style_from_lsp_per_line() {
|
||
let state = empty_state();
|
||
let mut s = local();
|
||
let bid = active_buffer(&state);
|
||
// `.cpp` buffer (no tree-sitter grammar) so styling comes from
|
||
// the LSP semantic-token authority (policy A). Three lines;
|
||
// line 0 has a token spanning bytes 0..3, line 2 has one
|
||
// spanning bytes 8..11. Line 1 has no tokens.
|
||
//
|
||
// Buffer layout (newlines included):
|
||
// bytes 0..4 "abc\n" line 0 = [0,3) ← token [0,3)
|
||
// bytes 4..8 "def\n" line 1 = [4,7) ← no token
|
||
// bytes 8..12 "ghi\n" line 2 = [8,11) ← token [8,11)
|
||
let sid = seed_lsp_style(
|
||
&state,
|
||
bid,
|
||
b"abc\ndef\nghi\n",
|
||
vec![tok(0, 0, 3), tok(2, 0, 3)],
|
||
);
|
||
let _ = sid;
|
||
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
|
||
|
||
let msgs = s.render_frame(&state);
|
||
let (_, lines) = summary_of(&msgs).expect("summary emitted");
|
||
assert_eq!(lines.len(), 4, "three lines + trailing empty");
|
||
let kw_style = crate::cell::Style {
|
||
bold: true,
|
||
..crate::cell::Style::default()
|
||
};
|
||
assert_eq!(lines[0], kw_style, "line 0 dominated by the LSP token");
|
||
assert_eq!(lines[1], Style::default(), "line 1 has no token → default");
|
||
assert_eq!(lines[2], kw_style, "line 2 dominated by the LSP token");
|
||
assert_eq!(lines[3], Style::default(), "trailing empty line → default");
|
||
}
|
||
}
|