diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 03910ce..8cae97c 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -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)) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index e9f980e..11d0906 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -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, + /// 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, } impl ApplicationHandler 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 { 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 = 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 = 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, +} + +/// 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 { + let text_len = text.len() as u64; + let mut boundaries: Vec = 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 { + 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, + 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 { + 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 { | 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"]); + } +} diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs index e200b4e..78b00b3 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -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 = 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(¶ms, &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, +) -> Option { + 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 + } +} diff --git a/src/lsp.rs b/src/lsp.rs index 6143e2f..c9a2f7d 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -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)?; diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 5f5d154..b888c21 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -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