From 8ec3abcad59a56592043ffb62cdd8bb6c852570b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 19 May 2026 16:00:44 -0400 Subject: [PATCH] Step 3+5: InlineAdornments producer from LSP inlay hints + doc update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/semantic-frontend-protocol.md | 40 +++- src/semantic_render.rs | 299 ++++++++++++++++++++++++++--- 2 files changed, 309 insertions(+), 30 deletions(-) diff --git a/docs/semantic-frontend-protocol.md b/docs/semantic-frontend-protocol.md index 33c6167..2e4ebc1 100644 --- a/docs/semantic-frontend-protocol.md +++ b/docs/semantic-frontend-protocol.md @@ -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. diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 23691a6..e58cf71 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -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>, + /// `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>, } 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 { 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 { + 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 { + 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, + ) { + { + 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> { + 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"), + } + } }