-- builtin/runtime/lsp.lua --- T M4.12 default LSP integration. -- -- Wires the LSP request/response surface into a usable editor UX: -- * Declarative server config (`pmacs.lsp.config[language]`). -- * 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` / -- `pmacs.lsp.inlay_hints`, bound to -- default chords below. -- -- Scope: one server per language across all buffers; async-await -- request/react (the editor never blocks). Landed: cross-file -- go-to-definition (L1), multi-file rename / WorkspaceEdit applier -- (L2), code actions + `workspace/executeCommand` + server→client -- `workspace/applyEdit` (L3), ordered resource-op edits -- (create/rename/delete file) with buffer-registry reconciliation -- (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 {} -- Default rust-analyzer config. Users replace any field from init.lua -- before any rust file opens. pmacs.lsp.config.rust = pmacs.lsp.config.rust or { command = "rust-analyzer", args = {}, init_options = { cargo = { allFeatures = true }, checkOnSave = { command = "clippy" }, procMacro = { enable = true }, }, } -- Default Python config: basedpyright (an MIT fork of pyright that -- re-enables inlay hints / semantic tokens in the open-source server, -- which upstream pyright withholds for Pylance). `--stdio` is the -- LSP transport. `settings` is answered to basedpyright's -- `workspace/configuration` pull (pmacs now advertises that -- capability) — `basic` keeps the diagnostics gutter from being -- flooded by the fork's stricter defaults. A project's -- `pyrightconfig.json` / `[tool.pyright]` still wins where present. -- Users override any field from init.lua before a .py opens; -- swapping to upstream pyright is just `command = "pyright-langserver"`. pmacs.lsp.config.python = pmacs.lsp.config.python or { command = "basedpyright-langserver", args = { "--stdio" }, settings = { python = { analysis = { typeCheckingMode = "basic" } }, basedpyright = { analysis = { typeCheckingMode = "basic" } }, }, } -- C / C++ via clangd. One clangd binary serves both; `config.c` and -- `config.cpp` are separate entries only so the `language_id` sent in -- `didOpen` is accurate (clangd respects it). clangd takes its -- project model from `compile_commands.json` / `compile_flags.txt` -- at the project root, not `workspace/configuration`, so no -- `settings` here; `--background-index` enables cross-file features. -- Users override from init.lua before a C/C++ file opens. pmacs.lsp.config.c = pmacs.lsp.config.c or { command = "clangd", args = { "--background-index" }, } pmacs.lsp.config.cpp = pmacs.lsp.config.cpp or { command = "clangd", args = { "--background-index" }, } -- Go via gopls. `gopls` with no args serves LSP over stdio. gopls -- pulls its configuration via `workspace/configuration` (now -- answered, #13) under the `gopls` section; an empty section means -- "use defaults" — present, not null, which gopls prefers. Users -- populate it (e.g. staticcheck, analyses) from init.lua. pmacs.lsp.config.go = pmacs.lsp.config.go or { command = "gopls", args = {}, settings = { gopls = {} }, } -- LSP-side extension → language map, deliberately independent of the -- tree-sitter detection in `pmacs.parse` (which is grammar-gated: -- Python has an LSP server but no bundled grammar). Consulted only -- when `pmacs.parse.language_for_path` finds nothing, so grammar- -- backed languages keep their existing detection. Extensible from -- init.lua: `pmacs.lsp.filetypes.foo = "bar"`. pmacs.lsp.filetypes = pmacs.lsp.filetypes or {} pmacs.lsp.filetypes.py = pmacs.lsp.filetypes.py or "python" pmacs.lsp.filetypes.pyi = pmacs.lsp.filetypes.pyi or "python" -- C. `.h` is ambiguous C/C++; default it to C (clangd copes either -- way, and users can remap `pmacs.lsp.filetypes.h = "cpp"`). pmacs.lsp.filetypes.c = pmacs.lsp.filetypes.c or "c" pmacs.lsp.filetypes.h = pmacs.lsp.filetypes.h or "c" -- C++. for _, ext in ipairs({ "cpp", "cc", "cxx", "hpp", "hh", "hxx", "ipp", "inl", "cppm" }) do pmacs.lsp.filetypes[ext] = pmacs.lsp.filetypes[ext] or "cpp" end -- Go. pmacs.lsp.filetypes.go = pmacs.lsp.filetypes.go or "go" -- Per-buffer attachment record: { language, server, uri, version }. -- Keyed by `tostring(BufferIdLua)` because BufferIdLua hands out fresh -- userdata each call (so two handles to the same buffer wouldn't hash -- equal as raw keys). local attachments = {} -- Minimal file:// percent-encoder. Matches src/lsp.rs's policy: ASCII -- alpha-num + a small set of path-safe punctuation pass through; every -- other byte goes through %XX. Iterates per-byte (`gmatch(".")` is -- byte-wise) so multibyte UTF-8 components encode cleanly. local function file_uri_for(path) if not path then return nil end local out = "file://" for ch in path:gmatch(".") do local b = string.byte(ch) if (b >= 48 and b <= 57) -- 0-9 or (b >= 65 and b <= 90) -- A-Z or (b >= 97 and b <= 122) -- a-z or b == 47 or b == 45 or b == 95 or b == 46 or b == 126 or b == 58 then out = out .. ch else out = out .. string.format("%%%02X", b) end end return out end local function active_buffer_text() local b = pmacs.window.buffer() if not b then return "" end return b:slice(0, b:len()) end local function active_buffer_path() return pmacs.editor.file_path() end local function active_buffer_language() local path = active_buffer_path() if not path then return nil end -- Grammar-backed detection first (keeps rust/.rs etc. exactly as -- before); fall back to the LSP-only filetype map so languages -- with a server but no tree-sitter grammar (Python) still attach. local lang = pmacs.parse.language_for_path(path) if lang then return lang end local ext = path:match("%.([%w_]+)$") return ext and pmacs.lsp.filetypes[ext] or nil end local function ensure_server(language) local cfg = pmacs.lsp.config[language] if not cfg or not cfg.command then return nil end -- Reuse an existing same-language server if one is up. Multi-root -- scoping (one server per project root) ships post-v0.1. for _, info in ipairs(pmacs.lsp.list()) do if info.language_id == language and info.state then local kind = info.state.kind if kind ~= "crashed" and kind ~= "stopped" then return info.id end end end local ok, sid = pcall(pmacs.lsp.spawn, { label = "default-" .. language, language_id = language, command = cfg.command, args = cfg.args or {}, env = cfg.env, init_options = cfg.init_options, settings = cfg.settings, }) if ok then return sid end return nil end -- True iff `sid` is still registered with the manager and isn't dead. -- Stale attachments — left behind by a server that crashed, was -- forgotten, or was spawned against a now-replaced `pmacs.lsp.config` -- entry — get rebuilt on the next attach attempt. local function server_is_live(sid) if not sid then return false end for _, info in ipairs(pmacs.lsp.list()) do if tostring(info.id) == tostring(sid) then local kind = info.state and info.state.kind return kind ~= "crashed" and kind ~= "stopped" end end return false end local function attach_buffer(buf) if not buf then return nil end local key = tostring(buf) local existing = attachments[key] if existing and server_is_live(existing.server) then return existing end if existing then attachments[key] = nil end local language = active_buffer_language() if not language then return nil end local sid = ensure_server(language) if not sid then return nil end local path = active_buffer_path() local uri = file_uri_for(path) if not uri then return nil end local rec = { 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`. pcall(pmacs.lsp.did_open, sid, uri, rec.version, active_buffer_text()) return rec end local function attached_for_active() local buf = pmacs.window.buffer() if not buf then return nil end return attachments[tostring(buf)] or attach_buffer(buf) end -- Hooks -------------------------------------------------------------------- pmacs.hook.add("buffer.after-load", function() pcall(attach_buffer, pmacs.window.buffer()) end) pmacs.hook.add("buffer.after-edit", function() local buf = pmacs.window.buffer() if not buf then return end local rec = attachments[tostring(buf)] if not rec then return end rec.version = rec.version + 1 pcall(pmacs.lsp.did_change, rec.server, rec.uri, rec.version, active_buffer_text()) end) -- Async request surface (T M4.5 async bridge). The Rust manager -- registers each `textDocument/*` request with the async runtime and -- returns a job id; the JSON-RPC response (or a server-teardown -- drain) settles it. `_request_*_raw` is the raw job-id-returning -- binding (mirrors `pmacs.mcp._send_request_raw`); the wrappers below -- hand back a `pmacs.workers` Handle whose `:await()` resumes the -- caller when the response lands. The pre-v1.0 `poll_until` tick-loop -- this replaced blocked the editor for the whole request; awaiting -- yields the coroutine instead. local workers_mod = pmacs.workers assert(workers_mod and workers_mod._new_handle, "pmacs.workers._new_handle missing; did async.lua load before lsp.lua?") assert(pmacs.lsp._request_completion_raw, "pmacs.lsp._request_completion_raw missing; lua_bindings::install_lsp not run?") local new_handle = workers_mod._new_handle local function wrap_request(raw) -- Raises on dispatch failure (e.g. "server not ready"), matching -- `mcp.lua`'s wrapper; callers pcall the `request():await()` chain. return function(...) return new_handle(raw(...)) end end pmacs.lsp.request_completion = wrap_request(pmacs.lsp._request_completion_raw) pmacs.lsp.request_hover = wrap_request(pmacs.lsp._request_hover_raw) pmacs.lsp.request_signature_help = wrap_request(pmacs.lsp._request_signature_help_raw) pmacs.lsp.request_definition = wrap_request(pmacs.lsp._request_definition_raw) pmacs.lsp.request_formatting = wrap_request(pmacs.lsp._request_formatting_raw) pmacs.lsp.request_references = wrap_request(pmacs.lsp._request_references_raw) pmacs.lsp.request_declaration = wrap_request(pmacs.lsp._request_declaration_raw) pmacs.lsp.request_type_definition = wrap_request(pmacs.lsp._request_type_definition_raw) pmacs.lsp.request_implementation = wrap_request(pmacs.lsp._request_implementation_raw) pmacs.lsp.request_document_symbol = wrap_request(pmacs.lsp._request_document_symbol_raw) pmacs.lsp.request_workspace_symbol = wrap_request(pmacs.lsp._request_workspace_symbol_raw) pmacs.lsp.request_document_highlight = wrap_request(pmacs.lsp._request_document_highlight_raw) 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 -- server went away mid-request (drain in `lsp.rs`) and -- `{ tag = "failed", message = ... }` for a JSON-RPC error response; -- a raw dispatch failure surfaces as a plain string. local function lsp_await_error(err) if type(err) == "table" then if err.tag == "cancelled" then return "server unavailable (request cancelled)" elseif err.tag == "failed" then return err.message or "server error" end return tostring(err.tag or "error") end return tostring(err) end -- Cursor positioning ------------------------------------------------------ -- -- LSP positions are 0-based (line, character). pmacs.editor.cursor_line -- and pmacs.editor.cursor_col already return 0-based byte counts; for -- ASCII / UTF-8 text without astral codepoints, byte == UTF-16 code -- unit, which is what every shipped server actually accepts. Multi-byte -- conversion lands with the v0.2 LSP hardening pass. local function move_active_cursor_to(line, col) -- Walk via primitives so all overlay observers see the navigation. pmacs.editor.move_line_start() -- Move to row 0 first, then step down `line` rows. while pmacs.editor.cursor_line() > 0 do pmacs.editor.move_up() end for _ = 1, line do pmacs.editor.move_down() end for _ = 1, col do pmacs.editor.move_right() end end -- Compute the byte offset of (line, col) within `text` where lines are -- separated by `\n`. Used by `apply_text_edits` to map LSP coordinates -- to byte positions on the rope. local function byte_offset_for(text, line, col) if line == 0 then return col end local pos = 0 local current_line = 0 while current_line < line do local nl = text:find("\n", pos + 1, true) if not nl then return #text end pos = nl current_line = current_line + 1 end return pos + col end local function apply_text_edits(edits) if not edits or #edits == 0 then return 0 end local buf = pmacs.window.buffer() if not buf then return 0 end local text = active_buffer_text() -- Resolve every edit against the *original* text, then sort by start -- byte descending so each replacement leaves earlier offsets valid. local resolved = {} for _, e in ipairs(edits) do table.insert(resolved, { start = byte_offset_for(text, e.start_line, e.start_col), stop = byte_offset_for(text, e.end_line, e.end_col), text = e.new_text, }) end table.sort(resolved, function(a, b) return a.start > b.start end) for _, e in ipairs(resolved) do if e.start == e.stop then buf:insert(e.start, e.text) elseif e.text == "" then buf:delete(e.start, e.stop) else buf:replace(e.start, e.stop, e.text) end end return #resolved end -- T M4.5 L2/L4 — apply a parsed LSP `WorkspaceEdit` given as the -- ordered op list `pmacs.rename.ops` / `code_action.edit` / -- `_parse_workspace_edit` hand back: each entry is tagged `op` = -- "edit" | "create" | "rename" | "delete". Order is the server's and -- is honoured exactly, because the spec sequences ops (a `create` -- must precede the `edit` that fills the new file). -- -- Atomicity: a true cross-buffer/disk transaction is out of scope, so -- the applier refuses to mutate *anything* unless every URI it -- touches resolves to a real file path first (`path_for_uri`). An op -- naming an `untitled:`/non-file document aborts the whole edit -- cleanly, origin buffer untouched, rather than half-applying. -- -- Text edits go through `apply_text_edits` (offsets resolved against -- that buffer's *original* text, applied reverse-start) after -- `find_or_open` makes the target active. Resource ops go through -- `pmacs.buffer.apply_resource_op` (filesystem + buffer-registry -- reconciliation). The buffer the user invoked from is restored last -- (best-effort: it may itself have been renamed/deleted). Returns -- `edit_count, file_count, resource_op_count` on success, or -- `nil, message` if the preflight rejected the edit. local function apply_workspace_edit(ops) local plan = {} for _, op in ipairs(ops or {}) do if op.op == "edit" then if op.edits and #op.edits > 0 then local path = pmacs.lsp.path_for_uri(op.uri) if not path then return nil, "cannot resolve " .. tostring(op.uri) end plan[#plan + 1] = { kind = "edit", path = path, edits = op.edits } end elseif op.op == "create" then local path = pmacs.lsp.path_for_uri(op.uri) if not path then return nil, "cannot resolve " .. tostring(op.uri) end plan[#plan + 1] = { kind = "create", path = path, overwrite = op.overwrite, ignore_if_exists = op.ignore_if_exists, } elseif op.op == "rename" then local from = pmacs.lsp.path_for_uri(op.old_uri) local to = pmacs.lsp.path_for_uri(op.new_uri) if not from or not to then return nil, "cannot resolve rename " .. tostring(op.old_uri) .. " -> " .. tostring(op.new_uri) end plan[#plan + 1] = { kind = "rename", old_path = from, new_path = to, overwrite = op.overwrite, ignore_if_exists = op.ignore_if_exists, } elseif op.op == "delete" then local path = pmacs.lsp.path_for_uri(op.uri) if not path then return nil, "cannot resolve " .. tostring(op.uri) end plan[#plan + 1] = { kind = "delete", path = path, recursive = op.recursive, ignore_if_not_exists = op.ignore_if_not_exists, } end end if #plan == 0 then return 0, 0, 0 end local origin = active_buffer_path() local edit_total, files, res_ops = 0, 0, 0 for _, item in ipairs(plan) do if item.kind == "edit" then pmacs.buffer.find_or_open(item.path) edit_total = edit_total + apply_text_edits(item.edits) files = files + 1 else pmacs.buffer.apply_resource_op(item) res_ops = res_ops + 1 end end -- Return the user to where they invoked from — best-effort, since -- that path may have just been renamed or deleted. if origin then pcall(pmacs.buffer.find_or_open, origin) end return edit_total, files, res_ops end -- T M4.5 L3 — server→client `workspace/applyEdit` pump. -- -- After a code action's `executeCommand`, servers (rust-analyzer, -- gopls, …) deliver the actual change as a `workspace/applyEdit` -- *request* — surfaced by the manager as a `request` event on the -- server's event stream (the same "expose the request to the -- consumer" path as `workspace/configuration`, minus the built-in -- answer). We drain attachment servers' events each async tick, apply -- any applyEdit through the shared applier, and reply `{ applied }`. -- -- Only servers in `attachments` are drained, so a test (or package) -- that owns its own directly-spawned server and reads its events -- itself is unaffected. Server ids are snapshotted before the loop -- because `apply_workspace_edit` → `find_or_open` can attach a new -- buffer mid-iteration (mutating `attachments`). local function handle_apply_edit_requests() local sids, seen = {}, {} for _, rec in pairs(attachments) do local sid = rec.server if sid then local k = tostring(sid) if not seen[k] then seen[k] = true sids[#sids + 1] = sid end end end for _, sid in ipairs(sids) do local ok, evs = pcall(pmacs.lsp.events_take, sid) if ok and evs then for _, ev in ipairs(evs) do if ev.kind == "request" and ev.method == "workspace/applyEdit" then local edit = ev.params and ev.params.edit local applied, reason = false, nil if edit then local parsed = pmacs.lsp._parse_workspace_edit(edit) local n, info = apply_workspace_edit(parsed.ops) if n then applied = true else reason = info end else reason = "missing edit" end local result = { applied = applied } if not applied then result.failureReason = tostring(reason) end pcall(pmacs.lsp.send_response, sid, ev.request_id, result) end end end end end if pmacs._async and pmacs._async.tick then local _prior_async_tick = pmacs._async.tick pmacs._async.tick = function(...) local ret = _prior_async_tick(...) pcall(handle_apply_edit_requests) return ret end end -- Commands ---------------------------------------------------------------- -- Each command captures the cursor/target at invocation time, then -- spawns a coroutine that awaits the request and reacts. The editor -- never blocks: the command function returns immediately and the -- modeline updates when the response lands (or the await fails). -- `:await()` sequences the work and surfaces server-gone / server- -- error as structured errors; the normalized typed store (hybrid -- model) is still the read path, so LSP result-shape variance -- (Location | Location[] | LocationLink[], MarkupContent, …) stays -- parsed in one place in Rust rather than re-derived here. function pmacs.lsp.go_to_definition() local rec = attached_for_active() if not rec then pmacs.editor.set_status("LSP: no server for active buffer") return end local line = pmacs.editor.cursor_line() local col = pmacs.editor.cursor_col() pmacs.definition.clear(rec.server, rec.uri) pmacs.async(function() local ok, err = pcall(function() pmacs.lsp.request_definition(rec.server, rec.uri, line, col):await() end) if not ok then pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) return end local locs = pmacs.definition.locations(rec.server, rec.uri) if not locs or #locs == 0 then pmacs.editor.set_status("LSP: no definition found") return end local first = locs[1] if first.uri == rec.uri then -- Same file: record the origin so M-, returns here, then move. pmacs.editor.push_jump() move_active_cursor_to(first.line, first.col) pmacs.editor.set_status(string.format( "LSP: definition at %d:%d", first.line + 1, first.col + 1)) else -- Cross-file (SP-4): decode the URI, record the jump origin -- *before* switching away, open-or-reuse the target buffer, -- then position the cursor. `find_or_open` switches the active -- buffer and fires `buffer.after-load`, which attaches an LSP -- to the newly opened file. local path = pmacs.lsp.path_for_uri(first.uri) if not path then pmacs.editor.set_status( "LSP: cannot open non-file definition " .. first.uri) return end pmacs.editor.push_jump() local ok2, oerr = pcall(pmacs.buffer.find_or_open, path) if not ok2 then -- Open failed: drop the origin we just pushed so M-, isn't -- left pointing at a jump that never happened. pmacs.editor.jump_back() pmacs.editor.set_status( "LSP: failed to open " .. path .. ": " .. tostring(oerr)) return end move_active_cursor_to(first.line, first.col) pmacs.editor.set_status(string.format( "LSP: definition at %s:%d:%d", path, first.line + 1, first.col + 1)) end end) end function pmacs.lsp.find_references() local rec = attached_for_active() if not rec then pmacs.editor.set_status("LSP: no server for active buffer") return end local line = pmacs.editor.cursor_line() local col = pmacs.editor.cursor_col() pmacs.references.clear(rec.server, rec.uri) pmacs.async(function() local ok, err = pcall(function() pmacs.lsp.request_references(rec.server, rec.uri, line, col):await() end) if not ok then pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) return end local locs = pmacs.references.locations(rec.server, rec.uri) if not locs or #locs == 0 then pmacs.editor.set_status("LSP: no references found") return end -- v1 surfaces a modeline summary (count + first hit); a -- references list buffer is future UX work, like the hover panel. local first = locs[1] pmacs.editor.set_status(string.format( "LSP: %d reference%s; first at %s:%d:%d", #locs, (#locs == 1 and "" or "s"), first.uri, first.line + 1, first.col + 1)) end) end function pmacs.lsp.document_symbols() local rec = attached_for_active() if not rec then pmacs.editor.set_status("LSP: no server for active buffer") return end pmacs.document_symbol.clear(rec.server, rec.uri) pmacs.async(function() local ok, err = pcall(function() pmacs.lsp.request_document_symbol(rec.server, rec.uri):await() end) if not ok then pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) return end local syms = pmacs.document_symbol.symbols(rec.server, rec.uri) if not syms or #syms == 0 then pmacs.editor.set_status("LSP: no symbols") return end -- v1 modeline summary (count + first symbol); a structured -- outline buffer driven off this store is future UX work, like -- the references list and hover panel. local first = syms[1] pmacs.editor.set_status(string.format( "LSP: %d symbol%s; first '%s' at %d:%d", #syms, (#syms == 1 and "" or "s"), first.name, first.line + 1, first.col + 1)) 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 pmacs.editor.set_status("LSP: no server for active buffer") return end pmacs.formatting.clear(rec.server, rec.uri) pmacs.async(function() local ok, err = pcall(function() pmacs.lsp.request_formatting(rec.server, rec.uri, 4, true):await() end) if not ok then pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) return end local edits = pmacs.formatting.edits(rec.server, rec.uri) if not edits or #edits == 0 then pmacs.editor.set_status("LSP: no formatting edits") return end local n = apply_text_edits(edits) pmacs.editor.set_status(string.format("LSP: applied %d edits", n)) end) end -- T M4.5 L2 — rename the symbol under the cursor. Prompts for the new -- name in the minibuffer; on accept, sends `textDocument/rename`, -- awaits the `WorkspaceEdit`, and drives it through the multi-file -- applier. The position is captured *before* the prompt opens so the -- request still targets the original symbol even though the minibuffer -- session moved focus. function pmacs.lsp.rename() local rec = attached_for_active() if not rec then pmacs.editor.set_status("LSP: no server for active buffer") return end local line = pmacs.editor.cursor_line() local col = pmacs.editor.cursor_col() pmacs.minibuffer.read { prompt = "Rename symbol to: ", on_cancel = function() pmacs.editor.set_status("LSP: rename cancelled") end, on_accept = function(new_name) if not new_name or new_name == "" then pmacs.editor.set_status("LSP: rename needs a new name") return end pmacs.rename.clear(rec.server, rec.uri) pmacs.async(function() local ok, err = pcall(function() pmacs.lsp.request_rename(rec.server, rec.uri, line, col, new_name):await() end) if not ok then pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) return end local ops = pmacs.rename.ops(rec.server, rec.uri) if not ops or #ops == 0 then pmacs.editor.set_status("LSP: rename produced no edits") return end local n, files, res = apply_workspace_edit(ops) if not n then -- Preflight rejected it; nothing was mutated. pmacs.editor.set_status("LSP: rename aborted: " .. tostring(files)) return end local msg = string.format( "LSP: renamed — %d edit%s across %d file%s", n, (n == 1 and "" or "s"), files, (files == 1 and "" or "s")) if res and res > 0 then msg = msg .. string.format( " (+%d file op%s)", res, (res == 1 and "" or "s")) end pmacs.editor.set_status(msg) end) end, } end -- T M4.5 L3 — code actions at the cursor. Requests the actions, -- then applies the first one: an inline `edit` goes through the -- shared WorkspaceEdit applier; a `command` is dispatched via -- `workspace/executeCommand` (after which the server usually drives -- the change with a server→client `workspace/applyEdit`, handled by -- the pump installed below). A selection UI over multiple actions is -- future UX work, like the references list and hover panel — v1 -- acts on the first and reports how many were offered. function pmacs.lsp.code_actions() local rec = attached_for_active() if not rec then pmacs.editor.set_status("LSP: no server for active buffer") return end local line = pmacs.editor.cursor_line() local col = pmacs.editor.cursor_col() pmacs.code_action.clear(rec.server, rec.uri) pmacs.async(function() local ok, err = pcall(function() pmacs.lsp.request_code_action( rec.server, rec.uri, line, col, line, col):await() end) if not ok then pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) return end local acts = pmacs.code_action.actions(rec.server, rec.uri) if not acts or #acts == 0 then pmacs.editor.set_status("LSP: no code actions") return end local first = acts[1] local bits = {} if first.has_edit then local n, files, res = apply_workspace_edit(first.edit) if not n then pmacs.editor.set_status("LSP: code action aborted: " .. tostring(files)) return end local b = string.format("%d edit(s) / %d file(s)", n, files) if res and res > 0 then b = b .. string.format(" / %d file op(s)", res) end table.insert(bits, b) end if first.command then local ok2, cerr = pcall(function() pmacs.lsp.request_execute_command( rec.server, first.command.command, first.command.arguments):await() end) if not ok2 then pmacs.editor.set_status("LSP: command failed: " .. lsp_await_error(cerr)) return end table.insert(bits, "ran '" .. first.command.command .. "'") end local detail = (#bits > 0) and (" — " .. table.concat(bits, ", ")) or "" pmacs.editor.set_status(string.format( "LSP: code action '%s'%s (%d available)", first.title, detail, #acts)) end) end function pmacs.lsp.hover_at_cursor() local rec = attached_for_active() if not rec then pmacs.editor.set_status("LSP: no server for active buffer") return end local line = pmacs.editor.cursor_line() local col = pmacs.editor.cursor_col() pmacs.hover.clear(rec.server, rec.uri) pmacs.async(function() local ok, err = pcall(function() pmacs.lsp.request_hover(rec.server, rec.uri, line, col):await() end) if not ok then pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) return end local hover = pmacs.hover.current(rec.server, rec.uri) if not hover then pmacs.editor.set_status("LSP: no hover info") return end -- Surface the first line of the hover body in the modeline. The -- popup view subscribes to the same store; a panel can wire in -- here when the keybinding is meant to surface one. local first = (hover.contents or ""):match("^[^\n]*") or "" pmacs.editor.set_status(first ~= "" and ("LSP: " .. first) or "LSP: hover empty") end) end function pmacs.lsp.signature_help_at_cursor() local rec = attached_for_active() if not rec then pmacs.editor.set_status("LSP: no server for active buffer") return end local line = pmacs.editor.cursor_line() local col = pmacs.editor.cursor_col() pmacs.signature.clear(rec.server, rec.uri) pmacs.async(function() local ok, err = pcall(function() pmacs.lsp.request_signature_help(rec.server, rec.uri, line, col):await() end) if not ok then pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) return end local help = pmacs.signature.current(rec.server, rec.uri) if not help or not help.signatures or #help.signatures == 0 then pmacs.editor.set_status("LSP: no signature help") return end local active = help.signatures[(help.active_signature or 0) + 1] pmacs.editor.set_status(active and ("LSP: " .. active.label) or "LSP: signature unknown") end) end -- Default commands + keymap entries -------------------------------------- pmacs.command.define { name = "lsp.go-to-definition", description = "Jump to the definition of the symbol under the cursor (LSP).", fn = pmacs.lsp.go_to_definition, } pmacs.command.define { name = "lsp.format-buffer", description = "Format the active buffer through the attached LSP server.", fn = pmacs.lsp.format_buffer, } pmacs.command.define { name = "lsp.hover", description = "Surface the hover documentation for the symbol under the cursor.", fn = pmacs.lsp.hover_at_cursor, } pmacs.command.define { name = "lsp.signature-help", description = "Surface the signature of the function call at the cursor.", fn = pmacs.lsp.signature_help_at_cursor, } pmacs.command.define { name = "lsp.find-references", description = "Find references to the symbol under the cursor (LSP).", fn = pmacs.lsp.find_references, } pmacs.command.define { name = "lsp.document-symbols", description = "List the symbols (outline) of the active buffer (LSP).", fn = pmacs.lsp.document_symbols, } pmacs.command.define { name = "lsp.rename", description = "Rename the symbol under the cursor across the workspace (LSP).", fn = pmacs.lsp.rename, } pmacs.command.define { name = "lsp.code-actions", description = "Apply a code action for the symbol/range under the cursor (LSP).", 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. pmacs.command.define { name = "lsp.jump-back", description = "Return to the location before the last LSP navigation jump.", fn = function() if not pmacs.editor.jump_back() then pmacs.editor.set_status("LSP: jump ring empty") end end, } -- Default chords. M-. follows the cross-editor convention for -- go-to-definition; the others sit on `C-c` to keep printable letters -- self-inserting. The user can override or unbind any of these from -- init.lua. pmacs.keymap.bind { scope = "global", sequence = "M-.", command = "lsp.go-to-definition" } pmacs.keymap.bind { scope = "global", sequence = "M-?", command = "lsp.find-references" } pmacs.keymap.bind { scope = "global", sequence = "M-,", command = "lsp.jump-back" } 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" }