Dotfiles/micro/plug/mlsp/main.lua

1412 lines
46 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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