-- 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` / `pmacs.lsp.semantic_tokens`, 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) with `textDocument/prepareRename` gating when the server -- supports it, code actions + `workspace/executeCommand` + server→client -- `workspace/applyEdit` (L3), ordered resource-op edits -- (create/rename/delete file) with buffer-registry reconciliation -- (L4), inlay hints, and semantic tokens (each data + modeline; -- wiring them into rendering is a separate rendering milestone), -- incl. the server→client `workspace/inlayHint/refresh` and -- `workspace/semanticTokens/refresh` requests, and dynamic -- `workspace/didChangeWatchedFiles` registration backed by a -- polling snapshot-diff watcher. pmacs.lsp = pmacs.lsp or {} -- Per-language server config. Each entry: -- command (string) required — server binary -- args (list) argv after command -- env (table) extra environment -- init_options (table) `initializationOptions` -- settings (table) answered to `workspace/configuration` -- root (string) optional explicit project root; overrides -- the `pmacs.project.detect` marker walk used -- to set `rootUri`/`cwd` (see project_root_for) 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 = {} }, } -- TypeScript / JavaScript via typescript-language-server (the -- tsserver wrapper). One binary serves the whole family; like -- `c`/`cpp` these are separate config entries purely so the -- `language_id` sent in `didOpen` is accurate — tsserver keys -- diagnostics and some code actions off it (typescript / -- typescriptreact / javascript / javascriptreact). `--stdio` is the -- LSP transport. The project model comes from `tsconfig.json` / -- `jsconfig.json` (no `workspace/configuration` pull). Users -- override from init.lua before a TS/JS file opens; swapping to -- `vtsls` is just `command = "vtsls"`. for _, lid in ipairs({ "typescript", "typescriptreact", "javascript", "javascriptreact" }) do pmacs.lsp.config[lid] = pmacs.lsp.config[lid] or { command = "typescript-language-server", args = { "--stdio" }, } end -- Lua via lua-language-server (sumneko). Speaks LSP over stdio with -- no transport flag. It pulls configuration via -- `workspace/configuration` under the `Lua` section; an empty table -- is "use defaults" — present, not null (same rationale as gopls). -- `.lua` is tree-sitter-grammar-backed, so `pmacs.parse` already -- resolves the `lua` language and this is the config that attaches. -- Users populate `settings.Lua` (runtime.version, workspace.library, -- diagnostics.globals = { "pmacs" }, …) from init.lua. pmacs.lsp.config.lua = pmacs.lsp.config.lua or { command = "lua-language-server", args = {}, settings = { Lua = {} }, } -- Bash / shell via bash-language-server. `start` is its -- LSP-over-stdio subcommand. No project config; it shells out to -- `shellcheck` (diagnostics) and `shfmt` (formatting) when those are -- on PATH. Users override from init.lua before a shell file opens. pmacs.lsp.config.bash = pmacs.lsp.config.bash or { command = "bash-language-server", args = { "start" }, } -- TOML via taplo. `taplo lsp stdio` serves LSP over stdio. taplo -- pulls configuration via `workspace/configuration` under the -- `taplo` section (empty ⇒ defaults, present not null); a project -- `.taplo.toml` still wins. Users populate it from init.lua. pmacs.lsp.config.toml = pmacs.lsp.config.toml or { command = "taplo", args = { "lsp", "stdio" }, settings = { taplo = {} }, } -- Zig via zls. `zls` with no args serves LSP over stdio; it reads -- `zls.json` / `build.zig` for the project model (no -- `workspace/configuration` pull). Users override from init.lua -- before a `.zig` / `.zon` file opens. pmacs.lsp.config.zig = pmacs.lsp.config.zig or { command = "zls", args = {}, } -- 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" -- Tier 1 single-binary servers. TypeScript / JavaScript distinguish -- the JSX variants so the server enables the JSX parser. for _, ext in ipairs({ "ts", "mts", "cts" }) do pmacs.lsp.filetypes[ext] = pmacs.lsp.filetypes[ext] or "typescript" end pmacs.lsp.filetypes.tsx = pmacs.lsp.filetypes.tsx or "typescriptreact" for _, ext in ipairs({ "js", "mjs", "cjs" }) do pmacs.lsp.filetypes[ext] = pmacs.lsp.filetypes[ext] or "javascript" end pmacs.lsp.filetypes.jsx = pmacs.lsp.filetypes.jsx or "javascriptreact" -- Lua (lua-language-server). pmacs bundles a Lua grammar, so -- `language_for_path` resolves `.lua` first; this entry is the LSP -- fallback and keeps the language id stable if that ever changes. pmacs.lsp.filetypes.lua = pmacs.lsp.filetypes.lua or "lua" -- Bash / shell (bash-language-server). pmacs.lsp.filetypes.sh = pmacs.lsp.filetypes.sh or "bash" pmacs.lsp.filetypes.bash = pmacs.lsp.filetypes.bash or "bash" -- TOML (taplo). pmacs.lsp.filetypes.toml = pmacs.lsp.filetypes.toml or "toml" -- Zig (zls). `.zon` is Zig Object Notation, handled by the same server. pmacs.lsp.filetypes.zig = pmacs.lsp.filetypes.zig or "zig" pmacs.lsp.filetypes.zon = pmacs.lsp.filetypes.zon or "zig" -- 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 buffer_text(buf) if not buf then return "" end return buf:slice(0, buf:len()) end -- didChange coalescing (typing perf) ----------------------------------------- -- -- Document sync is full-text, so each `textDocument/didChange` ships -- the entire buffer. Sending one per keystroke cost three O(file) -- copies plus an O(file) JSON write to the server pipe *per typed -- character* — the dominant daemon-side typing cost on large files. -- The after-edit hook now only bumps the version, marks the cached -- render families stale (cheap), and records the buffer as dirty; -- the actual notification ships from the async tick once the buffer -- has been quiet for DID_CHANGE_QUIET_MS, or unconditionally once -- the oldest unsent edit is DID_CHANGE_MAX_LAG_MS old (so the server -- keeps converging during continuous typing). Versions may skip -- values across a coalesced burst; LSP only requires that they -- increase. Anything that asks the server about a document flushes -- it first so no request is answered against stale text. local DID_CHANGE_QUIET_MS = 75 local DID_CHANGE_MAX_LAG_MS = 400 -- Dirty buffers: key (tostring(buf)) -> { -- rec = the attachment record the edits belong to, -- first_ms = monotonic time of the oldest unsent edit, -- last_ms = monotonic time of the newest unsent edit, -- } local pending_did_change = {} -- Forward declaration — defined below (needs helpers that follow); -- `flush_did_change` re-pulls inlay hints after each coalesced send. local pull_inlay_hints_quiet local function flush_did_change(key) local pending = pending_did_change[key] if not pending then return end pending_did_change[key] = nil local rec = pending.rec -- The attachment may have been torn down or replaced (server -- crash -> re-attach) since the edit was recorded; only the live -- record's server should hear about the buffer. if attachments[key] ~= rec then return end local ok, text = pcall(buffer_text, rec.buffer) if not ok then return end pcall(pmacs.lsp.did_change, rec.server, rec.uri, rec.version, text) -- Inlay hints are pull-model: the store's stale flag (set per edit) -- only clears on a fresh `textDocument/inlayHint` response, and the -- server never volunteers one. Re-request at flush cadence so -- hints come back shortly after each pause instead of staying -- suppressed until the next attach/refresh. The request is -- supersede-keyed per (server, method, uri), so a burst of flushes -- cancels its own predecessors rather than piling up. pcall(pull_inlay_hints_quiet, rec) end local function flush_did_change_for(rec) if rec and rec.buffer then flush_did_change(tostring(rec.buffer)) end end local function flush_all_did_changes() for key in pairs(pending_did_change) do flush_did_change(key) end end local function flush_due_did_changes() if next(pending_did_change) == nil then return end local now = pmacs.editor.monotonic_ms() for key, pending in pairs(pending_did_change) do if now - pending.last_ms >= DID_CHANGE_QUIET_MS or now - pending.first_ms >= DID_CHANGE_MAX_LAG_MS then flush_did_change(key) end end end -- Exposed for tests and for glue that must synchronize the server's -- document view before an out-of-band operation (e.g. a save hook). function pmacs.lsp._flush_did_changes() flush_all_did_changes() 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 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 -- Directory component of a path, or nil if it has none. local function dir_of(path) if not path then return nil end return path:match("^(.*)/[^/]*$") end -- The project root to send as `rootUri` / `cwd` in `initialize`, for -- the file at `path`. Without this a server spawned by the auto-attach -- hook gets the *editor's* cwd as its root (the `build_initialize` -- fallback), which is wrong for project-model-strict servers: gopls -- and rust-analyzer only analyze files under the module/workspace at -- `rootUri`, so opening a file from outside the launch directory -- yields zero diagnostics/hover/definition. Resolution order: -- 1. explicit `pmacs.lsp.config[language].root` override, -- 2. `pmacs.project.detect` marker walk (go.mod / Cargo.toml / -- pyproject.toml / package.json / .git …) — the same detector -- the rest of the editor uses, honoring set_search_boundary, -- 3. the file's own directory (a lone file still gets a sane root -- rather than leaking the editor cwd). -- This is single-root: it fixes which root the one per-language server -- uses, NOT one-server-per-root scoping (still deferred post-v0.1). local function project_root_for(language, path) local cfg = pmacs.lsp.config[language] if cfg and cfg.root then return cfg.root end if not path then return nil end local ok, det = pcall(pmacs.project.detect, path) if ok and det and det.root then return det.root end return dir_of(path) end local function ensure_server(language, path) 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, so the -- first file that attaches a given language fixes that server's -- root; later files of the same language reuse it regardless of -- their own project (known, documented limitation). 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 root = project_root_for(language, path) 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, cwd = root, root_uri = root and file_uri_for(root) or nil, }) 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 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 -- Assigns the forward-declared local above (so `flush_did_change` -- can re-pull); a fresh `local function` here would shadow it. 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 -- The server must see the current text before being asked to -- compute positions against it (didChange is debounced). A no-op -- when called from `flush_did_change` itself (the pending entry is -- removed before the send), so this cannot recurse. flush_did_change_for(rec) 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 -- is keyed by the same `tostring(buf)` the attachments table uses. local styled_buffers = {} -- M4.6 (task #23): buffers that already had a `DiagnosticView` -- overlay pushed. Same dedup discipline as `styled_buffers` — -- `pmacs.diag._attach_view` stacks a fresh overlay on every call, -- so the after-load path must gate itself. local diag_viewed_buffers = {} 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 -- Unsent edits targeted the dead attachment; the did_open below -- carries the full current text, superseding them. pending_did_change[key] = nil end local language = active_buffer_language() if not language then return nil end -- Path resolved before spawn so the server's `rootUri` can be -- derived from the file's project (see `project_root_for`). local path = active_buffer_path() local sid = ensure_server(language, path) if not sid then return nil end local uri = file_uri_for(path) if not uri then return nil end 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`. pcall(pmacs.lsp.did_open, sid, uri, rec.version, active_buffer_text()) -- M_B3: dual-authority styling. Always push the LSP style overlay -- when an LSP server is up — whether or not the buffer has a -- bundled tree-sitter grammar. When the grammar exists too, -- `SyntaxHighlightView` paints first (lexical: keywords, strings, -- operators) and `LspStyleView` paints after (semantic: function / -- type / macro / namespace identifiers from clangd's tokens); their -- styles compose via `crate::overlay::merge_styles`, so the final -- cell carries both authorities' contributions. This replaces -- M_B1's policy-A exclusivity, which left grammar-backed languages -- (Rust, C, C++) without LSP semantic refinement. if not styled_buffers[key] then local ok, attached = pcall(pmacs.lsp._attach_style, buf) if ok and attached then styled_buffers[key] = true end end -- M4.6 (task #23): attach the DiagnosticView so the TUI grid -- renderer underlines bytes covered by published diagnostics. -- Keyed by `uri` to match the diag store; the view re-reads the -- store on every render, so no further wiring is needed when -- diagnostics update. if not diag_viewed_buffers[key] then 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 local function attached_for_active() local buf = pmacs.window.buffer() if not buf then return nil end local key = tostring(buf) local rec = attachments[key] if rec then -- Every interactive command resolves its attachment here before -- issuing requests; flushing now means the server answers those -- requests against the current text (didChange is debounced). flush_did_change(key) return rec end return attach_buffer(buf) end -- Pure, side-effect-free attachment lookup for the active buffer: -- returns the live record (with `.uri`) when a server is already -- attached, else nil. Unlike `attached_for_active`, it never *triggers* -- an attach --- the context menu (Q#CM3) calls it to decide whether to -- show symbol/diagnostic items, and must not perturb LSP state just by -- opening. function pmacs.lsp.active_attachment() local buf = pmacs.window.buffer() if not buf then return nil end return attachments[tostring(buf)] end -- Flushing variant for request-issuing callers outside this file -- (Q#C8): when the active buffer already has a server attached, -- flush any debounced didChange first and return the record, so the -- caller's request is answered against the current text. Unlike the -- local `attached_for_active`, this NEVER triggers an attach: the -- in-buffer completion driver calls it on ordinary typing, and -- spawning language servers as a typing side effect is wrong (and, -- concretely, wedged the m4 suite with per-keystroke spawn attempts -- across parallel tests). Attachment remains buffer-open policy. function pmacs.lsp.attachment_for_request() local buf = pmacs.window.buffer() if not buf then return nil end local key = tostring(buf) local rec = attachments[key] if not rec then return nil end flush_did_change(key) return rec end -- Hooks -------------------------------------------------------------------- pmacs.hook.add("buffer.after-load", function() pcall(attach_buffer, pmacs.window.buffer()) end) pmacs.hook.add("buffer.after-switch", function() -- Arc 1b: switching buffers clears the window's overlays, and -- `attach_buffer` early-returns for a live attachment without -- touching views — so a switch back to an attached buffer must -- re-push the LSP style + diagnostic views itself. The just- -- cleared window makes this exactly-once per switch; the dedup -- tables keep gating the after-load path only. Without this, -- navigating between attached buffers looked like "the LSP -- deactivated" (no semantic color, no underlines). local buf = pmacs.window.buffer() if not buf then return end local key = tostring(buf) local rec = attachments[key] if not rec then return end local ok_s, attached_s = pcall(pmacs.lsp._attach_style, buf) if ok_s and attached_s then styled_buffers[key] = true end local ok_d, attached_d = pcall(pmacs.diag._attach_view, buf, rec.uri) if ok_d and attached_d then diag_viewed_buffers[key] = true end end) pmacs.hook.add("buffer.after-edit", function() local buf = pmacs.window.buffer() if not buf then return end local key = tostring(buf) local rec = attachments[key] if not rec then return end rec.version = rec.version + 1 -- Stale suppression must stay keystroke-accurate even though the -- O(file) didChange send below is coalesced: render families -- anchored to pre-edit positions are hidden from this edit on. pcall(pmacs.lsp._mark_document_stale, rec.uri) local now = pmacs.editor.monotonic_ms() local pending = pending_did_change[key] if pending and pending.rec == rec then pending.last_ms = now else pending_did_change[key] = { rec = rec, first_ms = now, last_ms = now } end 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_prepare_rename = wrap_request(pmacs.lsp._request_prepare_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) pmacs.lsp.request_semantic_tokens = wrap_request(pmacs.lsp._request_semantic_tokens_raw) pmacs.lsp.request_semantic_tokens_range = wrap_request(pmacs.lsp._request_semantic_tokens_range_raw) pmacs.lsp.request_semantic_tokens_delta = wrap_request(pmacs.lsp._request_semantic_tokens_delta_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). The `character` field -- crossing the wire is already converted to/from a pmacs byte offset -- by the transport layer (`PositionEncoding` negotiation + -- `char_to_byte`/`byte_to_char` in src/lsp.rs), so `col` here is a -- byte offset, not a UTF-16 unit. The one residual: the walk below -- steps `col` times with `move_right` (one codepoint per step), which -- equals the byte offset only for single-byte-per-codepoint text — -- multi-byte lines land the cursor short. Byte-accurate cursor -- placement is the remaining position-encoding follow-up. 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 -- Re-pull a per-`(server, uri)` store for every buffer attached to -- `sid`. Fire-and-forget: the response absorbs into its store via the -- request's route, exactly like the explicit command path — no await -- needed. `request_fn(sid, uri)` issues the re-pull. local function repull_for_attachments(sid, request_fn) for _, rec in pairs(attachments) do if rec.server == sid and rec.uri then -- Server-initiated repulls (diagnostics refresh, semantic -- tokens refresh) must also see the latest text first. flush_did_change_for(rec) pcall(request_fn, sid, rec.uri, rec) end end end -- T M4.5 — workspace file watching (workspace/didChangeWatchedFiles). -- -- Servers register watchers dynamically via client/registerCapability. -- pmacs has no kernel file-watch, so each registration runs a polling -- snapshot-diff coroutine: walk the base dir into a { relpath = sig } -- map and, every tick, diff against the previous map to emit per-file -- created/changed/deleted FileEvents (filtered by the glob and the -- WatchKind bitmask), batched into one notification. Coarser than an -- inotify bridge but accurate; a watcher self-cancels when the server -- dies or the capability is unregistered. local FILE_WATCH_INTERVAL_MS = 250 -- file_watchers[tostring(sid)][registrationId] = list of watch records -- ({ cancelled = bool, _sleep = handle? }), one per glob watcher. local file_watchers = {} -- WatchKind is a bitmask (Create=1, Change=2, Delete=4); test it -- arithmetically so this stays valid under luajit (no 5.3 `&`). local function kind_has(mask, bit) return mask % (bit * 2) >= bit end -- Expand `{a,b}` alternations into brace-free globs (nested handled -- by recursing the remainder; unbalanced braces left literal). local function expand_braces(glob) local open = glob:find("{", 1, true) if not open then return { glob } end local depth, close = 0, nil for i = open, #glob do local c = glob:sub(i, i) if c == "{" then depth = depth + 1 elseif c == "}" then depth = depth - 1 if depth == 0 then close = i break end end end if not close then return { glob } end local prefix, body, suffix = glob:sub(1, open - 1), glob:sub(open + 1, close - 1), glob:sub(close + 1) local parts, d2, start = {}, 0, 1 for i = 1, #body do local c = body:sub(i, i) if c == "{" then d2 = d2 + 1 elseif c == "}" then d2 = d2 - 1 elseif c == "," and d2 == 0 then parts[#parts + 1] = body:sub(start, i - 1) start = i + 1 end end parts[#parts + 1] = body:sub(start) local out = {} for _, alt in ipairs(parts) do for _, tail in ipairs(expand_braces(suffix)) do out[#out + 1] = prefix .. alt .. tail end end return out end -- Translate one brace-free glob to an anchored Lua pattern. local function glob_one_to_pattern(glob) local p, i, n = "^", 1, #glob while i <= n do local c = glob:sub(i, i) if c == "*" then if glob:sub(i + 1, i + 1) == "*" then -- Lua patterns can't quantify a group, so `**/` (zero+ path -- segments) becomes the lazy `.-` (`.` spans `/`); a bare -- `**` becomes `.*`. if glob:sub(i + 2, i + 2) == "/" then p, i = p .. ".-", i + 3 else p, i = p .. ".*", i + 2 end else p, i = p .. "[^/]*", i + 1 end elseif c == "?" then p, i = p .. "[^/]", i + 1 elseif c == "[" then local j = i + 1 if glob:sub(j, j) == "!" then j = j + 1 end if glob:sub(j, j) == "]" then j = j + 1 end while j <= n and glob:sub(j, j) ~= "]" do j = j + 1 end local cls = glob:sub(i + 1, j - 1):gsub("^!", "^") p, i = p .. "[" .. cls .. "]", j + 1 else if c:match("[%(%)%.%%%+%-%^%$%[%]%*%?]") then p = p .. "%" .. c else p = p .. c end i = i + 1 end end return p .. "$" end local function glob_matcher(glob) local pats = {} for _, g in ipairs(expand_braces(glob)) do pats[#pats + 1] = glob_one_to_pattern(g) end return function(rel) for _, pat in ipairs(pats) do if rel:match(pat) then return true end end return false end end -- Recursively list files under `base` → { relpath = sig }. `sig` -- folds size+mtime+kind so a content/metadata change flips it. -- Symlinks are recorded, not traversed (loop-safe). Awaits fs -- primitives, so call from inside an async coroutine. local function scan_tree(base, matches) local out = {} local function walk(dir, rel_prefix) local ok, entries = pcall(function() return pmacs.fs.read_dir(dir):await() end) if not ok or not entries then return end for _, e in ipairs(entries) do local rel = (rel_prefix == "") and e.name or (rel_prefix .. "/" .. e.name) if e.kind == "dir" then walk(dir .. "/" .. e.name, rel) elseif matches(rel) then out[rel] = table.concat({ tostring(e.size), tostring(e.mtime), tostring(e.mtime_nsec), tostring(e.kind), }, "|") end end end walk(base, "") return out end local FC_CREATED, FC_CHANGED, FC_DELETED = 1, 2, 3 local function start_file_watcher(sid, base, glob, kind_mask, record) local matches = glob_matcher(glob) pmacs.async(function() local prev = scan_tree(base, matches) while not record.cancelled and server_is_live(sid) do local sh = pmacs.workers.sleep(FILE_WATCH_INTERVAL_MS) record._sleep = sh pcall(function() sh:await() end) record._sleep = nil if record.cancelled or not server_is_live(sid) then break end local cur = scan_tree(base, matches) local changes = {} for rel, sig in pairs(cur) do local was = prev[rel] if was == nil then if kind_has(kind_mask, 1) then changes[#changes + 1] = { uri = file_uri_for(base .. "/" .. rel), type = FC_CREATED } end elseif was ~= sig and kind_has(kind_mask, 2) then changes[#changes + 1] = { uri = file_uri_for(base .. "/" .. rel), type = FC_CHANGED } end end for rel in pairs(prev) do if cur[rel] == nil and kind_has(kind_mask, 4) then changes[#changes + 1] = { uri = file_uri_for(base .. "/" .. rel), type = FC_DELETED } end end if #changes > 0 then pcall(pmacs.lsp.did_change_watched_files, sid, changes) end prev = cur end end) end -- Resolve a GlobPattern (string | { baseUri, pattern }) to -- (base_dir, pattern). A bare string with no base falls back to the -- directory of an attached file on `sid` (best effort). local function resolve_watcher(sid, gp) if type(gp) == "table" and gp.baseUri then return pmacs.lsp.path_for_uri(gp.baseUri), gp.pattern or "**" end if type(gp) == "string" then for _, rec in pairs(attachments) do if rec.server == sid and rec.uri then local p = pmacs.lsp.path_for_uri(rec.uri) local dir = p and p:match("^(.*)/[^/]*$") if dir then return dir, gp end end end end return nil, nil end local function register_file_watchers(sid, registrations) local skey = tostring(sid) file_watchers[skey] = file_watchers[skey] or {} for _, reg in ipairs(registrations or {}) do if reg.method == "workspace/didChangeWatchedFiles" then local recs = {} for _, w in ipairs((reg.registerOptions or {}).watchers or {}) do local base, pat = resolve_watcher(sid, w.globPattern) if base and pat then local r = { cancelled = false } recs[#recs + 1] = r start_file_watcher(sid, base, pat, w.kind or 7, r) end end file_watchers[skey][reg.id] = recs end end end local function unregister_file_watchers(sid, unregs) local byid = file_watchers[tostring(sid)] if not byid then return end for _, u in ipairs(unregs or {}) do if u.method == "workspace/didChangeWatchedFiles" and byid[u.id] then for _, r in ipairs(byid[u.id]) do r.cancelled = true if r._sleep then pcall(function() r._sleep:cancel() end) end end byid[u.id] = nil end end end -- T M4.5 — server→client request pump. -- -- Some server→client *requests* are 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 a -- built-in answer). Each async tick we drain attachment servers' -- events and handle: -- -- * `workspace/applyEdit` (L3) — apply the edit through the shared -- applier, reply `{ applied }`. After a code action's -- `executeCommand`, servers (rust-analyzer, gopls, …) deliver the -- actual change this way. -- * `workspace/inlayHint/refresh` / -- `workspace/semanticTokens/refresh` — the server signals its -- cached hints/tokens are stale; reply `null` and re-pull that -- family for every attached document so the matching store -- (`pmacs.inlay_hint` / `pmacs.semantic_tokens`) stays fresh. -- * `client/registerCapability` / `client/unregisterCapability` — -- start/stop the file-watch coroutines for any -- `workspace/didChangeWatchedFiles` registration; reply `null`. -- -- 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_server_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) elseif ev.kind == "request" and ev.method == "workspace/inlayHint/refresh" then -- 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(_, _, rec) pull_inlay_hints_quiet(rec) end) elseif ev.kind == "request" and ev.method == "workspace/semanticTokens/refresh" then pcall(pmacs.lsp.send_response, sid, ev.request_id, nil) repull_for_attachments(sid, pmacs.lsp.request_semantic_tokens) elseif ev.kind == "request" and ev.method == "client/registerCapability" then pcall(pmacs.lsp.send_response, sid, ev.request_id, nil) pcall(register_file_watchers, sid, ev.params and ev.params.registrations) elseif ev.kind == "request" and ev.method == "client/unregisterCapability" then pcall(pmacs.lsp.send_response, sid, ev.request_id, nil) -- 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 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_server_requests) pcall(flush_due_did_changes) 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 -- LSP SymbolKind (1..=26) -> short outline tag (Arc 1b phase 2). local SYMBOL_KIND_TAGS = { "file", "module", "namespace", "package", "class", "method", "property", "field", "constructor", "enum", "interface", "function", "variable", "constant", "string", "number", "boolean", "array", "object", "key", "null", "enum-member", "struct", "event", "operator", "type-parameter", } -- Visit one LSP location (Arc 1b): the SP-4 cross-file template --- -- jump ring, find-or-open, cursor walk. Same-buffer hits skip the -- open. Shared by the references panel (and the outline in phase 2). local function visit_location(loc) local path = pmacs.lsp.path_for_uri(loc.uri) if not path then pmacs.editor.set_status("LSP: cannot decode target uri " .. tostring(loc.uri)) return end pmacs.editor.push_jump() local ok, err = pcall(pmacs.buffer.find_or_open, path) if not ok 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(err)) return end move_active_cursor_to(loc.line, loc.col) end -- Shorten `path` against the project root of `relative_to` (a path in -- the same project) for panel display; falls back to the full path. local function display_path(path, relative_to) local ok, proj = pcall(pmacs.project.detect, relative_to or path) if ok and proj and proj.root then local root = proj.root if root:sub(-1) ~= "/" then root = root .. "/" end if path:sub(1, #root) == root then return path:sub(#root + 1) end end return path 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 -- Arc 1b: a browsable *references* panel. RET visits (jump ring -- included, so M-, returns); q restores this buffer. local here = pmacs.lsp.path_for_uri(rec.uri) local rows = {} for _, loc in ipairs(locs) do local path = pmacs.lsp.path_for_uri(loc.uri) or loc.uri rows[#rows + 1] = { text = string.format("%s:%d:%d", display_path(path, here), loc.line + 1, loc.col + 1), item = loc, } end pmacs.listview.open { name = "*references*", header = string.format( "%d reference%s RET visit n/p move q quit", #locs, (#locs == 1 and "" or "s")), rows = rows, on_visit = visit_location, } pmacs.editor.set_status(string.format( "LSP: %d reference%s", #locs, (#locs == 1 and "" or "s"))) 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 -- Arc 1b phase 2: a browsable *outline* panel. Symbols arrive -- FLAT with a `depth` field --- indent, don't recurse. RET -- visits (jump ring: M-, returns to the outline row); q restores. local source_buf = rec.buffer local rows = {} for _, sym in ipairs(syms) do local tag = SYMBOL_KIND_TAGS[sym.kind] or "symbol" rows[#rows + 1] = { text = string.format( "%s%s [%s]", string.rep(" ", sym.depth or 0), sym.name, tag), item = sym, } end pmacs.listview.open { name = "*outline*", header = string.format( "%d symbol%s RET visit n/p move q quit", #syms, (#syms == 1 and "" or "s")), rows = rows, on_visit = function(sym) pmacs.editor.push_jump() local okv = pcall(pmacs.window.switch_buffer, source_buf) if not okv then pmacs.editor.jump_back() pmacs.editor.set_status("LSP: outline source buffer is gone") return end move_active_cursor_to(sym.line, sym.col) end, } pmacs.editor.set_status(string.format( "LSP: %d symbol%s", #syms, (#syms == 1 and "" or "s"))) 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) .. 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 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, end_line, end_col):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 -- T M4.5 — semantic tokens for the whole buffer. Incremental: if a -- prior result id exists for this buffer, request a -- `/full/delta` against it (the store keeps the raw int stream to -- splice on); otherwise a `/full` pull. Either way the store ends -- with the complete token set + a fresh result id, and a modeline -- summary (count + first token's type, resolved through the legend) -- is shown. Data only: wiring LSP tokens into styling (a second -- authority alongside tree-sitter) is a separate rendering -- milestone — a render layer subscribes to the same -- `pmacs.semantic_tokens` store when it lands. -- -- `pmacs.lsp.request_semantic_tokens_range` is also exposed (no -- default command) for a future viewport-aware caller: the bundle -- has no on-screen-range source, so a "range" command here would -- just duplicate `/full`. function pmacs.lsp.semantic_tokens() local rec = attached_for_active() if not rec then pmacs.editor.set_status("LSP: no server for active buffer") return end -- Don't clear: a delta splices against the retained raw stream. local prev = pmacs.semantic_tokens.result_id(rec.server, rec.uri) pmacs.async(function() local ok, err = pcall(function() if prev then pmacs.lsp.request_semantic_tokens_delta( rec.server, rec.uri, prev):await() else pmacs.lsp.request_semantic_tokens(rec.server, rec.uri):await() end end) if not ok then pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) return end local toks = pmacs.semantic_tokens.tokens(rec.server, rec.uri) if not toks or #toks == 0 then pmacs.editor.set_status("LSP: no semantic tokens") return end local first = toks[1] -- Resolve the type index through the legend (0-based index -> -- 1-based Lua array); fall back to the raw index if no legend. local legend = pmacs.semantic_tokens.legend(rec.server) local tname = legend and legend.token_types and legend.token_types[first.token_type + 1] or tostring(first.token_type) pmacs.editor.set_status(string.format( "LSP: %d semantic token%s%s; first '%s' at %d:%d", #toks, (#toks == 1 and "" or "s"), (prev and " (delta)" or ""), tname, first.line + 1, first.start + 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 — rename the symbol under the cursor. -- -- When the server advertises `renameProvider.prepareProvider`, a -- `textDocument/prepareRename` round-trip runs first: it gates the -- prompt (a `null` result means "not renameable here" — abort with a -- status, never open the prompt) and pre-fills the placeholder the -- server suggests. Servers that don't advertise prepare (or advertise -- `renameProvider: true`) skip straight to the prompt — the original -- L2 behavior, unchanged. The cursor 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() local function open_prompt(initial) pmacs.minibuffer.read { prompt = "Rename symbol to: ", initial = initial, 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 -- Gate on the server advertising prepareRename. `renameProvider` -- is `boolean | { prepareProvider?: boolean }`. local caps = pmacs.lsp.capabilities(rec.server) local rp = caps and caps.renameProvider if not (type(rp) == "table" and rp.prepareProvider == true) then open_prompt(nil) return end pmacs.prepare_rename.clear(rec.server, rec.uri) pmacs.async(function() local ok, err = pcall(function() pmacs.lsp.request_prepare_rename(rec.server, rec.uri, line, col):await() end) if not ok then pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) return end local pr = pmacs.prepare_rename.result(rec.server, rec.uri) if not pr or not pr.allowed then pmacs.editor.set_status("LSP: cannot rename here") return end open_prompt(pr.placeholder) 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. -- Apply one code action (Arc 1b phase 2: shared by the direct path -- and the picker). Runs its WorkspaceEdit inline and/or awaits its -- executeCommand, then reports what happened. Must run inside a -- `pmacs.async` coroutine. local function apply_code_action(rec, act) local bits = {} if act.has_edit then local n, files, res = apply_workspace_edit(act.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 act.command then local ok, cerr = pcall(function() pmacs.lsp.request_execute_command( rec.server, act.command.command, act.command.arguments):await() end) if not ok then pmacs.editor.set_status("LSP: command failed: " .. lsp_await_error(cerr)) return end table.insert(bits, "ran '" .. act.command.command .. "'") end local detail = (#bits > 0) and (" — " .. table.concat(bits, ", ")) or "" pmacs.editor.set_status(string.format( "LSP: code action '%s'%s", act.title, detail)) end 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 -- Arc 1b phase 2: one action applies directly (today's behavior, -- now correct instead of lucky); several open the minibuffer -- dropdown so the USER picks — v1 applied acts[1] blind. if #acts == 1 then apply_code_action(rec, acts[1]) return end local labels = {} for i, a in ipairs(acts) do labels[i] = string.format("%d: %s", i, a.title) end pmacs.minibuffer.read { prompt = string.format("Code action (%d): ", #acts), source = function() return labels end, on_accept = function(choice) if not choice or choice == "" then return end -- Accept both the completed candidate ("2: Inline fix") -- and a bare typed index ("2"). local idx = tonumber(choice:match("^(%d+)")) local act = idx and acts[idx] if not act then pmacs.editor.set_status("LSP: no such code action") return end pmacs.async(function() apply_code_action(rec, act) end) end, } 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 -- Arc 1b phase 2: the full (multi-line) hover body in a *lsp-help* -- panel --- `lsp.hover` keeps its one-line echo-area summary; this is -- the "show me everything" companion. Rows are non-visitable -- (item = nil, so RET is a no-op); q restores the source buffer. function pmacs.lsp.hover_doc() 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 or not hover.contents or hover.contents == "" then pmacs.editor.set_status("LSP: no hover info") return end local rows = {} for l in (hover.contents .. "\n"):gmatch("(.-)\n") do rows[#rows + 1] = { text = l } end pmacs.listview.open { name = "*lsp-help*", header = "hover documentation q quit", rows = rows, } 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-doc", description = "Show the full hover documentation in a *lsp-help* panel.", fn = pmacs.lsp.hover_doc, } 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, } pmacs.command.define { name = "lsp.semantic-tokens", description = "Fetch semantic tokens (type-aware classification) for the buffer (LSP).", fn = pmacs.lsp.semantic_tokens, } -- 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 y", command = "lsp.semantic-tokens" } pmacs.keymap.bind { scope = "global", sequence = "C-c h", command = "lsp.hover" } pmacs.keymap.bind { scope = "global", sequence = "C-c H", command = "lsp.hover-doc" } 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" } -- Diagnostic navigation (task #23, M4.6 surface) ----------------------------- -- -- Emacs's `M-g n` / `M-g p` jump between compile/next-error locations. We -- reuse the chord for LSP diagnostics: walk the diag store for the active -- buffer's URI and move the cursor to the next/previous diagnostic. Wraps -- around (`pmacs.diag.next`'s default), so repeated taps cycle. local function navigate_diagnostic(direction) local rec = attached_for_active() if not rec then pmacs.editor.set_status("diag: no LSP server for active buffer") return end local line = pmacs.editor.cursor_line() local col = pmacs.editor.cursor_col() local found if direction == "next" then found = pmacs.diag.next(rec.uri, line, col) else found = pmacs.diag.previous(rec.uri, line, col) end if not found then pmacs.editor.set_status("diag: no diagnostics in buffer") return end pmacs.editor.push_jump() move_active_cursor_to(found.start_line, found.start_col) pmacs.editor.set_status(string.format("diag (%s): %s", found.severity or "?", found.message or "")) end pmacs.command.define { name = "diag.next", description = "Jump to the next diagnostic in the active buffer (wraps).", fn = function() navigate_diagnostic("next") end, } pmacs.command.define { name = "diag.previous", description = "Jump to the previous diagnostic in the active buffer (wraps).", fn = function() navigate_diagnostic("previous") end, } pmacs.keymap.bind { scope = "global", sequence = "M-g n", command = "diag.next" } pmacs.keymap.bind { scope = "global", sequence = "M-g p", command = "diag.previous" }