From 1a0ccd024742095c4e77f89717216dece620ebcc Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 19 May 2026 12:58:36 -0400 Subject: [PATCH] T M4.5: inlay hints (textDocument/inlayHint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent LSP feature (not part of the L1-L4 cross-file arc), shipped in the same shape as every sibling: typed store + async request + Lua surface + command + modeline summary, with the inline renderer deferred as its own milestone. - src/inlay_hint.rs: parse InlayHint[]|null — position, label (string OR InlayHintLabelPart[] flattened), kind (type/ parameter), paddingLeft/Right, tooltip (string|MarkupContent). Store keyed (server, uri). 5 unit tests. - src/lsp.rs: inlay_hint_store + accessor, ResponseRoute::InlayHint + absorb, request_inlay_hint (range params), textDocument. inlayHint client capability (no resolveSupport/refreshSupport — full hints, on-demand re-query is the v1 model). - src/lua_bindings.rs: _request_inlay_hint_raw, pmacs.inlay_hint.{hints,clear}. - pmacs_fake_lsp.rs: textDocument/inlayHint arm returning a string-label type hint and a label-parts parameter hint. - builtin/runtime/lsp.lua: pmacs.lsp.inlay_hints() requests over the whole-buffer range, stores, modeline summary; lsp.inlay-hints command + C-c i; scope header notes the inline renderer is a later milestone. - tests/m4_acceptance.rs: m4_16 drives the request via the Lua surface, asserts both label shapes / kinds / padding parsed. Deferred (scoping, not a regression): inline virtual-text rendering. The VirtualCellOverlay model only overwrites existing cells; rendering hints inline needs a column-inserting/reflowing renderer — a rendering milestone, not an LSP task — staged like the hover panel / references list. pmacs.inlay_hint is the data surface a future render layer subscribes to. Gates: lib 1279/0, m4 71/0, m8_1 10/0, m8_9 26/0, m8_10 19/0, m9_1 18/0, m5_8 5/0, m11_5 (--features crdt) 2/0; fmt + clippy clean. Co-Authored-By: Claude Opus 4.7 --- builtin/runtime/lsp.lua | 57 +++++++- src/bin/pmacs_fake_lsp.rs | 27 ++++ src/inlay_hint.rs | 294 ++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/lsp.rs | 61 ++++++++ src/lua_bindings.rs | 91 ++++++++++++ tests/m4_acceptance.rs | 58 ++++++++ 7 files changed, 586 insertions(+), 3 deletions(-) create mode 100644 src/inlay_hint.rs diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 8bdab05..85adba5 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -5,7 +5,8 @@ -- * Auto-attach + did_open / did_change / did_close on buffer events. -- * `pmacs.lsp.go_to_definition` / `pmacs.lsp.format_buffer` / -- `pmacs.lsp.hover_at_cursor` / `pmacs.lsp.signature_help_at_cursor` --- / `pmacs.lsp.rename` / `pmacs.lsp.code_actions`, bound to +-- / `pmacs.lsp.rename` / `pmacs.lsp.code_actions` / +-- `pmacs.lsp.inlay_hints`, bound to -- default chords below. -- -- Scope: one server per language across all buffers; async-await @@ -14,8 +15,9 @@ -- (L2), code actions + `workspace/executeCommand` + server→client -- `workspace/applyEdit` (L3), ordered resource-op edits -- (create/rename/delete file) with buffer-registry reconciliation --- (L4). Inlay hints, semantic tokens, and file-watch capability --- registration are later layers. +-- (L4), and inlay hints (data + modeline; inline virtual-text +-- rendering is a later milestone). Semantic tokens and file-watch +-- capability registration are later layers. pmacs.lsp = pmacs.lsp or {} pmacs.lsp.config = pmacs.lsp.config or {} @@ -270,6 +272,7 @@ pmacs.lsp.request_document_highlight = wrap_request(pmacs.lsp._request_document_ pmacs.lsp.request_rename = wrap_request(pmacs.lsp._request_rename_raw) pmacs.lsp.request_code_action = wrap_request(pmacs.lsp._request_code_action_raw) pmacs.lsp.request_execute_command = wrap_request(pmacs.lsp._request_execute_command_raw) +pmacs.lsp.request_inlay_hint = wrap_request(pmacs.lsp._request_inlay_hint_raw) -- Render an `:await()` failure into a modeline-friendly reason. -- `Handle:await()` raises `{ tag = "cancelled", ... }` when the @@ -620,6 +623,47 @@ function pmacs.lsp.document_symbols() end) end +-- T M4.5 — inlay hints for the whole buffer. Requests over a range +-- spanning the document, stores the parsed hints, and surfaces a +-- modeline summary (count + first). Inline virtual-text rendering is +-- a separate milestone (the cell-overlay model does not yet reflow +-- real glyphs around inserted columns); a render layer subscribes to +-- the same `pmacs.inlay_hint` store when it lands — same staged +-- approach as the references list / hover panel. +function pmacs.lsp.inlay_hints() + local rec = attached_for_active() + if not rec then + 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. + local text = active_buffer_text() + local nl = 0 + for _ in text:gmatch("\n") do nl = nl + 1 end + 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() + end) + if not ok then + pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) + return + end + local hints = pmacs.inlay_hint.hints(rec.server, rec.uri) + if not hints or #hints == 0 then + pmacs.editor.set_status("LSP: no inlay hints") + return + end + local first = hints[1] + pmacs.editor.set_status(string.format( + "LSP: %d inlay hint%s; first '%s' at %d:%d", + #hints, (#hints == 1 and "" or "s"), + first.label, first.line + 1, first.col + 1)) + end) +end + function pmacs.lsp.format_buffer() local rec = attached_for_active() if not rec then @@ -871,6 +915,12 @@ pmacs.command.define { fn = pmacs.lsp.code_actions, } +pmacs.command.define { + name = "lsp.inlay-hints", + description = "Fetch inlay hints (inferred types / parameter names) for the buffer (LSP).", + fn = pmacs.lsp.inlay_hints, +} + -- T M4.5 L1 — unwind the cross-file jump ring. Pairs with the -- `pmacs.editor.push_jump()` every navigation action records before -- it moves the cursor. @@ -894,6 +944,7 @@ pmacs.keymap.bind { scope = "global", sequence = "M-,", command = "lsp.jump-ba pmacs.keymap.bind { scope = "global", sequence = "C-c o", command = "lsp.document-symbols" } pmacs.keymap.bind { scope = "global", sequence = "C-c r", command = "lsp.rename" } pmacs.keymap.bind { scope = "global", sequence = "C-c a", command = "lsp.code-actions" } +pmacs.keymap.bind { scope = "global", sequence = "C-c i", command = "lsp.inlay-hints" } pmacs.keymap.bind { scope = "global", sequence = "C-c h", command = "lsp.hover" } pmacs.keymap.bind { scope = "global", sequence = "C-c s", command = "lsp.signature-help" } pmacs.keymap.bind { scope = "global", sequence = "C-c f", command = "lsp.format-buffer" } diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs index f877721..f5bffbb 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -584,6 +584,33 @@ fn main() { }); write_frame(&mut stdout, &resp); } + ("textDocument/inlayHint", Some(idv)) => { + // T M4.5: a type hint (string label, kind 1) and a + // parameter hint (label *parts*, kind 2) so both + // label shapes are exercised. + let resp = serde_json::json!({ + "jsonrpc": "2.0", + "id": idv, + "result": [ + { + "position": { "line": 0, "character": 9 }, + "label": ": i32", + "kind": 1, + "paddingLeft": false, + "paddingRight": false, + "tooltip": "inferred type" + }, + { + "position": { "line": 1, "character": 4 }, + "label": [ { "value": "count" }, { "value": ":" } ], + "kind": 2, + "paddingLeft": false, + "paddingRight": true + } + ] + }); + write_frame(&mut stdout, &resp); + } // T M4.5 symbols/highlight. documentSymbol returns the // *hierarchical* DocumentSymbol shape (exercises tree // flatten + depth + parent); workspace/symbol the flat diff --git a/src/inlay_hint.rs b/src/inlay_hint.rs new file mode 100644 index 0000000..d336519 --- /dev/null +++ b/src/inlay_hint.rs @@ -0,0 +1,294 @@ +// inlay_hint.rs --- T M4.5 LSP inlay hints. + +//! `textDocument/inlayHint` response state. +//! +//! An inlay hint is a small annotation the server wants drawn *inline* +//! at a position (a parameter name before an argument, an inferred +//! type after a `let`). The label is either a plain string or an +//! array of `InlayHintLabelPart`s (each carrying its own `value`, +//! tooltip, location, command); this module flattens the parts' +//! `value`s into one display string — the part-level interactivity +//! (go-to-def on a type in a hint) is later UX, like the hover panel. +//! +//! Mirrors [`crate::formatting`] / [`crate::code_action`]: a parsed, +//! per-`(server, uri)` store. Nothing here renders: the inline +//! virtual-text renderer is a separate milestone (the cell-overlay +//! model does not yet reflow real glyphs around inserted columns). +//! Lua reads [`InlayHintStore`] and surfaces hints; a render layer can +//! subscribe to the same store when it lands. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use serde_json::Value; + +/// LSP `InlayHintKind`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InlayHintKind { + /// `1` — an inferred type. + Type, + /// `2` — a parameter name. + Parameter, +} + +impl InlayHintKind { + fn from_lsp(n: u64) -> Option { + match n { + 1 => Some(Self::Type), + 2 => Some(Self::Parameter), + _ => None, + } + } + + /// Lowercase wire-ish label for the Lua surface. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Type => "type", + Self::Parameter => "parameter", + } + } +} + +/// One parsed inlay hint. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InlayHint { + /// Zero-based line the hint sits on. + pub line: u32, + /// Zero-based column (UTF-16 code units, per LSP). + pub col: u32, + /// Display text (label string, or all label parts concatenated). + pub label: String, + /// Hint kind, if the server classified it. + pub kind: Option, + /// Render a space before the label. + pub padding_left: bool, + /// Render a space after the label. + pub padding_right: bool, + /// Plain-text tooltip, if any (`MarkupContent` is flattened to + /// its `value`). + pub tooltip: Option, +} + +/// Parsed `textDocument/inlayHint` response. +#[derive(Clone, Debug, Default)] +pub struct InlayHintResponse { + /// Hints in server order. + pub hints: Vec, +} + +impl InlayHintResponse { + /// Parse `InlayHint[] | null`. A `null` / non-array result yields + /// an empty list. + #[must_use] + pub fn from_lsp_value(v: &Value) -> Self { + let Some(arr) = v.as_array() else { + return Self::default(); + }; + let mut hints = Vec::with_capacity(arr.len()); + for item in arr { + if let Some(h) = parse_hint(item) { + hints.push(h); + } + } + Self { hints } + } + + /// True iff the server returned no hints. + #[must_use] + pub fn is_empty(&self) -> bool { + self.hints.is_empty() + } +} + +/// `label: string | InlayHintLabelPart[]` → one string. +fn parse_label(v: &Value) -> Option { + if let Some(s) = v.as_str() { + return Some(s.to_owned()); + } + let parts = v.as_array()?; + let mut out = String::new(); + for p in parts { + if let Some(s) = p.get("value").and_then(Value::as_str) { + out.push_str(s); + } + } + Some(out) +} + +/// `tooltip: string | MarkupContent` → plain text. +fn parse_tooltip(v: &Value) -> Option { + if let Some(s) = v.as_str() { + return Some(s.to_owned()); + } + v.get("value").and_then(Value::as_str).map(str::to_owned) +} + +fn parse_hint(v: &Value) -> Option { + let pos = v.get("position")?; + let line = pos.get("line")?.as_u64()? as u32; + let col = pos.get("character")?.as_u64()? as u32; + let label = parse_label(v.get("label")?)?; + Some(InlayHint { + line, + col, + label, + kind: v + .get("kind") + .and_then(Value::as_u64) + .and_then(InlayHintKind::from_lsp), + padding_left: v + .get("paddingLeft") + .and_then(Value::as_bool) + .unwrap_or(false), + padding_right: v + .get("paddingRight") + .and_then(Value::as_bool) + .unwrap_or(false), + tooltip: v.get("tooltip").and_then(parse_tooltip), + }) +} + +/// Per-server, per-uri inlay-hint state. +#[derive(Default)] +pub struct InlayHintStore { + by_key: HashMap, +} + +/// Key into [`InlayHintStore`]. +#[derive(Clone, Eq, PartialEq, Hash, Debug)] +pub struct InlayHintKey { + /// Decimal LSP server id. + pub server: String, + /// Document URI the request was made on. + pub uri: String, +} + +impl InlayHintKey { + /// Construct a key. + #[must_use] + pub fn new(server: impl Into, uri: impl Into) -> Self { + Self { + server: server.into(), + uri: uri.into(), + } + } +} + +impl InlayHintStore { + /// Empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Replace the response at `key`. + pub fn set(&mut self, key: InlayHintKey, response: InlayHintResponse) { + self.by_key.insert(key, response); + } + + /// Drop the entry at `key`. + pub fn clear(&mut self, key: &InlayHintKey) { + self.by_key.remove(key); + } + + /// Look up the entry at `key`. + #[must_use] + pub fn get(&self, key: &InlayHintKey) -> Option<&InlayHintResponse> { + self.by_key.get(key) + } +} + +/// Cheaply-cloneable shared handle. +pub type SharedInlayHintStore = Arc>; + +/// Build a fresh shared store. +#[must_use] +pub fn make_shared_store() -> SharedInlayHintStore { + Arc::new(Mutex::new(InlayHintStore::new())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_string_label_type_hint() { + let v = json!([{ + "position": { "line": 3, "character": 12 }, + "label": ": String", + "kind": 1, + "paddingLeft": false, + "paddingRight": true, + "tooltip": "inferred type" + }]); + let r = InlayHintResponse::from_lsp_value(&v); + assert_eq!(r.hints.len(), 1); + let h = &r.hints[0]; + assert_eq!((h.line, h.col), (3, 12)); + assert_eq!(h.label, ": String"); + assert_eq!(h.kind, Some(InlayHintKind::Type)); + assert!(!h.padding_left); + assert!(h.padding_right); + assert_eq!(h.tooltip.as_deref(), Some("inferred type")); + } + + #[test] + fn concatenates_label_parts_for_parameter_hint() { + let v = json!([{ + "position": { "line": 0, "character": 7 }, + "label": [ { "value": "name" }, { "value": ":" } ], + "kind": 2, + "tooltip": { "kind": "markdown", "value": "the param" } + }]); + let r = InlayHintResponse::from_lsp_value(&v); + let h = &r.hints[0]; + assert_eq!(h.label, "name:"); + assert_eq!(h.kind, Some(InlayHintKind::Parameter)); + assert_eq!(h.tooltip.as_deref(), Some("the param")); + } + + #[test] + fn unknown_kind_and_missing_optionals_default() { + let v = json!([{ + "position": { "line": 1, "character": 1 }, + "label": "x", + "kind": 99 + }]); + let r = InlayHintResponse::from_lsp_value(&v); + let h = &r.hints[0]; + assert_eq!(h.kind, None); + assert!(!h.padding_left); + assert!(!h.padding_right); + assert!(h.tooltip.is_none()); + } + + #[test] + fn null_response_is_empty() { + assert!(InlayHintResponse::from_lsp_value(&Value::Null).is_empty()); + } + + #[test] + fn store_set_get_clear() { + let mut s = InlayHintStore::new(); + let key = InlayHintKey::new("1", "file:///a"); + s.set( + key.clone(), + InlayHintResponse { + hints: vec![InlayHint { + line: 0, + col: 0, + label: "h".into(), + kind: None, + padding_left: false, + padding_right: false, + tooltip: None, + }], + }, + ); + assert_eq!(s.get(&key).unwrap().hints.len(), 1); + s.clear(&key); + assert!(s.get(&key).is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 3b1dc19..685b954 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,6 +63,7 @@ pub mod help; pub mod highlight; pub mod hook; pub mod hover; +pub mod inlay_hint; pub mod instance_buffer; pub mod instance_render; pub mod key; diff --git a/src/lsp.rs b/src/lsp.rs index 0576f39..eff73ae 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -763,6 +763,10 @@ pub struct LspManager { /// `textDocument/codeAction` response lands, keyed by `(server, /// uri)`. code_action_store: crate::code_action::SharedCodeActionStore, + /// T M4.5 inlay-hint store. Populated when a + /// `textDocument/inlayHint` response lands, keyed by `(server, + /// uri)`. + inlay_hint_store: crate::inlay_hint::SharedInlayHintStore, /// Per-server request id → response routing target. /// `request_completion` etc. record an entry here; `handle_response` /// consumes it to absorb the response into the correct store. @@ -826,6 +830,9 @@ enum ResponseRoute { /// Absorb a `textDocument/codeAction` response into /// [`crate::code_action::CodeActionStore`] at `(server, uri)`. CodeAction { uri: String }, + /// Absorb a `textDocument/inlayHint` response into + /// [`crate::inlay_hint::InlayHintStore`] at `(server, uri)`. + InlayHint { uri: String }, /// Absorb a Location-shaped nav response (references / declaration /// / typeDefinition / implementation) into /// [`crate::locations::LocationsStore`] at `(server, uri, kind)`. @@ -869,6 +876,7 @@ impl ResponseRoute { | ResponseRoute::Formatting { uri } | ResponseRoute::Rename { uri } | ResponseRoute::CodeAction { uri } + | ResponseRoute::InlayHint { uri } | ResponseRoute::Locations { uri, .. } | ResponseRoute::DocumentSymbol { uri } | ResponseRoute::DocumentHighlight { uri } => uri, @@ -952,6 +960,7 @@ impl LspManager { formatting_store: crate::formatting::make_shared_store(), rename_store: crate::rename::make_shared_store(), code_action_store: crate::code_action::make_shared_store(), + inlay_hint_store: crate::inlay_hint::make_shared_store(), pending_routes: HashMap::new(), status_tracker: crate::lsp_status::LspStatusTracker::new(), project_servers: HashMap::new(), @@ -1030,6 +1039,12 @@ impl LspManager { self.code_action_store.clone() } + /// Shared inlay-hint store (T M4.5). + #[must_use] + pub fn inlay_hint_store(&self) -> crate::inlay_hint::SharedInlayHintStore { + self.inlay_hint_store.clone() + } + /// T M4.8: per-server status snapshot, derived from the LSP event /// stream. The modeline reads its label from this. #[must_use] @@ -1808,6 +1823,36 @@ impl LspManager { Ok(job_id) } + /// Send `textDocument/inlayHint` over `[start, end]` in `uri` + /// (the visible/whole-buffer range the caller wants annotated). + /// The response is absorbed into the inlay-hint store at `(sid, + /// uri)`. Returns the async-runtime [`JobId`] the response will + /// settle. + #[allow(clippy::too_many_arguments)] + pub fn request_inlay_hint( + &mut self, + sid: LspServerId, + uri: impl Into, + start_line: u32, + start_col: u32, + end_line: u32, + end_col: u32, + ) -> Result { + let uri = uri.into(); + let params = json!({ + "textDocument": { "uri": uri.clone() }, + "range": { + "start": { "line": start_line, "character": start_col }, + "end": { "line": end_line, "character": end_col }, + }, + }); + let req_id = self.send_request(sid, "textDocument/inlayHint", params)?; + let job_id = self.register_awaiter(sid, req_id, "textDocument/inlayHint", &uri); + self.pending_routes + .insert((sid, req_id), ResponseRoute::InlayHint { uri }); + Ok(job_id) + } + /// Send `workspace/executeCommand`. No response route is /// registered: the command result is usually `null` and the real /// effect arrives as a server→client `workspace/applyEdit` (the @@ -2426,6 +2471,15 @@ impl LspManager { .expect("code action store mutex poisoned"); guard.set(key, resp); } + ResponseRoute::InlayHint { uri } => { + let resp = crate::inlay_hint::InlayHintResponse::from_lsp_value(result); + let key = crate::inlay_hint::InlayHintKey::new(server_key, uri.clone()); + let mut guard = self + .inlay_hint_store + .lock() + .expect("inlay hint store mutex poisoned"); + guard.set(key, resp); + } } } @@ -2870,6 +2924,13 @@ fn default_capabilities() -> Value { }, }, }, + // T M4.5 inlay hints. No `resolveSupport` — pmacs + // requests full hints (label/tooltip already populated), + // not lazily-resolved stubs. `refreshSupport` is left + // false (default) so servers don't send + // `workspace/inlayHint/refresh`; on-demand re-query is + // the v1 model. + "inlayHint": { "dynamicRegistration": false }, "publishDiagnostics": { "relatedInformation": true }, }, }) diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index cf6541b..8672b94 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -7529,6 +7529,30 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { )?; } + { + let m = manager.clone(); + lsp_mod.set( + "_request_inlay_hint_raw", + lua.create_function( + move |_, + (id, uri, sl, sc, el, ec): ( + LspServerIdLua, + String, + u32, + u32, + u32, + u32, + )| { + let job_id = m + .borrow_mut() + .request_inlay_hint(id.0, uri, sl, sc, el, ec) + .map_err(mlua::Error::external)?; + Ok(job_id) + }, + )?, + )?; + } + { let m = manager.clone(); lsp_mod.set( @@ -7804,6 +7828,7 @@ pub fn make_lsp_manager( install_formatting(lua, &manager)?; install_rename(lua, &manager)?; install_code_action(lua, &manager)?; + install_inlay_hint(lua, &manager)?; Ok(manager) } @@ -8604,6 +8629,7 @@ use crate::definition::{DefinitionKey, DefinitionLocation, DefinitionResponse}; use crate::document_highlight::{DocumentHighlightKey, Highlight}; use crate::formatting::{FormattingKey, FormattingResponse, TextEdit}; use crate::hover::{Hover, HoverKey}; +use crate::inlay_hint::{InlayHint as LspInlayHint, InlayHintKey}; use crate::locations::{LocationKind, LocationsKey}; use crate::rename::{RenameKey, WorkspaceEditResponse, WorkspaceOp}; use crate::signature::{Signature, SignatureHelp, SignatureKey, SignatureParameter}; @@ -9483,6 +9509,71 @@ pub fn install_code_action(lua: &Lua, manager: &SharedLspManager) -> mlua::Resul Ok(()) } +fn inlay_hint_to_lua(lua: &Lua, h: &LspInlayHint) -> mlua::Result { + let t = lua.create_table_with_capacity(0, 7)?; + t.set("line", h.line)?; + t.set("col", h.col)?; + t.set("label", h.label.as_str())?; + if let Some(k) = h.kind { + t.set("kind", k.as_str())?; + } + t.set("padding_left", h.padding_left)?; + t.set("padding_right", h.padding_right)?; + if let Some(tt) = h.tooltip.as_deref() { + t.set("tooltip", tt)?; + } + Ok(t) +} + +/// Install `pmacs.inlay_hint.*` (T M4.5). `hints(sid, uri)` returns +/// `{ { line, col, label, kind?, padding_left, padding_right, +/// tooltip? }, … }` in server order; `clear(sid, uri)` drops the +/// entry. No renderer here — that is a separate milestone; this is +/// the data surface a render layer (or a list view) reads. +pub fn install_inlay_hint(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> { + let pmacs: Table = lua.globals().get("pmacs")?; + let m = lua.create_table()?; + + { + let mgr = manager.clone(); + m.set( + "hints", + lua.create_function(move |lua, (id, uri): (LspServerIdLua, String)| { + let store_handle = mgr.borrow().inlay_hint_store(); + let guard = store_handle + .lock() + .expect("inlay hint store mutex poisoned"); + let key = InlayHintKey::new(id.0.raw().to_string(), uri); + let out = lua.create_table()?; + if let Some(r) = guard.get(&key) { + for (i, h) in r.hints.iter().enumerate() { + out.set(i + 1, inlay_hint_to_lua(lua, h)?)?; + } + } + Ok(Value::Table(out)) + })?, + )?; + } + + { + let mgr = manager.clone(); + m.set( + "clear", + lua.create_function(move |_, (id, uri): (LspServerIdLua, String)| { + let store_handle = mgr.borrow().inlay_hint_store(); + let mut guard = store_handle + .lock() + .expect("inlay hint store mutex poisoned"); + guard.clear(&InlayHintKey::new(id.0.raw().to_string(), uri)); + Ok(()) + })?, + )?; + } + + pmacs.set("inlay_hint", m)?; + Ok(()) +} + // --------------------------------------------------------------------------- // pmacs.project: project model (T M4.9) // --------------------------------------------------------------------------- diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 5829928..3a5822e 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -3746,6 +3746,64 @@ fn m4_15_workspace_edit_resource_ops_apply_in_order() { ); } +/// T M4.5 — inlay hints through the Lua surface. Drives +/// `pmacs.lsp.request_inlay_hint` against the fake and asserts the +/// typed `pmacs.inlay_hint` store parsed both label shapes (a +/// string-label type hint and a label-parts parameter hint), the +/// kinds, and `paddingRight`. +#[test] +fn m4_16_lua_surface_drives_inlay_hints() { + let mut s = pmacs::editor::EditorState::new(); + spawn_lsp_and_init(&mut s, None); + + 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)" + )) + .exec() + .expect("kick off inlay hint request"); + + assert!( + pump_lua_flag( + &mut s, + &format!("#pmacs.inlay_hint.hints(_G._lsp, '{uri}') > 0"), + 5, + ), + "inlay hint response did not land in the store" + ); + + let (count, l0, c0, label0, kind0, label1, kind1, pad1): ( + usize, + u32, + u32, + String, + String, + String, + String, + bool, + ) = s + .lua_host + .lua() + .load(format!( + "local h = pmacs.inlay_hint.hints(_G._lsp, '{uri}') + return #h, h[1].line, h[1].col, h[1].label, h[1].kind, + h[2].label, h[2].kind, h[2].padding_right" + )) + .eval() + .expect("read inlay hints back"); + assert_eq!(count, 2); + assert_eq!((l0, c0), (0, 9)); + assert_eq!(label0, ": i32"); + assert_eq!(kind0, "type"); + // Label parts were concatenated. + assert_eq!(label1, "count:"); + assert_eq!(kind1, "parameter"); + assert!(pad1, "second hint requested paddingRight"); +} + /// 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