Render inline adornments in pmacs-gpu

This commit is contained in:
Levi Neuwirth 2026-05-27 10:24:20 -04:00
parent 407ab75bdf
commit 71b21dee1e
5 changed files with 574 additions and 94 deletions

View File

@ -228,6 +228,24 @@ local function active_buffer_text()
return b:slice(0, b:len())
end
local function buffer_text(buf)
if not buf then return "" end
return buf:slice(0, buf:len())
end
local function document_end_position(text)
local line, col = 0, 0
for i = 1, #text do
if text:byte(i) == 10 then
line = line + 1
col = 0
else
col = col + 1
end
end
return line, col
end
local function active_buffer_path()
return pmacs.editor.file_path()
end
@ -321,6 +339,29 @@ local function server_is_live(sid)
return false
end
local function server_is_initialized(sid)
local ok, state = pcall(pmacs.lsp.status, sid)
return ok and state and state.kind == "initialized"
end
local function server_supports_inlay_hints(sid)
local ok, caps = pcall(pmacs.lsp.capabilities, sid)
if not ok or not caps then return false end
return caps.inlayHintProvider ~= nil and caps.inlayHintProvider ~= false
end
local function pull_inlay_hints_quiet(rec)
if not rec or not server_is_initialized(rec.server) then return end
if not server_supports_inlay_hints(rec.server) then return end
local end_line, end_col = document_end_position(buffer_text(rec.buffer))
pmacs.async(function()
pcall(function()
pmacs.lsp.request_inlay_hint(
rec.server, rec.uri, 0, 0, end_line, end_col):await()
end)
end)
end
-- M_B1: buffers that already had an `LspStyleView` overlay pushed,
-- so the after-load / on-demand attach paths don't stack duplicate
-- overlays. Mirrors `highlighted_buffers` in `syntax.lua`; the entry
@ -348,7 +389,13 @@ local function attach_buffer(buf)
if not sid then return nil end
local uri = file_uri_for(path)
if not uri then return nil end
local rec = { language = language, server = sid, uri = uri, version = 1 }
local rec = {
buffer = buf,
language = language,
server = sid,
uri = uri,
version = 1,
}
attachments[key] = rec
-- did_open is a notification; the manager queues it cleanly even
-- while the server is in `starting` / `initializing`.
@ -376,6 +423,7 @@ local function attach_buffer(buf)
local ok, attached = pcall(pmacs.diag._attach_view, buf, uri)
if ok and attached then diag_viewed_buffers[key] = true end
end
pull_inlay_hints_quiet(rec)
return rec
end
@ -610,7 +658,7 @@ end
local function repull_for_attachments(sid, request_fn)
for _, rec in pairs(attachments) do
if rec.server == sid and rec.uri then
pcall(request_fn, sid, rec.uri)
pcall(request_fn, sid, rec.uri, rec)
end
end
end
@ -913,8 +961,8 @@ local function handle_server_requests()
-- Result is `null` on success per the LSP spec; then
-- re-pull so the store reflects the server's new state.
pcall(pmacs.lsp.send_response, sid, ev.request_id, nil)
repull_for_attachments(sid, function(s, uri)
pmacs.lsp.request_inlay_hint(s, uri, 0, 0, 0xFFFFF, 0)
repull_for_attachments(sid, function(_, _, rec)
pull_inlay_hints_quiet(rec)
end)
elseif ev.kind == "request"
and ev.method == "workspace/semanticTokens/refresh" then
@ -931,6 +979,10 @@ local function handle_server_requests()
-- LSP spells the field "unregisterations".
pcall(unregister_file_watchers, sid,
ev.params and ev.params.unregisterations)
elseif ev.kind == "initialized" then
repull_for_attachments(sid, function(_, _, rec)
pull_inlay_hints_quiet(rec)
end)
end
end
end
@ -1093,16 +1145,16 @@ function pmacs.lsp.inlay_hints()
pmacs.editor.set_status("LSP: no server for active buffer")
return
end
-- Whole-document range: (0,0) .. (one past the last line, 0). An
-- over-wide end line is fine — servers clamp to the document.
-- Whole-document range: (0,0) .. exact document end. Some servers,
-- including rust-analyzer, reject one-past or otherwise over-wide
-- line numbers instead of clamping.
local text = active_buffer_text()
local nl = 0
for _ in text:gmatch("\n") do nl = nl + 1 end
local end_line, end_col = document_end_position(text)
pmacs.inlay_hint.clear(rec.server, rec.uri)
pmacs.async(function()
local ok, err = pcall(function()
pmacs.lsp.request_inlay_hint(
rec.server, rec.uri, 0, 0, nl + 1, 0):await()
rec.server, rec.uri, 0, 0, end_line, end_col):await()
end)
if not ok then
pmacs.editor.set_status("LSP: " .. lsp_await_error(err))

View File

@ -32,8 +32,9 @@ use glyphon::{
TextArea, TextAtlas, TextBounds, TextRenderer, Viewport,
};
use pmacs_protocol::{
BufferId, ByteRange, Decoration, DecorationKind, DecorationSegment, InstanceMessage,
StyleSegment, StyleSpan, cell::Color as CellColor,
AdornmentContent, AdornmentPlacement, BufferId, ByteRange, Decoration, DecorationKind,
DecorationSegment, InlineAdornment, InstanceMessage, StyleSegment, StyleSpan,
cell::Color as CellColor,
};
use wgpu::MultisampleState;
use winit::application::ApplicationHandler;
@ -203,6 +204,13 @@ struct State {
/// are not rendered in session 5; see the session-5 design note
/// for the deferred quad-pipeline finding.
current_decorations: Vec<Decoration>,
/// Inline virtual text for `current_buffer_id` (session 6).
/// Producer-side Phase A currently emits LSP inlay hints as
/// `AtOffset` text adornments only. The GUI stores the whole scoped
/// set and projects it into the shaped rich text without inserting
/// bytes into `current_text`; source byte ranges for style spans and
/// decorations therefore remain source-relative.
current_adornments: Vec<InlineAdornment>,
}
impl ApplicationHandler<AppEvent> for App {
@ -402,28 +410,32 @@ impl State {
current_buffer_id: None,
current_spans: Vec::new(),
current_decorations: Vec::new(),
current_adornments: Vec::new(),
}
}
/// Replace the rendered text with `text` and request a redraw.
/// No-op when `text` is byte-identical to the current rendering
/// (avoids the re-shape cost when an unchanged buffer ticks).
/// Returns `false` when `text` is byte-identical to the current
/// rendering (avoids the re-shape cost when an unchanged buffer
/// ticks).
///
/// Replaces the rope text and routes through `reshape` so the
/// rich-text rendering uses the current `current_spans`. When
/// called from the `CrdtOp` path (text shifted under existing
/// spans) the spans are momentarily stale relative to the new
/// byte positions — `reshape` clamps via `range.end.min(text_len)`
/// so rendering is safe, but visual styling may be off until the
/// daemon's next `StyleSpans` frame catches up. A real artifact;
/// classified as a session-4 known limitation rather than a bug.
fn set_text(&mut self, text: &str) {
/// rich-text rendering uses the current spans, decorations, and
/// inline adornments. When called from the `CrdtOp` path (text
/// shifted under existing source anchors) those anchors are
/// momentarily stale relative to the new byte positions —
/// `reshape` clamps via `range.end.min(text_len)` so rendering is
/// safe, but visual styling may be off until the daemon's next
/// semantic frame catches up. A real artifact; classified as a
/// known Phase A limitation rather than a bug.
fn set_text(&mut self, text: &str) -> bool {
if self.current_text == text {
return;
return false;
}
self.current_text.clear();
self.current_text.push_str(text);
self.reshape();
true
}
/// Apply one `InstanceMessage`; return a follow-up
@ -444,13 +456,17 @@ impl State {
/// search match). Session 5 renders diagnostic kinds as fg color
/// overrides; background-kind decorations are accumulated but
/// not painted (see session 5's deferred quad-pipeline finding).
/// - `InlineAdornments` — replace the scoped virtual-text set and
/// reshape the display projection. Session 6 consumes `AtOffset`
/// text adornments (LSP inlay hints); other placements/content
/// remain explicitly deferred.
/// - `Goodbye` — surfaced via the reader thread's clean-EOF path,
/// not handled here.
///
/// Remaining `SemanticFrame` variants (`InlineAdornments`,
/// `FileStyleSummary`) plus the grid variants (`CellDelta`,
/// `Cursor`, `CursorByte`) and presence updates are ignored in
/// session 5 — they land in subsequent Phase A sessions.
/// Remaining `SemanticFrame` variants (`FileStyleSummary`) plus
/// the grid variants (`CellDelta`, `Cursor`, `CursorByte`) and
/// presence updates are ignored in session 6 — they land in
/// subsequent Phase A sessions.
fn apply_attach_message(&mut self, msg: InstanceMessage) -> Option<ViewportSend> {
match msg {
InstanceMessage::BufferSnapshot {
@ -471,7 +487,10 @@ impl State {
// buffer is authoritative.
self.current_spans.clear();
self.current_decorations.clear();
self.set_text(&text);
self.current_adornments.clear();
if !self.set_text(&text) {
self.reshape();
}
Some(ViewportSend {
buffer_id,
visible: ByteRange {
@ -514,6 +533,12 @@ impl State {
// *incremental* updates ship dirty-range spans only,
// and an emptied cache loses the non-dirty viewport
// styling entirely.
//
// InlineAdornments use whole-set suppression rather
// than dirty segments, so the same ownership rule
// applies here: keep the last set until the producer
// sends a replacement. Session 8's temporal probe is
// where visible edit-flicker/staleness gets scored.
let text = doc.get_text(LORO_TEXT_CONTAINER).to_string();
self.set_text(&text);
None
@ -552,6 +577,15 @@ impl State {
self.reshape();
None
}
InstanceMessage::InlineAdornments { buffer_id, items } => {
if self.current_buffer_id != Some(buffer_id) {
return None;
}
self.current_adornments = items;
self.current_adornments.sort_by_key(|a| a.at);
self.reshape();
None
}
_ => None,
}
}
@ -702,17 +736,13 @@ impl State {
}
/// Re-build the cosmic-text Buffer from `current_text` +
/// `current_spans` + `current_decorations`. Computes a sorted
/// boundary list (every span and decoration edge, plus 0 and
/// `text_len`) and emits one chunk per `[boundary_i, boundary_{i+1})`
/// interval. Effective color picks the first matching decoration
/// kind with a renderable color (diagnostics in session 5; the
/// background-needing kinds — `Selection` / `SearchMatch` /
/// `SearchMatchActive` / `CurrentLine` — produce `None` and fall
/// through to span color). The decoration override means a
/// diagnostic squiggle's color beats syntax color for the bytes
/// it covers, which matches user expectation across both
/// reference editors and the pmacs TUI.
/// `current_spans` + `current_decorations` +
/// `current_adornments`. Source styling/decorations remain
/// byte-indexed into `current_text`; adornments contribute extra
/// rich-text chunks at their anchors without mutating the source
/// string. That display projection is the central session-6
/// invariant: virtual text must not shift the source-byte ranges
/// used by `StyleSpans` / `Decorations`.
///
/// Complexity is O(B × (S + D)) per reshape where B is the boundary
/// count and S+D is spans+decorations. For viewport-scoped data
@ -721,61 +751,21 @@ impl State {
/// surfaces in profile data — recorded but not done in session 5.
fn reshape(&mut self) {
let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono"));
let text_len = self.current_text.len() as u64;
// Collect every interesting byte position. Clamp to text_len
// so a stale span/decoration past EOF (CrdtOp→next-frame race)
// can't index out.
let mut boundaries: Vec<u64> = vec![0, text_len];
for sp in &self.current_spans {
boundaries.push(sp.range.start.min(text_len));
boundaries.push(sp.range.end.min(text_len));
}
for d in &self.current_decorations {
boundaries.push(d.range.start.min(text_len));
boundaries.push(d.range.end.min(text_len));
}
boundaries.sort_unstable();
boundaries.dedup();
let mut chunks: Vec<(String, Attrs<'static>)> = Vec::new();
for w in boundaries.windows(2) {
let (a, b) = (w[0], w[1]);
if a >= b {
continue;
}
// Pick the effective color at byte `a` (which is also the
// color for every byte in `[a, b)` since boundaries
// bracket every coverage change).
let mut color: Option<glyphon::Color> = None;
for d in &self.current_decorations {
if d.range.start <= a
&& a < d.range.end
&& let Some(c) = decoration_kind_to_color(d.kind)
{
color = Some(c);
break;
}
}
if color.is_none() {
for sp in &self.current_spans {
if sp.range.start <= a && a < sp.range.end {
color = cell_color_to_glyphon(sp.style.fg);
break;
}
}
}
let chunks: Vec<(String, Attrs<'static>)> = projected_rich_chunks(
&self.current_text,
&self.current_spans,
&self.current_decorations,
&self.current_adornments,
)
.into_iter()
.map(|chunk| {
let mut attrs = default_attrs.clone();
if let Some(c) = color {
if let Some(c) = chunk.color {
attrs = attrs.color(c);
}
chunks.push((self.current_text[a as usize..b as usize].to_owned(), attrs));
}
// No spans / decorations + empty text ⇒ feed one empty chunk
// so set_rich_text has something to draw.
if chunks.is_empty() {
chunks.push((String::new(), default_attrs.clone()));
}
(chunk.text, attrs)
})
.collect();
self.buffer.set_rich_text(
&mut self.font_system,
chunks.iter().map(|(s, a)| (s.as_str(), a.clone())),
@ -876,6 +866,128 @@ impl State {
}
}
#[derive(Clone, Debug)]
struct RichChunk {
text: String,
color: Option<glyphon::Color>,
}
/// Build the rich-text chunks fed to glyphon. Source chunks come from
/// `text` and retain source-byte styling; inline adornments create
/// extra chunks at their anchors and therefore do not shift any source
/// span/decoration range.
fn projected_rich_chunks(
text: &str,
spans: &[StyleSpan],
decorations: &[Decoration],
adornments: &[InlineAdornment],
) -> Vec<RichChunk> {
let text_len = text.len() as u64;
let mut boundaries: Vec<u64> = vec![0, text_len];
for sp in spans {
boundaries.push(sp.range.start.min(text_len));
boundaries.push(sp.range.end.min(text_len));
}
for d in decorations {
boundaries.push(d.range.start.min(text_len));
boundaries.push(d.range.end.min(text_len));
}
let mut renderable_adornments: Vec<(usize, u64, &InlineAdornment)> = adornments
.iter()
.enumerate()
.filter_map(|(idx, a)| renderable_adornment_anchor(a, text_len).map(|at| (idx, at, a)))
.collect();
for (_, at, _) in &renderable_adornments {
boundaries.push(*at);
}
boundaries.sort_unstable();
boundaries.dedup();
renderable_adornments.sort_by_key(|(idx, at, _)| (*at, *idx));
let mut chunks = Vec::new();
let mut adorn_idx = 0usize;
for w in boundaries.windows(2) {
let (a, b) = (w[0], w[1]);
push_adornments_at(&mut chunks, &renderable_adornments, &mut adorn_idx, a);
if a < b {
chunks.push(RichChunk {
text: text[a as usize..b as usize].to_owned(),
color: source_color_at(a, spans, decorations),
});
}
}
push_adornments_at(
&mut chunks,
&renderable_adornments,
&mut adorn_idx,
text_len,
);
if chunks.is_empty() {
chunks.push(RichChunk {
text: String::new(),
color: None,
});
}
chunks
}
fn renderable_adornment_anchor(adornment: &InlineAdornment, text_len: u64) -> Option<u64> {
match (&adornment.placement, &adornment.content) {
(AdornmentPlacement::AtOffset, AdornmentContent::Text { .. }) => {
Some(adornment.at.min(text_len))
}
// Session 6 consumes the inlay-hint producer surface only.
// Other placements and resource handles need layout/resource
// policy, so silently ignore them until their sessions land.
_ => None,
}
}
fn push_adornments_at(
chunks: &mut Vec<RichChunk>,
adornments: &[(usize, u64, &InlineAdornment)],
next: &mut usize,
at: u64,
) {
while let Some((_, anchor, adornment)) = adornments.get(*next).copied() {
if anchor != at {
break;
}
if let AdornmentContent::Text { text, style } = &adornment.content {
chunks.push(RichChunk {
text: text.clone(),
color: Some(adornment_text_color(style.fg)),
});
}
*next += 1;
}
}
fn adornment_text_color(fg: CellColor) -> glyphon::Color {
cell_color_to_glyphon(fg).unwrap_or_else(|| glyphon::Color::rgb(130, 130, 140))
}
fn source_color_at(
byte: u64,
spans: &[StyleSpan],
decorations: &[Decoration],
) -> Option<glyphon::Color> {
for d in decorations {
if d.range.start <= byte
&& byte < d.range.end
&& let Some(c) = decoration_kind_to_color(d.kind)
{
return Some(c);
}
}
for sp in spans {
if sp.range.start <= byte && byte < sp.range.end {
return cell_color_to_glyphon(sp.style.fg);
}
}
None
}
/// Convert a `pmacs-protocol::cell::Color` to a `glyphon::Color`.
/// Returns `None` for `Default` so the renderer falls back to the
/// `Attrs` default color (white-ish in our render) rather than
@ -969,3 +1081,133 @@ fn decoration_kind_to_color(kind: DecorationKind) -> Option<glyphon::Color> {
| DecorationKind::CurrentLine => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use pmacs_protocol::cell::Style;
fn style_with_fg(fg: CellColor) -> Style {
Style {
fg,
..Style::default()
}
}
fn span(start: u64, end: u64, fg: CellColor) -> StyleSpan {
StyleSpan {
range: ByteRange { start, end },
style: style_with_fg(fg),
}
}
fn adornment(at: u64, placement: AdornmentPlacement, text: &str) -> InlineAdornment {
InlineAdornment {
at,
placement,
content: AdornmentContent::Text {
text: text.to_owned(),
style: Style::default(),
},
}
}
fn resource_adornment(at: u64, placement: AdornmentPlacement) -> InlineAdornment {
InlineAdornment {
at,
placement,
content: AdornmentContent::Resource { handle: 7 },
}
}
fn chunk_texts(chunks: &[RichChunk]) -> Vec<&str> {
chunks.iter().map(|chunk| chunk.text.as_str()).collect()
}
#[test]
fn projected_rich_chunks_inserts_at_offset_without_source_bytes() {
let chunks = projected_rich_chunks(
"abcd",
&[],
&[],
&[adornment(2, AdornmentPlacement::AtOffset, "X")],
);
assert_eq!(chunk_texts(&chunks), vec!["ab", "X", "cd"]);
let rendered: String = chunks.iter().map(|chunk| chunk.text.as_str()).collect();
assert_eq!(rendered, "abXcd");
}
#[test]
fn inline_adornment_does_not_shift_source_style_ranges() {
let chunks = projected_rich_chunks(
"abcd",
&[span(2, 4, CellColor::Indexed(1))],
&[],
&[adornment(2, AdornmentPlacement::AtOffset, "X")],
);
assert_eq!(chunk_texts(&chunks), vec!["ab", "X", "cd"]);
assert!(chunks[0].color.is_none());
assert!(
chunks[1].color.is_some(),
"default-styled virtual text should render as muted adornment text"
);
assert!(
chunks[2].color.is_some(),
"source styling must still begin at source byte 2"
);
}
#[test]
fn inline_adornment_does_not_shift_source_decoration_ranges() {
let chunks = projected_rich_chunks(
"abcd",
&[],
&[Decoration {
range: ByteRange { start: 2, end: 4 },
kind: DecorationKind::DiagnosticError,
}],
&[adornment(2, AdornmentPlacement::AtOffset, "X")],
);
assert_eq!(chunk_texts(&chunks), vec!["ab", "X", "cd"]);
assert!(chunks[0].color.is_none());
assert!(
chunks[1].color.is_some(),
"default-styled virtual text should render as muted adornment text"
);
assert!(
chunks[2].color.is_some(),
"diagnostic fg override must still begin at source byte 2"
);
}
#[test]
fn unsupported_adornment_placements_are_ignored_for_session_6() {
let chunks = projected_rich_chunks(
"abcd",
&[],
&[],
&[
adornment(0, AdornmentPlacement::BeforeLine, "before"),
adornment(4, AdornmentPlacement::EndOfLine, "end"),
resource_adornment(2, AdornmentPlacement::AtOffset),
],
);
assert_eq!(chunk_texts(&chunks), vec!["abcd"]);
}
#[test]
fn adornment_anchor_past_end_clamps_to_end() {
let chunks = projected_rich_chunks(
"abcd",
&[],
&[],
&[adornment(99, AdornmentPlacement::AtOffset, "X")],
);
assert_eq!(chunk_texts(&chunks), vec!["abcd", "X"]);
}
}

View File

@ -33,6 +33,7 @@
//! `PMACS_FAKE_LSP_ROOT_SINK`, so a test can assert the
//! auto-attach path derives the project root from the opened file.
use std::collections::HashMap;
use std::io::{self, Read, Write};
#[allow(
@ -48,6 +49,7 @@ fn main() {
let mut stdin = io::stdin().lock();
let mut stdout = io::stdout().lock();
let mut crashed_after_init = false;
let mut open_docs: HashMap<String, String> = HashMap::new();
loop {
let body = match read_frame(&mut stdin) {
Ok(Some(b)) => b,
@ -124,6 +126,7 @@ fn main() {
"hoverProvider": true,
"completionProvider": { "triggerCharacters": ["."] },
"definitionProvider": true,
"inlayHintProvider": true,
"documentFormattingProvider": true,
"diagnosticProvider": { "interFileDependencies": false, "workspaceDiagnostics": false },
"semanticTokensProvider": {
@ -348,6 +351,24 @@ fn main() {
.and_then(|t| t.get("uri"))
.cloned()
.unwrap_or(serde_json::Value::Null);
if let Some(uri_s) = uri.as_str() {
let text = if method == "textDocument/didOpen" {
params
.get("textDocument")
.and_then(|t| t.get("text"))
.and_then(serde_json::Value::as_str)
} else {
params
.get("contentChanges")
.and_then(serde_json::Value::as_array)
.and_then(|a| a.first())
.and_then(|c| c.get("text"))
.and_then(serde_json::Value::as_str)
};
if let Some(text) = text {
open_docs.insert(uri_s.to_owned(), text.to_owned());
}
}
let echo = serde_json::json!({
"jsonrpc": "2.0",
"method": "pmacs/echo",
@ -756,6 +777,17 @@ fn main() {
write_frame(&mut stdout, &resp);
}
("textDocument/inlayHint", Some(idv)) => {
if mode == "inlaybounds"
&& let Some(message) = inlay_range_error(&params, &open_docs)
{
let resp = serde_json::json!({
"jsonrpc": "2.0",
"id": idv,
"error": { "code": -32603, "message": message }
});
write_frame(&mut stdout, &resp);
continue;
}
// T M4.5: a type hint (string label, kind 1) and a
// parameter hint (label *parts*, kind 2) so both
// label shapes are exercised.
@ -906,3 +938,39 @@ fn write_garbage() {
let _ = stdout.write_all(b"NotAValidLspFrame\r\nGarbageHeader\r\n\r\n{}");
let _ = stdout.flush();
}
fn document_end_position(text: &str) -> (u64, u64) {
let mut line = 0;
let mut col = 0;
for byte in text.bytes() {
if byte == b'\n' {
line += 1;
col = 0;
} else {
col += 1;
}
}
(line, col)
}
fn inlay_range_error(
params: &serde_json::Value,
open_docs: &HashMap<String, String>,
) -> Option<String> {
let uri = params
.get("textDocument")
.and_then(|t| t.get("uri"))
.and_then(serde_json::Value::as_str)?;
let text = open_docs.get(uri)?;
let (last_line, last_col) = document_end_position(text);
let end = params.get("range")?.get("end")?;
let line = end.get("line")?.as_u64()?;
let col = end.get("character")?.as_u64()?;
if line > last_line || (line == last_line && col > last_col) {
Some(format!(
"invalid inlay range end {line}:{col}; document ends at {last_line}:{last_col}"
))
} else {
None
}
}

View File

@ -2037,8 +2037,8 @@ impl LspManager {
let params = json!({
"textDocument": { "uri": uri.clone() },
"range": {
"start": { "line": start_line, "character": start_col },
"end": { "line": end_line, "character": end_col },
"start": self.outbound_position(sid, &uri, start_line, start_col),
"end": self.outbound_position(sid, &uri, end_line, end_col),
},
});
let req_id = self.send_request(sid, "textDocument/inlayHint", params)?;

View File

@ -3983,14 +3983,14 @@ fn m4_15_workspace_edit_resource_ops_apply_in_order() {
#[test]
fn m4_16_lua_surface_drives_inlay_hints() {
let mut s = pmacs::editor::EditorState::new();
spawn_lsp_and_init(&mut s, None);
spawn_lsp_and_init(&mut s, Some("inlaybounds"));
let uri = "file:///tmp/m4_16_inlay.rs";
s.lua_host
.lua()
.load(format!(
"pmacs.lsp.did_open(_G._lsp, '{uri}', 1, 'let x = 1\\nfn f() {{}}\\n')
pmacs.lsp.request_inlay_hint(_G._lsp, '{uri}', 0, 0, 5, 0)"
pmacs.lsp.request_inlay_hint(_G._lsp, '{uri}', 0, 0, 2, 0)"
))
.exec()
.expect("kick off inlay hint request");
@ -4151,6 +4151,57 @@ fn m4_18_inlay_hint_refresh_repulls_via_server_request() {
);
}
/// Session 6 follow-up: the GPU consumer can only render
/// `InlineAdornments` once the LSP inlay-hint store has data. A
/// server is not required to send `workspace/inlayHint/refresh` after
/// initialize, so the default LSP runtime should pull hints once for
/// an attached document when the server reaches `initialized`.
#[test]
fn m4_18b_inlay_hints_auto_pull_after_initialize() {
use pmacs::editor::EditorState;
let dir = tempfile::tempdir().expect("tempdir");
let a_path = dir.path().join("a.rs");
std::fs::write(&a_path, b"fn a() {}\n\n").expect("write a");
let a_disp = a_path.display().to_string();
let mut state = EditorState::new();
let fake = fake_lsp_path();
state
.lua_host
.lua()
.load(format!(
"pmacs.lsp.config.rust = {{
command = '{fake}',
env = {{ PMACS_FAKE_LSP_MODE = 'inlaybounds' }},
}}"
))
.exec()
.expect("override rust config");
state
.lua_host
.lua()
.load(format!("pmacs.buffer.find_or_open('{a_disp}')"))
.exec()
.expect("open a.rs");
let flag = format!(
"(function() \
local sid \
for _,r in ipairs(pmacs.lsp.list()) do \
if r.state and r.state.kind=='initialized' then sid=r.id end \
end \
if not sid then return false end \
local h = pmacs.inlay_hint.hints(sid, 'file://{a_disp}') \
return h ~= nil and #h > 0 \
end)()"
);
assert!(
pump_lua_flag(&mut state, &flag, 5),
"initialized inlay-capable server did not auto-pull hints into the store"
);
}
/// T M4.5 — server-driven semantic-tokens refresh. The
/// `semantictokensrefresh` fake sends `workspace/semanticTokens/
/// refresh` right after `initialized`; the pump must answer it and
@ -4965,6 +5016,73 @@ fn m4_28_real_clangd_diagnostics_and_semantic_tokens_via_auto_attach() {
assert_no_lsp_crash(&mut state, "clangd");
}
/// PATH-gated real-server hardening for Session 6: rust-analyzer
/// rejects over-wide `textDocument/inlayHint` ranges instead of
/// clamping them. The default-bundle auto-attach path must therefore
/// pull inlay hints over the exact document end, otherwise
/// `pmacs-gpu` receives no `InlineAdornments` for ordinary Rust files.
#[test]
fn m4_29_real_rust_analyzer_inlay_hints_via_auto_attach() {
use pmacs::editor::EditorState;
let Ok(rust_analyzer) = which_binary("rust-analyzer") else {
eprintln!("rust-analyzer not on PATH; skipping");
return;
};
let rust_analyzer = rust_analyzer.display().to_string();
let dir = tempfile::tempdir().expect("tempdir");
let root = std::fs::canonicalize(dir.path()).expect("canonicalize");
std::fs::create_dir(root.join("src")).expect("mkdir src");
std::fs::write(
root.join("Cargo.toml"),
b"[package]\nname = \"pmacs_ra_inlay_hardening\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.expect("Cargo.toml");
let src = "use std::collections::HashMap;\n\
\n\
fn main() {\n\
\tlet answer = 42;\n\
\tlet pi = 3.14;\n\
\tlet mut counts = HashMap::new();\n\
\tcounts.insert(\"a\", 1);\n\
\tlet _g = format_pair(answer, pi);\n\
}\n\
\n\
fn format_pair(n: i32, x: f64) -> String {\n\
\tformat!(\"{n}-{x}\")\n\
}\n";
let file = root.join("src/main.rs");
std::fs::write(&file, src).expect("main.rs");
let root_disp = root.display().to_string();
let file_disp = file.display().to_string();
let uri = format!("file://{file_disp}");
let mut state = EditorState::new();
real_server_open_and_init(&mut state, "rust", &rust_analyzer, &root_disp, &file_disp);
assert!(
pump_lua_flag(
&mut state,
&format!(
"(function() \
local sid \
for _,r in ipairs(pmacs.lsp.list()) do \
if r.state and r.state.kind=='initialized' then sid=r.id end \
end \
if not sid then return false end \
local h = pmacs.inlay_hint.hints(sid, '{uri}') \
return h ~= nil and #h > 0 \
end)()"
),
30,
),
"real rust-analyzer returned no inlay hints via auto-attach"
);
assert_no_lsp_crash(&mut state, "rust-analyzer");
}
/// Default LSP bundle (`builtin/runtime/lsp.lua`) is wired in: the
/// hooks are defined, the namespace tables exist, the user-facing
/// commands are registered with the command registry, and the default