-- 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`, -- bound to default chords below. -- -- v0.1 scope: one server per language across all buffers; same-file -- definition jumps only; synchronous request → poll → react cycle -- (sub-second for hot servers). Cross-file navigation, async-await -- coroutines, rename, code actions, inlay hints, semantic tokens, and -- file-watch capability registration are all v0.2 work. 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 {}, 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) -- 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 -- 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 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 pmacs.editor.set_status("LSP: definition lives in " .. first.uri) 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 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 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, } -- 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 = "C-c o", command = "lsp.document-symbols" } 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" }