1491 lines
57 KiB
Lua
1491 lines
57 KiB
Lua
-- 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 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
|
|
|
|
-- 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 = {}
|
|
|
|
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
|
|
-- 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 = { 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
|
|
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_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). 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
|
|
|
|
-- 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
|
|
pcall(request_fn, sid, rec.uri)
|
|
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(s, uri)
|
|
pmacs.lsp.request_inlay_hint(s, uri, 0, 0, 0xFFFFF, 0)
|
|
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)
|
|
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)
|
|
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
|
|
|
|
-- 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.
|
|
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,
|
|
}
|
|
|
|
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 s", command = "lsp.signature-help" }
|
|
pmacs.keymap.bind { scope = "global", sequence = "C-c f", command = "lsp.format-buffer" }
|