Step 3+5: InlineAdornments producer from LSP inlay hints + doc update

scoped_inline_adornments (free fn, mirrors scoped_style_spans) reads
the inlay-hint store via for_uri and maps each InlayHint to an
InlineAdornment { at, AtOffset, Text{padded label, default style} },
clipped to the declared viewport (anchor in [vis_start, vis_end)).
Step 0 established inlay columns are already byte offsets by the time
they reach the store (inbound_converted rewrites the Position-shaped
InlayHint.position), so line_col_to_byte is exact with no per-server
encoding — unlike semantic-token styling.

inline_adornments_msg does the suppression: the InlineAdornments wire
variant has no generation/full/segments, so this is M11.2-level only
(whole-set re-send on any change, nothing when byte-identical, and
never an empty frame when there is nothing to say — no spam).

Tests: clip-to-viewport + padding + AtOffset, suppress-then-resync,
no-emit-without-hints; the old never-emitted invariant is split into
block_adornments_and_fold_state_still_never_emitted (Block/Fold are
still unwired) plus inline_adornments_not_emitted_without_hints.
assert_semantic_only now admits InlineAdornments.

Step 5 (folded): docs/semantic-frontend-protocol.md moves StyleSpans
(policy A) + InlineAdornments out of "declared, not wired", and adds
two deferred Open questions — per-byte tree-sitter/LSP blend, and
multiple-servers-one-URI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-19 16:00:44 -04:00
parent 1e4b98ae1b
commit 8ec3abcad5
2 changed files with 309 additions and 30 deletions

View File

@ -21,11 +21,25 @@ against this design:
- **M11.5** — the headless `SemanticClient` glue + reconstruction-
equivalence and end-to-end tests.
`InlineAdornments` / `BlockAdornments` / `FoldState` /
`ResourceOffer` are declared but deliberately unproduced — pmacs has
no inlay-hint / blame / lens / fold / diff source yet; their
producers wire in when those features land (the same "declared, not
yet wired" discipline this arc used throughout). The "Open
Post-M11 producer arc (the LSP feature arc landed the missing data
sources, so the "wire in when those features land" promise came due):
- **`StyleSpans`, second authority** — policy A, *per-language*
styling authority. A grammar-backed language stays tree-sitter
only; a language with no bundled grammar (C/C++, …) is styled
from LSP semantic tokens (`lsp_scoped_style_spans`, encoding +
legend via `LspManager::semantic_style_context`). Never both on
one buffer. Reuses the M11.4 diff pipeline unchanged.
- **`InlineAdornments`** — produced from the LSP inlay-hint store
(`scoped_inline_adornments`). The wire variant carries no
`generation`/`full`/`segments`, so suppression is M11.2-level
(whole-set re-send on change, nothing when byte-identical, never
an empty frame).
`BlockAdornments` / `FoldState` / `ResourceOffer` remain declared
but deliberately unproduced — pmacs still has no blame / lens /
fold / diff source; their producers wire in when those features
land (the same "declared, not yet wired" discipline). The "Open
questions" section at the end remains open by design.
The grid path (TUI, SSH, a future GPU terminal-grade frontend)
@ -257,3 +271,19 @@ design work, not blockers, and none affect the v1.0 tag.
untested surface to the frontend↔instance glue, not all
rendering. The existing `audit/` + proptest discipline
extends naturally to semantic-frame goldens.
4. **Per-byte tree-sitter / LSP style blend.** The producer arc
chose policy A (per-language authority) deliberately: C/C++ has
*no* grammar so there is no overlap to resolve, and it reuses
the M11.4 diff pipeline untouched. A grammar-backed language
refined by LSP semantic precision (mutable-vs-const, etc.) would
need tree-sitter-base + LSP-overlay with a per-byte span merge —
more code and more async-LSP diff churn. Unresolved; deferred
until a concrete language motivates it.
5. **Multiple servers, one URI.** `SemanticTokenStore::for_uri` and
`semantic_style_context` pick the lowest-id server when several
attach to the same document. Blending styling/adornments across
servers (e.g. a typo linter + a language server) is unresolved;
the single-representative-server rule is the v1 simplification,
not a final answer.

View File

@ -33,8 +33,8 @@ use crate::buffer::BufferId;
use crate::cell::Style;
use crate::editor::EditorState;
use crate::protocol::{
ByteRange, Decoration, DecorationKind, DecorationSegment, FrontendId, InstanceMessage,
StyleSegment, StyleSpan,
AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment,
FrontendId, InlineAdornment, InstanceMessage, StyleSegment, StyleSpan,
};
/// The viewport a `semantic_render` frontend last declared.
@ -83,6 +83,13 @@ pub struct SemanticRenderState {
/// 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>>,
}
impl SemanticRenderState {
@ -95,6 +102,7 @@ impl SemanticRenderState {
viewport: None,
last_sent: HashMap::new(),
last_decorations: HashMap::new(),
last_adornments: HashMap::new(),
}
}
@ -112,20 +120,22 @@ impl SemanticRenderState {
/// 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
/// 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.
///
/// `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.
/// `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).
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.
@ -238,9 +248,51 @@ impl SemanticRenderState {
}
}
// --- InlineAdornments (Step 3 producer) ---
out.extend(self.inline_adornments_msg(state, &vp));
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;
}
self.last_adornments.insert(
vp.buffer_id,
LastFrame {
visible: vp.visible,
items: adornments.clone(),
},
);
Some(InstanceMessage::InlineAdornments {
buffer_id: vp.buffer_id,
items: adornments,
})
}
/// Project the [`Decoration`] set intersecting the declared
/// viewport: the session's selection (instance-authoritative,
/// byte-native) and LSP diagnostics (line/col → byte, severity →
@ -315,6 +367,78 @@ impl SemanticRenderState {
}
}
/// 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.
fn scoped_inline_adornments(state: &EditorState, vp: &DeclaredViewport) -> Vec<InlineAdornment> {
let core = state.core.borrow();
let Some(path) = core.active_buffer_path() else {
return Vec::new();
};
let uri = crate::lsp::path_to_file_uri(&path);
let hints = {
let store = state.lsp_manager.borrow().inlay_hint_store();
let guard = store.lock().expect("inlay-hint store mutex poisoned");
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
@ -671,14 +795,17 @@ mod tests {
}
/// All `InstanceMessage` variants the semantic projection may
/// emit are `StyleSpans` or `Decorations` — never `CellDelta`,
/// grid `Cursor`, or the not-yet-wired adornment/fold families.
/// emit are `StyleSpans`, `Decorations`, or `InlineAdornments` —
/// 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::StyleSpans { .. }
| InstanceMessage::Decorations { .. }
| InstanceMessage::InlineAdornments { .. }
),
"semantic projection emitted an unexpected variant: {m:?}"
);
@ -961,10 +1088,9 @@ mod tests {
}
#[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).
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();
@ -974,16 +1100,33 @@ mod tests {
assert!(
!matches!(
m,
InstanceMessage::InlineAdornments { .. }
| InstanceMessage::BlockAdornments { .. }
| InstanceMessage::FoldState { .. }
InstanceMessage::BlockAdornments { .. } | InstanceMessage::FoldState { .. }
),
"a not-yet-wired adornment/fold family was emitted: {m:?}"
"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
@ -1221,4 +1364,110 @@ mod tests {
"grammar-less buffer with no LSP tokens ⇒ zero spans"
);
}
// --- Step 3: InlineAdornments from the LSP inlay-hint store ---
/// Seed buffer text + path and an inlay-hint store entry. 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 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")));
}
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/h.rs"));
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 },
);
}
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"),
}
}
}