From b252e63580bd88a242fa145b4ee14154bec31be6 Mon Sep 17 00:00:00 2001 From: The_miro Date: Mon, 13 Jan 2025 17:30:31 +0100 Subject: [PATCH] and readded mlsp --- micro/plug/mlsp/.luacheckrc | 223 +++++ micro/plug/mlsp/LICENSE | 21 + micro/plug/mlsp/LanguageServers.md | 122 +++ micro/plug/mlsp/README.md | 99 ++ micro/plug/mlsp/config.lua | 154 +++ micro/plug/mlsp/json.lua | 396 ++++++++ micro/plug/mlsp/main.lua | 1411 ++++++++++++++++++++++++++++ 7 files changed, 2426 insertions(+) create mode 100644 micro/plug/mlsp/.luacheckrc create mode 100644 micro/plug/mlsp/LICENSE create mode 100644 micro/plug/mlsp/LanguageServers.md create mode 100644 micro/plug/mlsp/README.md create mode 100644 micro/plug/mlsp/config.lua create mode 100644 micro/plug/mlsp/json.lua create mode 100644 micro/plug/mlsp/main.lua diff --git a/micro/plug/mlsp/.luacheckrc b/micro/plug/mlsp/.luacheckrc new file mode 100644 index 0000000..a2e8a9b --- /dev/null +++ b/micro/plug/mlsp/.luacheckrc @@ -0,0 +1,223 @@ +-- globals from micro: https://github.com/zyedidia/micro/blob/master/runtime/help/plugins.md + +read_globals = { + "import", +} + +globals = { + "VERSION", + "init", + "preinit", + "postinit", + "onSetActive", + "onBufferOpen", + "onBufPaneOpen", + "onRune", + "preRune", + + "onCursorUp", + "onCursorDown", + "onCursorPageUp", + "onCursorPageDown", + "onCursorLeft", + "onCursorRight", + "onCursorStart", + "onCursorEnd", + "onSelectToStart", + "onSelectToEnd", + "onSelectUp", + "onSelectDown", + "onSelectLeft", + "onSelectRight", + "onSelectToStartOfText", + "onSelectToStartOfTextToggle", + "onWordRight", + "onWordLeft", + "onSelectWordRight", + "onSelectWordLeft", + "onMoveLinesUp", + "onMoveLinesDown", + "onDeleteWordRight", + "onDeleteWordLeft", + "onSelectLine", + "onSelectToStartOfLine", + "onSelectToEndOfLine", + "onInsertNewline", + "onInsertSpace", + "onBackspace", + "onDelete", + "onCenter", + "onInsertTab", + "onSave", + "onSaveAll", + "onSaveAs", + "onFind", + "onFindLiteral", + "onFindNext", + "onFindPrevious", + "onDiffPrevious", + "onDiffNext", + "onUndo", + "onRedo", + "onCopy", + "onCopyLine", + "onCut", + "onCutLine", + "onDuplicateLine", + "onDeleteLine", + "onIndentSelection", + "onOutdentSelection", + "onOutdentLine", + "onIndentLine", + "onPaste", + "onSelectAll", + "onOpenFile", + "onStart", + "onEnd", + "onPageUp", + "onPageDown", + "onSelectPageUp", + "onSelectPageDown", + "onHalfPageUp", + "onHalfPageDown", + "onStartOfLine", + "onEndOfLine", + "onStartOfText", + "onStartOfTextToggle", + "onParagraphPrevious", + "onParagraphNext", + "onToggleHelp", + "onToggleDiffGutter", + "onToggleRuler", + "onJumpLine", + "onClearStatus", + "onShellMode", + "onCommandMode", + "onQuit", + "onQuitAll", + "onAddTab", + "onPreviousTab", + "onNextTab", + "onNextSplit", + "onUnsplit", + "onVSplit", + "onHSplit", + "onPreviousSplit", + "onToggleMacro", + "onPlayMacro", + "onSuspend", + "onScrollUp", + "onScrollDown", + "onSpawnMultiCursor", + "onSpawnMultiCursorUp", + "onSpawnMultiCursorDown", + "onSpawnMultiCursorSelect", + "onRemoveMultiCursor", + "onRemoveAllMultiCursors", + "onSkipMultiCursor", + "onJumpToMatchingBrace", + "onAutocomplete", + + "preCursorUp", + "preCursorDown", + "preCursorPageUp", + "preCursorPageDown", + "preCursorLeft", + "preCursorRight", + "preCursorStart", + "preCursorEnd", + "preSelectToStart", + "preSelectToEnd", + "preSelectUp", + "preSelectDown", + "preSelectLeft", + "preSelectRight", + "preSelectToStartOfText", + "preSelectToStartOfTextToggle", + "preWordRight", + "preWordLeft", + "preSelectWordRight", + "preSelectWordLeft", + "preMoveLinesUp", + "preMoveLinesDown", + "preDeleteWordRight", + "preDeleteWordLeft", + "preSelectLine", + "preSelectToStartOfLine", + "preSelectToEndOfLine", + "preInsertNewline", + "preInsertSpace", + "preBackspace", + "preDelete", + "preCenter", + "preInsertTab", + "preSave", + "preSaveAll", + "preSaveAs", + "preFind", + "preFindLiteral", + "preFindNext", + "preFindPrevious", + "preDiffPrevious", + "preDiffNext", + "preUndo", + "preRedo", + "preCopy", + "preCopyLine", + "preCut", + "preCutLine", + "preDuplicateLine", + "preDeleteLine", + "preIndentSelection", + "preOutdentSelection", + "preOutdentLine", + "preIndentLine", + "prePaste", + "preSelectAll", + "preOpenFile", + "preStart", + "preEnd", + "prePageUp", + "prePageDown", + "preSelectPageUp", + "preSelectPageDown", + "preHalfPageUp", + "preHalfPageDown", + "preStartOfLine", + "preEndOfLine", + "preStartOfText", + "preStartOfTextToggle", + "preParagraphPrevious", + "preParagraphNext", + "preToggleHelp", + "preToggleDiffGutter", + "preToggleRuler", + "preJumpLine", + "preClearStatus", + "preShellMode", + "preCommandMode", + "preQuit", + "preQuitAll", + "preAddTab", + "prePreviousTab", + "preNextTab", + "preNextSplit", + "preUnsplit", + "preVSplit", + "preHSplit", + "prePreviousSplit", + "preToggleMacro", + "prePlayMacro", + "preSuspend", + "preScrollUp", + "preScrollDown", + "preSpawnMultiCursor", + "preSpawnMultiCursorUp", + "preSpawnMultiCursorDown", + "preSpawnMultiCursorSelect", + "preRemoveMultiCursor", + "preRemoveAllMultiCursors", + "preSkipMultiCursor", + "preJumpToMatchingBrace", + "preAutocomplete", +} diff --git a/micro/plug/mlsp/LICENSE b/micro/plug/mlsp/LICENSE new file mode 100644 index 0000000..cef8c17 --- /dev/null +++ b/micro/plug/mlsp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Andriamanitra + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/micro/plug/mlsp/LanguageServers.md b/micro/plug/mlsp/LanguageServers.md new file mode 100644 index 0000000..90f3aa4 --- /dev/null +++ b/micro/plug/mlsp/LanguageServers.md @@ -0,0 +1,122 @@ +# List of language servers & how to get them + +* [C/C++](#cc) +* [Clojure](#clojure) +* [Crystal](#crystal) +* [Go](#go) +* [Haskell](#haskell) +* [JavaScript/TypeScript](#javascripttypescript) +* [JSON](#json) +* [Lua](#lua) +* [Markdown](#markdown) +* [Python](#python) +* [Ruby](#ruby) +* [Rust](#rust) +* [Zig](#zig) + +## C/C++ + +- [Clangd](https://clangd.llvm.org/) + - Installation: [instructions](https://clangd.llvm.org/installation.html) + - Command: `clangd` + +## Clojure + +- [clojure-lsp](https://github.com/clojure-lsp/clojure-lsp) + - Installation: [instructions](https://clojure-lsp.io/installation/) + - Command: `clojure-lsp` + +## Crystal + +- [Crystalline](https://github.com/elbywan/crystalline) + - Installation: + [instructions](https://github.com/elbywan/crystalline#global-install) + - Command: `crystalline` + +## Go + +- [gopls](https://pkg.go.dev/golang.org/x/tools/gopls) + - Installation: + [instructions](https://pkg.go.dev/golang.org/x/tools/gopls#readme-installation) + - Command: `gopls` + +## Haskell + +- [HLS](https://github.com/haskell/haskell-language-server) + - Installation: use [ghcup](https://www.haskell.org/ghcup/) + - Command: `haskell-language-server-wrapper --lsp` + +## JavaScript/TypeScript + +- [deno](https://github.com/denoland/deno) + - Installation: + [instructions](https://github.com/denoland/deno_install/blob/master/README.md#deno_install) + - Command: `deno lsp` + +- [quick-lint-js](https://github.com/quick-lint/quick-lint-js) + - Only diagnostics (no formatting, hover information or code navigation) + - Installation: `npm install -g quick-lint-js` + - Command: `quick-lint-js --lsp` + +## JSON + +- [deno](https://github.com/denoland/deno) + - Installation: + [instructions](https://github.com/denoland/deno_install/blob/master/README.md#deno_install) + - Command: `deno lsp` + +## Lua + +- [luals](https://github.com/luals/lua-language-server) + - Installation: [instructions](https://luals.github.io/#other-install) + - Command: `lua-language-server` (make sure the executable was installed in your $PATH) +- [lua-lsp](https://github.com/Alloyed/lua-lsp) + - Unmaintained. You are in for trouble if you want to get it to work with Lua + 5.4. + - Installation: `luarocks install lua-lsp` + ([luarocks](https://github.com/luarocks/luarocks)) + - Command: `lua-lsp` + +## Markdown + +- [deno](https://github.com/denoland/deno) + - Installation: + [instructions](https://github.com/denoland/deno_install/blob/master/README.md#deno_install) + - Command: `deno lsp` + +## Python + +- [pylsp](https://github.com/python-lsp/python-lsp-server) + - Installation: `pip install python-lsp-server[all]` + - Command: `pylsp` + +- [Pyright](https://github.com/microsoft/pyright) + - Installation: `npm install -g pyright` + - Command: `pyright-langserver --stdio` + +- [ruff](https://github.com/astral-sh/ruff) + - Only diagnostics, formatting and code actions (no hover information or code navigation) + - Installation: `pip install ruff` + - Command: `ruff server` + +## Ruby + +- [ruby-lsp](https://github.com/Shopify/ruby-lsp) + - Installation: `gem install ruby-lsp` + - Command: `ruby-lsp` + +- [solargraph](https://github.com/castwide/solargraph) + - Installation: `gem install solargraph` + - Command: `solargraph stdio` + +## Rust + +- [rust-analyzer](https://github.com/rust-lang/rust-analyzer) + - Installation: `rustup component add rust-analyzer` + - Command: `rust-analyzer` + +## Zig + +- [zls](https://github.com/zigtools/zls) + - Installation: [instructions](https://github.com/zigtools/zls#installation) + - Command: `zls` diff --git a/micro/plug/mlsp/README.md b/micro/plug/mlsp/README.md new file mode 100644 index 0000000..e962275 --- /dev/null +++ b/micro/plug/mlsp/README.md @@ -0,0 +1,99 @@ +# µlsp + +LSP client for [micro-editor](https://github.com/zyedidia/micro). +Note that this is a work in progress and has not yet been tested extensively – expect there to be some bugs. +Please [open an issue](https://github.com/Andriamanitra/mlsp/issues/new) if you run into any! + + +## Demo + +[https://asciinema.org/a/610761](https://asciinema.org/a/610761) + + +## Installation + +Simply clone the repository to your micro plugins directory: + +``` +git clone https://github.com/Andriamanitra/mlsp ~/.config/micro/plug/mlsp +``` + +You will also need to install [language servers](LanguageServers.md) for the +programming languages you want to use. + +The plugin currently provides following commands: + +- `lsp start deno lsp` starts a language server by executing command `deno lsp`. + Without arguments the `lsp start` command will try to guess the right server by + looking at the currently open filetype. +- `lsp stop deno` stops the language server with name `deno`. Without arguments + the `lsp stop` command will stop _all_ currently running language servers. +- `lsp hover` shows hover information for the code under cursor. +- `lsp format` formats the buffer that is currently open. +- `lsp autocomplete` for code completion suggestions. PROTIP: If you wish to use the + same key as micro's autocompletion (tab by default), enable `tabAutocomplete` + in `config.lua` instead of binding `command:lsp autocomplete` to a key! +- `lsp goto-definition` – open the definition for the symbol under cursor +- `lsp goto-declaration` – open the declaration for the symbol under cursor +- `lsp goto-typedefinition` – open the type definition for the symbol under cursor +- `lsp goto-implementation` – open the implementation for the symbol under cursor +- `lsp find-references` - find all references to the symbol under cursor (shows the results in a new pane) +- `lsp document-symbols` - list all symbols in the current document +- `lsp diagnostic-info` - show more information about a diagnostic on the current line (useful for multiline diagnostic messages) + +You can type the commands on micro command prompt or bind them to keys by adding +something like this to your `bindings.json`: + +```json +{ + "F7": "command:lsp start", + "F8": "command:lsp format", + "Alt-h": "command:lsp hover", + "Alt-d": "command:lsp goto-definition", + "Alt-r": "command:lsp find-references" +} +``` + + +## Supported features + +- [x] get hover information +- [x] show diagnostics (disabled by default, edit `config.lua` to enable) +- [x] autocomplete using tab (disabled by default, edit `config.lua` to enable) +- [x] format document +- [x] format selection +- [x] go to definition +- [x] go to declaration +- [x] go to implementation +- [x] go to type definition +- [x] find references +- [x] list document symbols +- [ ] rename symbol +- [ ] code actions +- [x] incremental document synchronization (better performance when editing large files) +- [ ] [suggest a feature](https://github.com/Andriamanitra/mlsp/issues/new) + + +## Showing LSP information on statusline + +The plugin provides a function `mlsp.status` that can be used in the status line format. +Here is an example configuration (`~/.config/micro/settings.json`) that uses it: + +```json +{ + "statusformatl": "$(filename) $(modified)($(line),$(col)) | ft:$(opt:filetype) | µlsp:$(mlsp.status)" +} +``` + +See [micro documentation](https://github.com/zyedidia/micro/blob/master/runtime/help/options.md) +and the built-in [status plugin](https://github.com/zyedidia/micro/blob/master/runtime/plugins/status/help/status.md) +for more information on customizing the statusline. + + +## Known issues + +- The very first autocompletion with `rust-analyzer` after initialization is very slow (it can take multiple seconds). + +## Other similar projects + +* [AndCake/micro-plugin-lsp](https://github.com/AndCake/micro-plugin-lsp) is another LSP plugin for micro-editor. diff --git a/micro/plug/mlsp/config.lua b/micro/plug/mlsp/config.lua new file mode 100644 index 0000000..5055e71 --- /dev/null +++ b/micro/plug/mlsp/config.lua @@ -0,0 +1,154 @@ +-- defaults for omitted server options (you probably don't want to change these) +local defaultLanguageServerOptions = { + -- Unique name for the server to be shown in statusbar and logs + -- Defaults to the same as cmd if omitted + shortName = nil, + + -- (REQUIRED) command to execute the language server + cmd = "", + + -- Arguments for the above command + args = {}, + + -- Language server specific options that are sent to the server during + -- initialization – you can usually omit this field + initializationOptions = nil, + + -- callback function that is called when language server is initialized + -- (useful for debugging and disabling server capabilities) + -- For example to disable getting hover information from a server: + -- onInitialized = function(client) + -- client.serverCapabilities.hoverProvider = false + -- end + onInitialized = nil, +} + +-- Pre-made configurations for commonly used language servers – you can also +-- define your own servers to be used in settings at the bottom of this file. +-- See defaultLanguageServerOptions above for the available options. +languageServer = { + clangd = { + cmd = "clangd" + }, + clojurelsp = { + cmd = "clojure-lsp" + }, + crystalline = { + cmd = "crystalline" + }, + deno = { + cmd = "deno", + args = {"lsp"} + }, + gopls = { + cmd = "gopls" + }, + hls = { + shortName = "hls", + cmd = "haskell-language-server-wrapper", + args = {"--lsp"} + }, + julials = { + shortName = "julials", + cmd = "julia", + args = {"--startup-file=no", "--history-file=no", "-e", "using LanguageServer; runserver()"} + }, + lualsp = { + cmd = "lua-lsp" + }, + luals = { + cmd = "lua-language-server" + }, + pylsp = { + cmd = "pylsp" + }, + pyright = { + shortName = "pyright", + cmd = "pyright-langserver", + args = {"--stdio"} + }, + quicklintjs = { + cmd = "quick-lint-js", + args = {"--lsp"} + }, + rubocop = { + cmd = "rubocop", + args = {"--lsp"} + }, + rubylsp = { + cmd = "ruby-lsp" + }, + ruff = { + cmd = "ruff", + args = {"server"}, + onInitialized = function(client) + -- does not give useful results + client.serverCapabilities.hoverProvider = false + end + }, + rustAnalyzer = { + shortName = "rust", + cmd = "rust-analyzer" + }, + solargraph = { + cmd = "solargraph", + args = {"stdio"} + }, + zls = { + cmd = "zls" + } +} + +-- you don't need to care about this part but it's basically filling in defaults +-- for all missing fields in language servers defined above +defaultLanguageServerOptions.__index = defaultLanguageServerOptions +for _, server in pairs(languageServer) do + setmetatable(server, defaultLanguageServerOptions) +end + + +settings = { + + -- Use LSP completion in place of micro's default Autocomplete action when + -- available (you can bind `command:autocomplete` command to a different + -- key in ~/.config/micro/bindings.json even if this setting is false) + tabAutocomplete = true, + + -- Automatically start language server(s) when a buffer with matching + -- filetype is opened + autostart = { + -- Example #1: Start gopls when editing .go files: + -- go = { languageServer.gopls }, + + -- Example #2: Start pylsp AND ruff-lsp when editing Python files: + -- python = { languageServer.pylsp, languageServer.ruff }, + }, + + -- Language server to use when `lsp` command is executed without args + defaultLanguageServer = { + c = languageServer.clangd, + ["c++"] = languageServer.clangd, + clojure = languageServer.clojurelsp, + crystal = languageServer.crystalline, + go = languageServer.gopls, + haskell = languageServer.hls, + javascript = languageServer.deno, + julia = languageServer.julials, + json = languageServer.deno, + lua = languageServer.luals, + markdown = languageServer.deno, + python = languageServer.pylsp, + ruby = languageServer.rubylsp, + rust = languageServer.rustAnalyzer, + typescript = languageServer.deno, + zig = languageServer.zls, + }, + + -- Which kinds of diagnostics to show in the gutter + showDiagnostics = { + error = false, + warning = false, + information = false, + hint = false + }, +} diff --git a/micro/plug/mlsp/json.lua b/micro/plug/mlsp/json.lua new file mode 100644 index 0000000..06b70d7 --- /dev/null +++ b/micro/plug/mlsp/json.lua @@ -0,0 +1,396 @@ +-- +-- json.lua +-- +-- Copyright (c) 2020 rxi +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy of +-- this software and associated documentation files (the "Software"), to deal in +-- the Software without restriction, including without limitation the rights to +-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +-- of the Software, and to permit persons to whom the Software is furnished to do +-- so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in all +-- copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. +-- + + +-- by default the empty table ({} in lua) gets serialized into json array +-- this hack allows us to use json.object when we want to emit an empty object +-- instead +local emptyMap_metatable = {} +emptyMap_metatable.__index = emptyMap_metatable +local emptyMap = {} +setmetatable(emptyMap, emptyMap_metatable) + +------------------------------------------------------------------------------- +-- Encode +------------------------------------------------------------------------------- + +local encode + +local escape_char_map = { + [ "\\" ] = "\\", + [ "\"" ] = "\"", + [ "\b" ] = "b", + [ "\f" ] = "f", + [ "\n" ] = "n", + [ "\r" ] = "r", + [ "\t" ] = "t", +} + +local escape_char_map_inv = { [ "/" ] = "/" } +for k, v in pairs(escape_char_map) do + escape_char_map_inv[v] = k +end + + +local function escape_char(c) + return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte())) +end + + +local function encode_nil(val) + return "null" +end + + +local function encode_table(val, stack) + local res = {} + stack = stack or {} + + -- Circular reference? + if stack[val] then error("circular reference") end + + stack[val] = true + + if (rawget(val, 1) ~= nil or next(val) == nil) and (getmetatable(val) ~= emptyMap_metatable) then + -- Treat as array -- check keys are valid and it is not sparse + local n = 0 + for k in pairs(val) do + if type(k) ~= "number" then + error("invalid table: mixed or invalid key types") + end + n = n + 1 + end + if n ~= #val then + error("invalid table: sparse array") + end + -- Encode + for i, v in ipairs(val) do + table.insert(res, encode(v, stack)) + end + stack[val] = nil + return "[" .. table.concat(res, ",") .. "]" + + else + -- Treat as an object + for k, v in pairs(val) do + if type(k) ~= "string" then + error("invalid table: mixed or invalid key types") + end + table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) + end + stack[val] = nil + return "{" .. table.concat(res, ",") .. "}" + end +end + + +local function encode_string(val) + return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' +end + + +local function encode_number(val) + -- Check for NaN, -inf and inf + if val ~= val or val <= -math.huge or val >= math.huge then + error("unexpected number value '" .. tostring(val) .. "'") + end + return string.format("%.14g", val) +end + + +local type_func_map = { + [ "nil" ] = encode_nil, + [ "table" ] = encode_table, + [ "string" ] = encode_string, + [ "number" ] = encode_number, + [ "boolean" ] = tostring, +} + + +encode = function(val, stack) + local t = type(val) + local f = type_func_map[t] + if f then + return f(val, stack) + end + error("unexpected type '" .. t .. "'") +end + + + +------------------------------------------------------------------------------- +-- Decode +------------------------------------------------------------------------------- + +local parse + +local function create_set(...) + local res = {} + for i = 1, select("#", ...) do + res[ select(i, ...) ] = true + end + return res +end + +local space_chars = create_set(" ", "\t", "\r", "\n") +local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") +local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") +local literals = create_set("true", "false", "null") + +local literal_map = { + [ "true" ] = true, + [ "false" ] = false, + [ "null" ] = nil, +} + + +local function next_char(str, idx, set, negate) + for i = idx, #str do + if set[str:sub(i, i)] ~= negate then + return i + end + end + return #str + 1 +end + + +local function decode_error(str, idx, msg) + local line_count = 1 + local col_count = 1 + for i = 1, idx - 1 do + col_count = col_count + 1 + if str:sub(i, i) == "\n" then + line_count = line_count + 1 + col_count = 1 + end + end + error( string.format("%s at line %d col %d", msg, line_count, col_count) ) +end + + +local function codepoint_to_utf8(n) + -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa + local f = math.floor + if n <= 0x7f then + return string.char(n) + elseif n <= 0x7ff then + return string.char(f(n / 64) + 192, n % 64 + 128) + elseif n <= 0xffff then + return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) + elseif n <= 0x10ffff then + return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, + f(n % 4096 / 64) + 128, n % 64 + 128) + end + error( string.format("invalid unicode codepoint '%x'", n) ) +end + + +local function parse_unicode_escape(s) + local n1 = tonumber( s:sub(1, 4), 16 ) + local n2 = tonumber( s:sub(7, 10), 16 ) + -- Surrogate pair? + if n2 then + return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) + else + return codepoint_to_utf8(n1) + end +end + + +local function parse_string(str, i) + local res = "" + local j = i + 1 + local k = j + + while j <= #str do + local x = str:byte(j) + + if x < 32 then + decode_error(str, j, "control character in string") + + elseif x == 92 then -- `\`: Escape + res = res .. str:sub(k, j - 1) + j = j + 1 + local c = str:sub(j, j) + if c == "u" then + local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1) + or str:match("^%x%x%x%x", j + 1) + or decode_error(str, j - 1, "invalid unicode escape in string") + res = res .. parse_unicode_escape(hex) + j = j + #hex + else + if not escape_chars[c] then + decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string") + end + res = res .. escape_char_map_inv[c] + end + k = j + 1 + + elseif x == 34 then -- `"`: End of string + res = res .. str:sub(k, j - 1) + return res, j + 1 + end + + j = j + 1 + end + + decode_error(str, i, "expected closing quote for string") +end + + +local function parse_number(str, i) + local x = next_char(str, i, delim_chars) + local s = str:sub(i, x - 1) + local n = tonumber(s) + if not n then + decode_error(str, i, "invalid number '" .. s .. "'") + end + return n, x +end + + +local function parse_literal(str, i) + local x = next_char(str, i, delim_chars) + local word = str:sub(i, x - 1) + if not literals[word] then + decode_error(str, i, "invalid literal '" .. word .. "'") + end + return literal_map[word], x +end + + +local function parse_array(str, i) + local res = {} + local n = 1 + i = i + 1 + while 1 do + local x + i = next_char(str, i, space_chars, true) + -- Empty / end of array? + if str:sub(i, i) == "]" then + i = i + 1 + break + end + -- Read token + x, i = parse(str, i) + res[n] = x + n = n + 1 + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "]" then break end + if chr ~= "," then decode_error(str, i, "expected ']' or ','") end + end + return res, i +end + + +local function parse_object(str, i) + local res = {} + i = i + 1 + while 1 do + local key, val + i = next_char(str, i, space_chars, true) + -- Empty / end of object? + if str:sub(i, i) == "}" then + i = i + 1 + break + end + -- Read key + if str:sub(i, i) ~= '"' then + decode_error(str, i, "expected string for key") + end + key, i = parse(str, i) + -- Read ':' delimiter + i = next_char(str, i, space_chars, true) + if str:sub(i, i) ~= ":" then + decode_error(str, i, "expected ':' after key") + end + i = next_char(str, i + 1, space_chars, true) + -- Read value + val, i = parse(str, i) + -- Set + res[key] = val + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "}" then break end + if chr ~= "," then decode_error(str, i, "expected '}' or ','") end + end + return res, i +end + + +local char_func_map = { + [ '"' ] = parse_string, + [ "0" ] = parse_number, + [ "1" ] = parse_number, + [ "2" ] = parse_number, + [ "3" ] = parse_number, + [ "4" ] = parse_number, + [ "5" ] = parse_number, + [ "6" ] = parse_number, + [ "7" ] = parse_number, + [ "8" ] = parse_number, + [ "9" ] = parse_number, + [ "-" ] = parse_number, + [ "t" ] = parse_literal, + [ "f" ] = parse_literal, + [ "n" ] = parse_literal, + [ "[" ] = parse_array, + [ "{" ] = parse_object, +} + + +parse = function(str, idx) + local chr = str:sub(idx, idx) + local f = char_func_map[chr] + if f then + return f(str, idx) + end + decode_error(str, idx, "unexpected character '" .. chr .. "'") +end + + +local function decode(str) + if type(str) ~= "string" then + error("expected argument of type string, got " .. type(str)) + end + local res, idx = parse(str, next_char(str, 1, space_chars, true)) + idx = next_char(str, idx, space_chars, true) + if idx <= #str then + decode_error(str, idx, "trailing garbage") + end + return res +end + + +json = { + _version = "0.2.0", + object = emptyMap, + decode = function(s) return decode(s) end, + encode = function(o) return encode(o) end +} diff --git a/micro/plug/mlsp/main.lua b/micro/plug/mlsp/main.lua new file mode 100644 index 0000000..084227c --- /dev/null +++ b/micro/plug/mlsp/main.lua @@ -0,0 +1,1411 @@ +VERSION = "0.2.0" + +local micro = import("micro") +local config = import("micro/config") +local shell = import("micro/shell") +local buffer = import("micro/buffer") +local util = import("micro/util") +local go_os = import("os") +local go_strings = import("strings") +local go_time = import("time") +local filepath = import("path/filepath") + +local settings = settings +local json = json + +function init() + -- ordering of the table affects the autocomplete suggestion order + local subcommands = { + ["start"] = startServer, + ["stop"] = stopServers, + ["diagnostic-info"] = openDiagnosticBufferAction, + ["document-symbols"] = documentSymbolsAction, + ["find-references"] = findReferencesAction, + ["format"] = formatAction, + ["goto-definition"] = gotoAction("definition"), + ["goto-declaration"] = gotoAction("declaration"), + ["goto-implementation"] = gotoAction("implementation"), + ["goto-typedefinition"] = gotoAction("typeDefinition"), + ["hover"] = hoverAction, + ["sync-document"] = function (bp) syncFullDocument(bp.Buf) end, + ["autocomplete"] = completionAction, + ["showlog"] = showLog, + } + + local lspCompleter = function (buf) + -- Do NOT autocomplete after first argument + -- TODO: autocomplete "lsp start " and "lsp stop " + local args = go_strings.Split(buf:Line(0), " ") + if #args > 2 then return nil, nil end + + local suggestions = {} + local completions = {} + local lastArg = args[#args] + + for subcommand, _ in pairs(subcommands) do + local startIdx, endIdx = string.find(subcommand, lastArg, 1, true) + if startIdx == 1 then + local completion = string.sub(subcommand, endIdx + 1, #subcommand) + table.insert(completions, completion) + table.insert(suggestions, subcommand) + end + end + + return completions, suggestions + end + + local lspCommand = function(bp, argsUserdata) + local args = {} + for _, a in userdataIterator(argsUserdata) do table.insert(args, a) end + + if #args == 0 then + startServer(bp, {}) + return + end + + local subcommand = table.remove(args, 1) + local func = subcommands[subcommand] + if func then + func(bp, args) + else + display_error(string.format("Unknown subcommand '%s'", subcommand)) + end + end + + micro.SetStatusInfoFn("mlsp.status") + config.MakeCommand("lsp", lspCommand, lspCompleter) +end + +local activeConnections = {} +local allConnections = {} +setmetatable(allConnections, { __index = function (_, k) return activeConnections[k] end }) +local docBuffers = {} +local lastAutocompletion = -1 +local undoStackLengthBefore = 0 + +local LSPClient = {} +LSPClient.__index = LSPClient + +local LSPRange = { + fromSelection = function(selection) + -- create Range https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#range + -- from [2]Loc https://pkg.go.dev/github.com/zyedidia/micro/v2@v2.0.12/internal/buffer#Cursor + return { + ["start"] = { line = selection[1].Y, character = selection[1].X }, + ["end"] = { line = selection[2].Y, character = selection[2].X } + } + end, + fromDelta = function(delta) + local deltaEnd = delta.End + -- for some reason delta.End is often 0,0 when inserting characters + if deltaEnd.Y == 0 and deltaEnd.X == 0 then + deltaEnd = delta.Start + end + + return { + ["start"] = { line = delta.Start.Y, character = delta.Start.X }, + ["end"] = { line = deltaEnd.Y, character = deltaEnd.X } + } + end, + toLocs = function(range) + local a, b = range["start"], range["end"] + return buffer.Loc(a.character, a.line), buffer.Loc(b.character, b.line) + end +} + +function status(buf) + local servers = {} + for _, client in pairs(activeConnections) do + table.insert(servers, client.clientId) + end + if #servers == 0 then + return "off" + elseif #servers == 1 then + return servers[1] + else + return string.format("[%s]", table.concat(servers, ",")) + end +end + +function startServer(bufpane, args) + local server + if next(args) ~= nil then + local cmd = table.remove(args, 1) + -- prefer languageServer with given name from config.lua if no args given + if next(args) == nil and languageServer[cmd] ~= nil then + server = languageServer[cmd] + else + server = languageServer[cmd] or { cmd = cmd, args = args } + end + else + local ftype = bufpane.Buf:FileType() + server = settings.defaultLanguageServer[ftype] + if server == nil then + display_error(string.format("No language server set up for file type '%s'", ftype)) + return + end + end + + LSPClient:initialize(server) +end + +function stopServers(bufpane, args) + local name = args[1] + if not name then -- stop all + for clientId, client in pairs(activeConnections) do + client:stop() + end + activeConnections = {} + elseif activeConnections[name] then + activeConnections[name]:stop() + activeConnections[name] = nil + else + display_error(string.format("No active language server with name '%s'", name)) + end +end + +function showLog(bufpane, args) + local hasArgs, name = pcall(function() return args[1] end) + + for _, client in pairs(activeConnections) do + if not hasArgs or client.name == name then + foundClient = client + break + end + end + + if foundClient == nil then + display_info("No LSP client found") + return + end + + if foundClient.stderr == "" then + display_info(foundClient.clientId, " has not written anything to stderr") + return + end + + local title = string.format("[µlsp] Log for '%s' (%s)", foundClient.name, foundClient.clientId) + local newBuffer = buffer.NewBuffer(foundClient.stderr, title) + + newBuffer:SetOption("filetype", "text") + newBuffer.Type.scratch = true + newBuffer.Type.Readonly = true + + micro.CurPane():HSplitBuf(newBuffer) +end + +function LSPClient:initialize(server) + local clientId = server.shortName or server.cmd + + if allConnections[clientId] ~= nil then + display_info(clientId, " is already running") + return + end + + local client = {} + setmetatable(client, LSPClient) + + allConnections[clientId] = client + + client.clientId = clientId + client.requestId = 0 + client.stderr = "" + client.buffer = "" + client.expectedLength = nil + client.serverCapabilities = {} + client.serverName = nil + client.serverVersion = nil + client.sentRequests = {} + client.openFiles = {} + client.onInitialized = server.onInitialized + + -- the last parameter(s) to JobSpawn are userargs which get passed down to + -- the callback functions (onStdout, onStderr, onExit) + client.job = shell.JobSpawn(server.cmd, server.args, onStdout, onStderr, onExit, clientId) + if client.job.Err ~= nil then + return + end + log(string.format("Started '%s' with args", server.cmd), server.args) + + local wd, _ = go_os.Getwd() + local rootUri = string.format("file://%s", wd:uriEncode()) + + local params = { + processId = go_os.Getpid(), + rootUri = rootUri, + workspaceFolders = { { name = "root", uri = rootUri } }, + capabilities = { + textDocument = { + synchronization = { didSave = true, willSave = false }, + hover = { contentFormat = {"plaintext"} }, + completion = { + completionItem = { + snippetSupport = false, + documentationFormat = {}, + }, + contextSupport = true + } + } + } + } + if server.initializationOptions ~= nil then + params.initializationOptions = server.initializationOptions + end + + client:request("initialize", params) + return client +end + +function LSPClient:stop() + for filePath, _ in pairs(self.openFiles) do + for _, docBuf in ipairs(docBuffers[filePath]) do + docBuf:ClearMessages(self.clientId) + end + end + log("stopped", self.clientId) + display_info(self.clientId, " stopped") + shell.JobStop(self.job) +end + +function LSPClient:send(msg) + msg = json.encode(msg) + local msgWithHeaders = string.format("Content-Length: %d\r\n\r\n%s", #msg, msg) + shell.JobSend(self.job, msgWithHeaders) + log("(", self.clientId, ")->", msgWithHeaders, "\n\n") +end + +function LSPClient:notification(method, params) + local msg = { + jsonrpc = "2.0", + method = method + } + if params ~= nil then + msg.params = params + else + -- the spec allows params to be omitted but language server implementations + -- are buggy so we can put an empty object there for now + -- https://github.com/golang/go/issues/57459 + msg.params = json.object + end + self:send(msg) +end + +function LSPClient:request(method, params) + local msg = { + jsonrpc = "2.0", + id = self.requestId, + method = method + } + if params ~= nil then + msg.params = params + else + -- the spec allows params to be omitted but language server implementations + -- are buggy so we can put an empty object there for now + -- https://github.com/golang/go/issues/57459 + msg.params = json.object + end + self.sentRequests[self.requestId] = method + self.requestId = self.requestId + 1 + self:send(msg) +end + +function LSPClient:handleResponseError(method, error) + display_error(string.format("%s (Error %d, %s)", error.message, error.code, method)) + + if method == "textDocument/completion" then + setCompletions({}) + end +end + +function LSPClient:handleResponseResult(method, result) + if method == "initialize" then + self.serverCapabilities = result.capabilities + if result.serverInfo then + self.serverName = result.serverInfo.name + self.serverVersion = result.serverInfo.version + display_info(string.format("Initialized %s version %s", self.serverName, self.serverVersion)) + else + display_info(string.format("Initialized '%s' (no version information)", self.clientId)) + end + self:notification("initialized") + activeConnections[self.clientId] = self + allConnections[self.clientId] = nil + if type(self.onInitialized) == "function" then + self:onInitialized() + end + -- FIXME: iterate over *all* currently open buffers + onBufferOpen(micro.CurPane().Buf) + elseif method == "textDocument/hover" then + local showHoverInfo = function (results) + local bf = buffer.NewBuffer(results, "[µlsp] hover") + bf.Type.Scratch = true + bf.Type.Readonly = true + micro.CurPane():HSplitIndex(bf, true) + end + + -- result.contents being a string or array is deprecated but as of 2023 + -- * pylsp still responds with {"contents": ""} for no results + -- * lua-lsp still responds with {"contents": []} for no results + if result == nil or result.contents == "" or table.empty(result.contents) then + display_info("No hover results") + elseif type(result.contents) == "string" then + showHoverInfo(result.contents) + elseif type(result.contents.value) == "string" then + showHoverInfo(result.contents.value) + else + display_info("WARNING: Ignored textDocument/hover result due to unrecognized format") + end + elseif method == "textDocument/formatting" then + if result == nil or next(result) == nil then + display_info("Formatted file (no changes)") + else + local textedits = result + editBuf(micro.CurPane().Buf, textedits) + display_info("Formatted file") + end + elseif method == "textDocument/rangeFormatting" then + if result == nil or next(result) == nil then + display_info("Formatted selection (no changes)") + else + local textedits = result + editBuf(micro.CurPane().Buf, textedits) + display_info("Formatted selection") + end + elseif method == "textDocument/completion" then + -- TODO: handle result.isIncomplete = true somehow + local completions = {} + + if result ~= nil then + -- result can be either CompletionItem[] or an object + -- { isIncomplete: bool, items: CompletionItem[] } + completions = result.items or result + end + + if #completions == 0 then + display_info("No completions") + setCompletions({}) + return + end + + local cursor = micro.CurPane().Buf:GetActiveCursor() + local backward = cursor.X + while backward > 0 and util.IsWordChar(util.RuneStr(cursor:RuneUnder(backward-1))) do + backward = backward - 1 + end + + cursor:SetSelectionStart(buffer.Loc(backward, cursor.Y)) + cursor:SetSelectionEnd(buffer.Loc(cursor.X, cursor.Y)) + + local completionList = {} + + if self.serverName == "rust-analyzer" then + -- unlike any other language server I've tried, rust-analyzer gives + -- completions that don't start with current stem, end with special + -- characters (eg. self::) and also occasionally contain duplicates + -- (same identifier from different namespace) + + local stem = cursor:GetSelection() + stem = util.String(stem) + + local uniqueCompletions = {} + for _, completionItem in pairs(completions) do + local item = completionItem.insertText or completionItem.label + -- FIXME: micro's autocomplete doesn't deal well with non-alnum + -- completions so we are currently just discarding them + if item:match("^[%a%d_]+$") and item:startsWith(stem) then + uniqueCompletions[item] = 1 + end + end + + for c, _ in pairs(uniqueCompletions) do + table.insert(completionList, c) + end + else + for _, completionItem in pairs(completions) do + local item = completionItem.insertText or completionItem.label + table.insert(completionList, item) + end + end + + cursor:DeleteSelection() + setCompletions(completionList) + + elseif method == "textDocument/references" then + if result == nil or table.empty(result) then + display_info("No references found") + return + end + showReferenceLocations("[µlsp] references", result) + elseif + method == "textDocument/declaration" or + method == "textDocument/definition" or + method == "textDocument/typeDefinition" or + method == "textDocument/implementation" + then + -- result: Location | Location[] | LocationLink[] | null + if result == nil or table.empty(result) then + display_info(string.format("%s not found", method:match("textDocument/(.*)$"))) + else + -- FIXME: handle list of results properly + -- if result is a list just take the first one + if result[1] then result = result[1] end + + -- FIXME: support LocationLink[] + if result.targetRange ~= nil then + display_info("LocationLinks are not supported yet") + return + end + + -- now result should be Location + local filepath = absPathFromFileUri(result.uri) + local startLoc, _ = LSPRange.toLocs(result.range) + + openFileAtLoc(filepath, startLoc) + end + elseif method == "textDocument/documentSymbol" then + if result == nil or table.empty(result) then + display_info("No symbols found in current document") + return + end + local symbolLocations = {} + local symbolLabels = {} + local SYMBOLKINDS = { + [1] = "File", + [2] = "Module", + [3] = "Namespace", + [4] = "Package", + [5] = "Class", + [6] = "Method", + [7] = "Property", + [8] = "Field", + [9] = "Constructor", + [10] = "Enum", + [11] = "Interface", + [12] = "Function", + [13] = "Variable", + [14] = "Constant", + [15] = "String", + [16] = "Number", + [17] = "Boolean", + [18] = "Array", + [19] = "Object", + [20] = "Key", + [21] = "Null", + [22] = "EnumMember", + [23] = "Struct", + [24] = "Event", + [25] = "Operator", + [26] = "TypeParameter", + } + for _, sym in ipairs(result) do + -- if sym.location is missing we are dealing with DocumentSymbol[] + -- instead of SymbolInformation[] + if sym.location == nil then + table.insert(symbolLocations, { + uri = micro.CurPane().Buf.AbsPath, + range = sym.range + }) + else + table.insert(symbolLocations, sym.location) + end + table.insert(symbolLabels, string.format("%-15s %s", "["..SYMBOLKINDS[sym.kind].."]", sym.name)) + end + showSymbolLocations("[µlsp] document symbols", symbolLocations, symbolLabels) + else + log("WARNING: dunno what to do with response to", method) + end +end + +function LSPClient:handleNotification(notification) + if notification.method == "textDocument/publishDiagnostics" then + local filePath = absPathFromFileUri(notification.params.uri) + + if self.openFiles[filePath] == nil then + log("DEBUG: received diagnostics for document that is not open:", filePath) + return + end + + local docVersion = notification.params.version + if docVersion ~= nil and docVersion ~= self.openFiles[filePath].version then + log("WARNING: received diagnostics for outdated version of document") + return + end + + self.openFiles[filePath].diagnostics = notification.params.diagnostics + + -- in the usual case there is only one buffer with the same document so a loop + -- would not be necessary, but there may sometimes be multiple buffers with the + -- same exact document open! + for _, buf in ipairs(docBuffers[filePath]) do + showDiagnostics(buf, self.clientId, notification.params.diagnostics) + end + elseif notification.method == "window/showMessage" then + -- notification.params.type can be 1 = error, 2 = warning, 3 = info, 4 = log, 5 = debug + if notification.params.type < 3 then + display_info(notification.params.message) + end + elseif notification.method == "window/logMessage" then + -- TODO: somehow include these messages in `lsp-showlog` + else + log("WARNING: don't know what to do with that message") + end +end + +function LSPClient:receiveMessage(text) + local decodedMsg = json.decode(text) + local request = self.sentRequests[decodedMsg.id] + if request then + self.sentRequests[decodedMsg.id] = nil + if decodedMsg.error then + self:handleResponseError(request, decodedMsg.error) + else + self:handleResponseResult(request, decodedMsg.result) + end + else + self:handleNotification(decodedMsg) + end +end + +function LSPClient:textDocumentIdentifier(buf) + return { uri = string.format("file://%s", buf.AbsPath:uriEncode()) } +end + +function LSPClient:didOpen(buf) + local textDocument = self:textDocumentIdentifier(buf) + local filePath = buf.AbsPath + + -- if file is already open, do nothing + if self.openFiles[filePath] ~= nil then + return + end + + local bufText = util.String(buf:Bytes()) + self.openFiles[filePath] = { + version = 1, + diagnostics = {} + } + textDocument.languageId = buf:FileType() + textDocument.version = 1 + textDocument.text = bufText + + self:notification("textDocument/didOpen", { + textDocument = textDocument + }) +end + +function LSPClient:didClose(buf) + local textDocument = self:textDocumentIdentifier(buf) + local filePath = buf.AbsPath + + if self.openFiles[filePath] ~= nil then + self.openFiles[filePath] = nil + + self:notification("textDocument/didClose", { + textDocument = textDocument + }) + end +end + +function LSPClient:didChange(buf, changes) + local textDocument = self:textDocumentIdentifier(buf) + local filePath = buf.AbsPath + + if self.openFiles[filePath] == nil then + log("ERROR: tried to emit didChange event for document that was not open") + return + end + + local newVersion = self.openFiles[filePath].version + 1 + + self.openFiles[filePath].version = newVersion + textDocument.version = newVersion + + self:notification("textDocument/didChange", { + textDocument = textDocument, + contentChanges = changes + }) +end + +function LSPClient:didSave(buf) + local textDocument = self:textDocumentIdentifier(buf) + + self:notification("textDocument/didSave", { + textDocument = textDocument + }) +end + +function LSPClient:onStdout(text) + + -- TODO: figure out if this is a performance bottleneck when receiving long + -- messages (tens of thousands of bytes) – I suspect Go's buffers would be + -- much faster than Lua string concatenation + self.buffer = self.buffer .. text + + while true do + if self.expectedLength == nil then + -- receive headers + -- TODO: figure out if it's necessary to handle the Content-Type header + local a, b = self.buffer:find("\r\n\r\n") + if a == nil then return end + local headers = self.buffer:sub(0, a) + local _, _, m = headers:find("Content%-Length: (%d+)") + self.expectedLength = tonumber(m) + self.buffer = self.buffer:sub(b+1) + + elseif self.buffer:len() < self.expectedLength then + return + + else + -- receive content + self:receiveMessage(self.buffer:sub(0, self.expectedLength)) + self.buffer = self.buffer:sub(self.expectedLength + 1) + self.expectedLength = nil + end + end +end + +function log(...) + micro.Log("[µlsp]", unpack(arg)) +end + +function display_error(...) + micro.InfoBar():Error("[µlsp] ", unpack(arg)) +end + +function display_info(...) + micro.InfoBar():Message("[µlsp] ", unpack(arg)) +end + + + +-- USER TRIGGERED ACTIONS +function hoverAction(bufpane) + local client = findClientWithCapability("hoverProvider", "hover information") + if client ~= nil then + local buf = bufpane.Buf + local cursor = buf:GetActiveCursor() + client:request("textDocument/hover", { + textDocument = client:textDocumentIdentifier(buf), + position = { line = cursor.Y, character = cursor.X } + }) + end +end + +function formatAction(bufpane) + local buf = bufpane.Buf + local selectedRanges = {} + + for i = 1, #buf:GetCursors() do + local cursor = buf:GetCursor(i - 1) + if cursor:HasSelection() then + table.insert(selectedRanges, LSPRange.fromSelection(cursor.CurSelection)) + end + end + + if #selectedRanges > 1 then + display_error("Formatting multiple selections is not supported yet") + return + end + + local formatOptions = { + -- most servers completely ignore these values but tabSize and + -- insertSpaces are required according to the specification + -- https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#formattingOptions + tabSize = buf.Settings["tabsize"], + insertSpaces = buf.Settings["tabstospaces"], + trimTrailingWhitespace = true, + insertFinalNewline = true, + trimFinalNewlines = true + } + + if #selectedRanges == 0 then + local client = findClientWithCapability("documentFormattingProvider", "formatting") + if client ~= nil then + client:request("textDocument/formatting", { + textDocument = client:textDocumentIdentifier(buf), + options = formatOptions + }) + end + else + local client = findClientWithCapability("documentRangeFormattingProvider", "formatting selections") + if client ~= nil then + client:request("textDocument/rangeFormatting", { + textDocument = client:textDocumentIdentifier(buf), + range = selectedRanges[1], + options = formatOptions + }) + end + end +end + +function completionAction(bufpane) + local client = findClientWithCapability("completionProvider", "completion") + if client ~= nil then + local buf = bufpane.Buf + local cursor = buf:GetActiveCursor() + client:request("textDocument/completion", { + textDocument = client:textDocumentIdentifier(buf), + position = { line = cursor.Y, character = cursor.X }, + context = { + -- 1 = Invoked, 2 = TriggerCharacter, 3 = TriggerForIncompleteCompletions + triggerKind = 1, + } + }) + end +end + +function gotoAction(kind) + local cap = string.format("%sProvider", kind) + local requestMethod = string.format("textDocument/%s", kind) + + return function(bufpane) + local client = findClientWithCapability(cap, requestMethod) + if client ~= nil then + local buf = bufpane.Buf + local cursor = buf:GetActiveCursor() + client:request(requestMethod, { + textDocument = client:textDocumentIdentifier(buf), + position = { line = cursor.Y, character = cursor.X } + }) + end + end +end + +function findReferencesAction(bufpane) + local client = findClientWithCapability("referencesProvider", "finding references") + if client ~= nil then + local buf = bufpane.Buf + local cursor = buf:GetActiveCursor() + client:request("textDocument/references", { + textDocument = client:textDocumentIdentifier(buf), + position = { line = cursor.Y, character = cursor.X }, + context = { includeDeclaration = true } + }) + end +end + +function documentSymbolsAction(bufpane) + local client = findClientWithCapability("documentSymbolProvider", "document symbols") + if client ~= nil then + local buf = bufpane.Buf + client:request("textDocument/documentSymbol", { + textDocument = client:textDocumentIdentifier(buf) + }) + end +end + +function openDiagnosticBufferAction(bufpane) + local buf = bufpane.Buf + local cursor = buf:GetActiveCursor() + local filePath = buf.AbsPath + local found = false + + for _, client in pairs(activeConnections) do + local diagnostics = client.openFiles[filePath].diagnostics + for idx, diagnostic in pairs(diagnostics) do + local startLoc, endLoc = LSPRange.toLocs(diagnostic.range) + if cursor.Loc.Y == startLoc.Y then + found = true + local bufContents = string.format( + "%s %s\nhref: %s\nseverity: %s\n\n%s", + diagnostic.source or client.serverName or client.clientId, + diagnostic.code or "(no error code)", + diagnostic.codeDescription and diagnostic.codeDescription.href or "-", + diagnostic.severity and severityToString(diagnostic.severity) or "-", + diagnostic.message + ) + local bufTitle = string.format("[µlsp] %s diagnostics #%d", client.clientId, idx) + local newBuffer = buffer.NewBuffer(bufContents, bufTitle) + newBuffer.Type.Readonly = true + local height = bufpane:GetView().Height + local newpane = micro.CurPane():HSplitBuf(newBuffer) + if height > 16 then + bufpane:ResizePane(height - 8) + end + end + end + end + if not found then + display_info("Found no diagnostics on current line") + end +end + + +-- EVENTS (LUA CALLBACKS) +-- https://github.com/zyedidia/micro/blob/master/runtime/help/plugins.md#lua-callbacks + +function onStdout(text, userargs) + local clientId = userargs[1] + log("<-(", clientId, "[stdout] )", text, "\n\n") + local client = allConnections[clientId] + client:onStdout(text) +end + +function onStderr(text, userargs) + local clientId = userargs[1] + -- log("<-(", clientId, "[stderr] )", text, "\n\n") + local client = allConnections[clientId] + client.stderr = client.stderr .. text +end + +function onExit(text, userargs) + local clientId = userargs[1] + local client = allConnections[clientId] + if client then + local reasonMsg + if client.job.Err ~= nil then -- LookPath error + reasonMsg = string.format("%s exited (%s)", clientId, client.job.Err:Error()) + elseif client.job.ProcessState ~= nil then + reasonMsg = string.format("%s exited (%s)", clientId, client.job.ProcessState:String()) + else + reasonMsg = string.format("%s exited", clientId) + end + display_error(reasonMsg) + log(reasonMsg) + end + activeConnections[clientId] = nil + allConnections[clientId] = nil +end + +function onBufferOpen(buf) + if buf.Type.Kind ~= buffer.BTDefault then return end + if buf:FileType() == "unknown" then return end + -- Ignore buffers created by clients + if string.startsWith(buf:GetName(), "[µlsp]") then return end + + local filePath = buf.AbsPath + + if docBuffers[filePath] == nil then + docBuffers[filePath] = { buf } + else + table.insert(docBuffers[filePath], buf) + end + + for _, client in pairs(activeConnections) do + client:didOpen(buf) + end + + local autostarts = settings.autostart[buf:FileType()] + if autostarts ~= nil then + for _, server in ipairs(autostarts) do + local clientId = server.shortName or server.cmd + if allConnections[clientId] == nil then + LSPClient:initialize(server) + end + end + end +end + +function onQuit(bufpane) + local closedBuf = bufpane.Buf + if closedBuf.Type.Kind ~= buffer.BTDefault then return end + + local filePath = closedBuf.AbsPath + if docBuffers[filePath] == nil then + return + elseif #docBuffers[filePath] > 1 then + -- there are still other buffers with the same file open + local remainingBuffers = {} + for _, buf in ipairs(docBuffers[filePath]) do + if buf ~= closedBuf then + table.insert(remainingBuffers, buf) + end + end + docBuffers[filePath] = remainingBuffers + else + -- this was the last buffer in which this particular file was open + docBuffers[filePath] = nil + + for _, client in pairs(activeConnections) do + client:didClose(closedBuf) + end + end + +end + +function onSave(bufpane) + for _, client in pairs(activeConnections) do + client:didSave(bufpane.Buf) + end +end + +function preAutocomplete(bufpane) + -- use micro's own autocompleter if there is no LSP connection + if next(activeConnections) == nil then return end + if not settings.tabAutocomplete then return end + if findClientWithCapability("completionProvider") == nil then return end + + -- "[µlsp] no autocompletions" message can be confusing if it does + -- not get cleared before falling back to micro's own completion + bufpane:ClearInfo() + + local cursor = bufpane.Buf:GetActiveCursor() + + -- don't autocomplete at the beginning of the line because you + -- often want tab to mean indentation there! + if cursor.X == 0 then return end + + -- if last auto completion happened on the same line then don't + -- do completionAction again (because updating the completions + -- would mess up tabbing through the suggestions) + -- FIXME: invent a better heuristic than line number for this + if lastAutocompletion == cursor.Y then return end + + local charBeforeCursor = util.RuneStr(cursor:RuneUnder(cursor.X-1)) + + if charBeforeCursor:match("%S") then + -- make sure there are at least two empty suggestions to capture + -- the autocompletion event – otherwise micro inserts '\t' before + -- the language server has a chance to reply with suggestions + setCompletions({"", ""}) + + completionAction(bufpane) + lastAutocompletion = cursor.Y + end +end + +-- Prevent inserting tab when autocompletions are being requested +function preInsertTab(bufpane) + if next(activeConnections) == nil then return true end + if not settings.tabAutocomplete then return true end + + local cursor = bufpane.Buf:GetActiveCursor() + return lastAutocompletion ~= cursor.Y +end + +-- FIXME: figure out how to disable all this garbage when there are no active connections + +function onBeforeTextEvent(buf, tevent) + if next(activeConnections) == nil then return end + + local changes = {} + for _, delta in userdataIterator(tevent.Deltas) do + table.insert( + changes, + { + range = LSPRange.fromDelta(delta), + text = util.String(delta.Text) + } + ) + end + + for _, client in pairs(activeConnections) do + client:didChange(buf, changes) + end +end + +function syncFullDocument(buf) + if next(activeConnections) == nil then return end + + clearAutocomplete() + -- filetype is "unknown" for the command prompt + if buf:FileType() == "unknown" then + return + end + + local changes = { + { text = util.String(buf:Bytes()) } + } + for _, client in pairs(activeConnections) do + client:didChange(buf, changes) + end +end + +function onCursorUp(bufpane) clearAutocomplete() end +function onCursorDown(bufpane) clearAutocomplete() end +function onCursorPageUp(bufpane) clearAutocomplete() end +function onCursorPageDown(bufpane) clearAutocomplete() end +function onCursorLeft(bufpane) clearAutocomplete() end +function onCursorRight(bufpane) clearAutocomplete() end +function onCursorStart(bufpane) clearAutocomplete() end +function onCursorEnd(bufpane) clearAutocomplete() end + +function preUndo(bp) + undoStackLengthBefore = bp.Buf.UndoStack:Len() +end +function onUndo(bp) + local numUndos = undoStackLengthBefore - bp.Buf.UndoStack:Len() + return handleUndosRedos(bp.Buf, bp.Buf.RedoStack.Top, numUndos) +end + +function preRedo(bp) + undoStackLengthBefore = bp.Buf.UndoStack:Len() +end +function onRedo(bp) + local numRedos = bp.Buf.UndoStack:Len() - undoStackLengthBefore + return handleUndosRedos(bp.Buf, bp.Buf.UndoStack.Top, numRedos) +end + +function handleUndosRedos(buf, elem, numChanges) + if next(activeConnections) == nil then return end + + local TEXT_EVENT = {INSERT = 1, REMOVE = -1, REPLACE = 0} + local tevents = {} + for i = 1, numChanges do + table.insert(tevents, elem.Value) + elem = elem.Next + end + + local changes = {} + for i = 1, #tevents do + local tev = tevents[#tevents + 1 - i] + for _, delta in userdataIterator(tev.Deltas) do + local text = "" + local range = LSPRange.fromDelta(delta) + + if tev.EventType == TEXT_EVENT.INSERT then + range["end"] = range["start"] + text = util.String(delta.Text) + end + + table.insert(changes, { range = range, text = text }) + end + end + + for _, client in pairs(activeConnections) do + client:didChange(buf, changes) + end +end + +-- HELPER FUNCTIONS + +function string.split(str) + local result = {} + for x in str:gmatch("[^%s]+") do + table.insert(result, x) + end + return result +end + +function string.startsWith(str, needle) + return string.sub(str, 1, #needle) == needle +end + +function string.uriDecode(str) + local function hexToChar(x) + return string.char(tonumber(x, 16)) + end + return str:gsub("%%(%x%x)", hexToChar) +end + +function string.uriEncode(str) + local function charToHex(c) + return string.format("%%%02X", string.byte(c)) + end + str = str:gsub("([^%w/ _%-.~])", charToHex) + str = str:gsub(" ", "+") + return str +end + + +function table.empty(x) + return type(x) == "table" and next(x) == nil +end + + +function editBuf(buf, textedits) + -- sort edits by start position (earliest first) + local function sortByRangeStart(texteditA, texteditB) + local a = texteditA.range.start + local b = texteditB.range.start + return a.line < b.line or (a.line == b.line and a.character < b.character) + end + -- FIXME: table.sort is not guaranteed to be stable, and the LSP specification + -- says that if two edits share the same start position the order in the array + -- should dictate the order, so this is probably bugged in rare edge cases... + table.sort(textedits, sortByRangeStart) + + local cursor = buf:GetActiveCursor() + + -- maybe there is a nice way to keep multicursors and selections? for now let's + -- just get rid of them before editing the buffer to avoid weird behavior + buf:ClearCursors() + cursor:Deselect(true) + + -- using byte offset seems to be the easiest & most reliable way to keep cursor + -- position even when lines get added/removed + local cursorLoc = buffer.Loc(cursor.Loc.X, cursor.Loc.Y) + local cursorByteOffset = buffer.ByteOffset(cursorLoc, buf) + + local editedBufParts = {} + + local prevEnd = buf:Start() + + for _, textedit in pairs(textedits) do + local startLoc, endLoc = LSPRange.toLocs(textedit.range) + if endLoc:GreaterThan(buf:End()) then + endLoc = buf:End() + end + + table.insert(editedBufParts, util.String(buf:Substr(prevEnd, startLoc))) + table.insert(editedBufParts, textedit.newText) + prevEnd = endLoc + + -- if the cursor is in the middle of a textedit this can move it a bit but it's fiiiine + -- (I don't think there's a clean way to figure out the right place for it) + if startLoc:LessThan(cursorLoc) then + local oldTextLength = buffer.ByteOffset(endLoc, buf) - buffer.ByteOffset(startLoc, buf) + cursorByteOffset = cursorByteOffset - oldTextLength + textedit.newText:len() + end + end + + table.insert(editedBufParts, util.String(buf:Substr(prevEnd, buf:End()))) + + buf:Remove(buf:Start(), buf:End()) + buf:Insert(buf:End(), go_strings.Join(editedBufParts, "")) + + local newCursorLoc = buffer.Loc(0, 0):Move(cursorByteOffset, buf) + buf:GetActiveCursor():GotoLoc(newCursorLoc) + + syncFullDocument(buf) +end + +function severityToString(severity) + local severityTable = { + [1] = "error", + [2] = "warning", + [3] = "information", + [4] = "hint" + } + return severityTable[severity] or "information" +end + +function showDiagnostics(buf, owner, diagnostics) + + buf:ClearMessages(owner) + + for _, diagnostic in pairs(diagnostics) do + local severity = severityToString(diagnostic.severity) + + if settings.showDiagnostics[severity] then + local extraInfo = nil + if diagnostic.code ~= nil then + diagnostic.code = tostring(diagnostic.code) + if string.startsWith(diagnostic.message, diagnostic.code .. " ") then + diagnostic.message = diagnostic.message:sub(2 + #diagnostic.code) + end + end + if diagnostic.source ~= nil and diagnostic.code ~= nil then + extraInfo = string.format("(%s %s) ", diagnostic.source, diagnostic.code) + elseif diagnostic.source ~= nil then + extraInfo = string.format("(%s) ", diagnostic.source) + elseif diagnostic.code ~= nil then + extraInfo = string.format("(%s) ", diagnostic.code) + end + + local lineNumber = diagnostic.range.start.line + 1 + + local msgType = buffer.MTInfo + if severity == "warning" then + msgType = buffer.MTWarning + elseif severity == "error" then + msgType = buffer.MTError + end + + local startLoc, endLoc = LSPRange.toLocs(diagnostic.range) + + -- prevent underlining empty space at the ends of lines + -- (fix pylsp being off-by-one with endLoc.X) + local endLineLength = #buf:Line(endLoc.Y) + if endLoc.X > endLineLength then + endLoc = buffer.Loc(endLineLength, endLoc.Y) + end + + local msg = diagnostic.message + -- make the msg look better on one line if there's newlines or extra whitespace + msg = msg:gsub("(%a)\n(%a)", "%1 / %2"):gsub("%s+", " ") + msg = string.format("[µlsp] %s%s", extraInfo or "", msg) + buf:AddMessage(buffer.NewMessage(owner, msg, startLoc, endLoc, msgType)) + end + end +end + +function clearAutocomplete() + lastAutocompletion = -1 +end + +function setCompletions(completions) + local buf = micro.CurPane().Buf + + buf.Suggestions = completions + buf.Completions = completions + buf.CurSuggestion = -1 + + if next(completions) == nil then + buf.HasSuggestions = false + else + buf:CycleAutocomplete(true) + end +end + +function findClientWithCapability(capabilityName, featureDescription) + if next(activeConnections) == nil then + display_error("No language server is running! Try starting one with the `lsp` command.") + return + end + + for _, client in pairs(activeConnections) do + if client.serverCapabilities[capabilityName] then + return client + end + end + if featureDescription ~= nil then + display_error("None of the active language server(s) support ", featureDescription) + end + return nil +end + +function absPathFromFileUri(uri) + local match = uri:match("file://(.*)$") + if match then + return match:uriDecode() + else + return uri + end +end + +function relPathFromAbsPath(absPath) + local cwd, err = go_os.Getwd() + if err then return absPath end + local relPath + relPath, err = filepath.Rel(cwd, absPath) + if err then return absPath end + return relPath +end + +function openFileAtLoc(filepath, loc) + local bp = micro.CurPane() + + -- don't open a new tab if file is already open + local alreadyOpenPane, tabIdx, paneIdx = findBufPaneByPath(filepath) + + if alreadyOpenPane then + micro.Tabs():SetActive(tabIdx) + alreadyOpenPane:tab():SetActive(paneIdx) + bp = alreadyOpenPane + else + local newBuf, err = buffer.NewBufferFromFile(filepath) + if err ~= nil then + display_error(err) + return + end + bp:AddTab() + bp = micro.CurPane() + bp:OpenBuffer(newBuf) + end + + bp.Buf:ClearCursors() -- remove multicursors + local cursor = bp.Buf:GetActiveCursor() + cursor:Deselect(false) -- clear selection + cursor:GotoLoc(loc) + bp.Buf:RelocateCursors() -- make sure cursor is inside the buffer + bp:Center() +end + +-- takes Location[] https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#location +-- and renders them to user +function showSymbolLocations(newBufferTitle, lspLocations, labels) + local symbols = {} + local maxLabelLen = 0 + for i, lspLoc in ipairs(lspLocations) do + local fpath = absPathFromFileUri(lspLoc.uri) + local lineNumber = lspLoc.range.start.line + 1 + local columnNumber = lspLoc.range.start.character + 1 + local labelLen = #labels[i] + symbols[i] = { + label = labels[i], + location = string.format("%s:%d:%d\n", fpath, lineNumber, columnNumber) + } + if maxLabelLen < labelLen then maxLabelLen = labelLen end + end + + local bufContents = "" + local format = "%-" .. maxLabelLen .. "s # %s" + for _, sym in ipairs(symbols) do + bufContents = bufContents .. string.format(format, sym.label, sym.location) + end + + local newBuffer = buffer.NewBuffer(bufContents, newBufferTitle) + newBuffer.Type.Scratch = true + newBuffer.Type.Readonly = true + micro.CurPane():HSplitBuf(newBuffer) +end + +-- takes Location[] https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#location +-- and renders them to user +function showReferenceLocations(newBufferTitle, lspLocations) + local references = {} + for i, lspLoc in ipairs(lspLocations) do + local fpath = absPathFromFileUri(lspLoc.uri) + local lineNumber = lspLoc.range.start.line + 1 + local columnNumber = lspLoc.range.start.character + 1 + references[i] = { + path = fpath, + line = lineNumber, + column = columnNumber, + } + end + + table.sort(references, function(a, b) + if a.path ~= b.path then return a.path < b.path end + if a.line ~= b.line then return a.line < b.line end + return a.column < b.column + end) + + local bufLines = {} + local curFilePath = "" + local file = nil + local lineCount = 0 + local lineContent = "" + for _, ref in ipairs(references) do + if curFilePath ~= ref.path then + if file then file:close() end + if #bufLines > 0 then table.insert(bufLines, "") end + curFilePath = ref.path + table.insert(bufLines, curFilePath) + file = io.open(curFilePath, "rb") + lineCount = 0 + end + + -- file can be nil if io.open failed + if file ~= nil then + while lineCount < ref.line do + lineContent = file:read("*l") + lineCount = lineCount + 1 + end + end + table.insert(bufLines, string.format("\t%d:%d:%s", ref.line, ref.column, lineContent or "")) + end + + if file then file:close() end -- last iteration does not close last file + table.insert(bufLines, "") + + local newBuffer = buffer.NewBuffer(table.concat(bufLines, "\n"), newBufferTitle) + newBuffer.Type.Scratch = true + newBuffer.Type.Readonly = true + --We enforce tabs, dont annoy users + newBuffer.Settings["hltaberrors"] = false + micro.CurPane():HSplitBuf(newBuffer) +end + +function findBufPaneByPath(fpath) + if fpath == nil then return nil end + for tabIdx, tab in userdataIterator(micro.Tabs().List) do + for paneIdx, pane in userdataIterator(tab.Panes) do + -- pane.Buf is nil for panes that are not BufPanes (terminals etc) + if pane.Buf ~= nil and fpath == pane.Buf.AbsPath then + -- lua indexing starts from 1 but go is stupid and starts from 0 :/ + return pane, tabIdx - 1, paneIdx - 1 + end + end + end +end + +function userdataIterator(data) + local idx = 0 + return function () + idx = idx + 1 + local success, item = pcall(function() return data[idx] end) + if success then return idx, item end + end +end