Files
dotfiles/dot_config/micro/plug/lsp/main.lua
Cian Hughes 896af887ca Changed . token to _dot
This change allows the dotfiles to work with chezmoi (e.g: on windows)
and improves grepability with neovim/telescope
2024-11-07 13:52:17 +00:00

934 lines
31 KiB
Lua

VERSION = "0.6.2"
local micro = import("micro")
local config = import("micro/config")
local shell = import("micro/shell")
local util = import("micro/util")
local buffer = import("micro/buffer")
local fmt = import("fmt")
local go_os = import("os")
local path = import("path")
local filepath = import("path/filepath")
local cmd = {}
local id = {}
local version = {}
local currentAction = {}
local capabilities = {}
local filetype = ''
local rootUri = ''
local message = ''
local completionCursor = 0
local lastCompletion = {}
local splitBP = nil
local tabCount = 0
local json = {}
function toBytes(str)
local result = {}
for i=1,#str do
local b = str:byte(i)
if b < 32 then
table.insert(result, b)
end
end
return result
end
function getUriFromBuf(buf)
if buf == nil then return; end
local file = buf.AbsPath
local uri = fmt.Sprintf("file://%s", file)
return uri
end
function mysplit (inputstr, sep)
if sep == nil then
sep = "%s"
end
local t={}
for str in string.gmatch(inputstr, "([^"..sep.."]+)") do
table.insert(t, str)
end
return t
end
function parseOptions(inputstr)
local t = {}
inputstr = inputstr:gsub("[%w+_-]+=[^=,]+={.-}", function (str)
table.insert(t, str)
return '';
end)
inputstr = inputstr:gsub("[%w+_-]+=[^=,]+", function (str)
table.insert(t, str)
return '';
end)
return t
end
function startServer(filetype, callback)
local wd, _ = go_os.Getwd()
rootUri = fmt.Sprintf("file://%s", wd)
local envSettings, _ = go_os.Getenv("MICRO_LSP")
local settings = config.GetGlobalOption("lsp.server")
local fallback = "python=pylsp,go=gopls,typescript=deno lsp,javascript=deno lsp,markdown=deno lsp,json=deno lsp,jsonc=deno lsp,rust=rls,lua=lua-lsp,c++=clangd"
if envSettings ~= nil and #envSettings > 0 then
settings = envSettings
end
if settings ~= nil and #settings > 0 then
settings = settings .. "," .. fallback
else
settings = fallback
end
local server = parseOptions(settings)
micro.Log("Server Options", server)
for i in pairs(server) do
local part = mysplit(server[i], "=")
local run = mysplit(part[2], "%s")
local initOptions = part[3] or '{}'
local runCmd = table.remove(run, 1)
local args = run
if filetype == part[1] then
local send = withSend(part[1])
if cmd[part[1]] ~= nil then return; end
id[part[1]] = 0
micro.Log("Starting server", part[1])
cmd[part[1]] = shell.JobSpawn(runCmd, args, onStdout(part[1]), onStderr, onExit(part[1]), {})
currentAction[part[1]] = { method = "initialize", response = function (bp, data)
send("initialized", "{}", true)
capabilities[filetype] = data.result and data.result.capabilities or {}
callback(bp.Buf, filetype)
end }
send(currentAction[part[1]].method, fmt.Sprintf('{"processId": %.0f, "rootUri": "%s", "workspaceFolders": [{"name": "root", "uri": "%s"}], "initializationOptions": %s, "capabilities": {"textDocument": {"hover": {"contentFormat": ["plaintext", "markdown"]}, "publishDiagnostics": {"relatedInformation": false, "versionSupport": false, "codeDescriptionSupport": true, "dataSupport": true}, "signatureHelp": {"signatureInformation": {"documentationFormat": ["plaintext", "markdown"]}}}}}', go_os.Getpid(), rootUri, rootUri, initOptions))
return
end
end
end
function init()
config.RegisterCommonOption("lsp", "server", "python=pylsp,go=gopls,typescript=deno lsp,javascript=deno lsp,markdown=deno lsp,json=deno lsp,jsonc=deno lsp,rust=rls,lua=lua-lsp,c++=clangd")
config.RegisterCommonOption("lsp", "formatOnSave", true)
config.RegisterCommonOption("lsp", "autocompleteDetails", false)
config.RegisterCommonOption("lsp", "ignoreMessages", "")
config.RegisterCommonOption("lsp", "tabcompletion", true)
config.RegisterCommonOption("lsp", "ignoreTriggerCharacters", "completion")
-- example to ignore all LSP server message starting with these strings:
-- "lsp.ignoreMessages": "Skipping analyzing |See https://"
config.MakeCommand("hover", hoverAction, config.NoComplete)
config.MakeCommand("definition", definitionAction, config.NoComplete)
config.MakeCommand("lspcompletion", completionAction, config.NoComplete)
config.MakeCommand("format", formatAction, config.NoComplete)
config.MakeCommand("references", referencesAction, config.NoComplete)
config.TryBindKey("Alt-k", "command:hover", false)
config.TryBindKey("Alt-d", "command:definition", false)
config.TryBindKey("Alt-f", "command:format", false)
config.TryBindKey("Alt-r", "command:references", false)
config.TryBindKey("CtrlSpace", "command:lspcompletion", false)
config.AddRuntimeFile("lsp", config.RTHelp, "help/lsp.md")
-- @TODO register additional actions here
end
function withSend(filetype)
return function (method, params, isNotification)
if cmd[filetype] == nil then
return
end
local msg = fmt.Sprintf('{"jsonrpc": "2.0", %s"method": "%s", "params": %s}', not isNotification and fmt.Sprintf('"id": %.0f, ', id[filetype]) or "", method, params)
id[filetype] = id[filetype] + 1
msg = fmt.Sprintf("Content-Length: %.0f\r\n\r\n%s", #msg, msg)
--micro.Log("send", filetype, "sending", method or msg, msg)
shell.JobSend(cmd[filetype], msg)
end
end
function preRune(bp, r)
if splitBP ~= nil then
pcall(function () splitBP:Unsplit(); end)
splitBP = nil
local cur = bp.Buf:GetActiveCursor()
cur:Deselect(false);
cur:GotoLoc(buffer.Loc(cur.X + 1, cur.Y))
end
end
-- when a new character is types, the document changes
function onRune(bp, r)
local filetype = bp.Buf:FileType()
if cmd[filetype] == nil then
return
end
if splitBP ~= nil then
pcall(function () splitBP:Unsplit(); end)
splitBP = nil
end
local send = withSend(filetype)
local uri = getUriFromBuf(bp.Buf)
if r ~= nil then
lastCompletion = {}
end
-- allow the document contents to be escaped properly for the JSON string
local content = util.String(bp.Buf:Bytes()):gsub("\\", "\\\\"):gsub("\n", "\\n"):gsub("\r", "\\r"):gsub('"', '\\"'):gsub("\t", "\\t")
-- increase change version
version[uri] = (version[uri] or 0) + 1
send("textDocument/didChange", fmt.Sprintf('{"textDocument": {"version": %.0f, "uri": "%s"}, "contentChanges": [{"text": "%s"}]}', version[uri], uri, content), true)
local ignored = mysplit(config.GetGlobalOption("lsp.ignoreTriggerCharacters") or '', ",")
if r and capabilities[filetype] then
if not contains(ignored, "completion") and capabilities[filetype].completionProvider and capabilities[filetype].completionProvider.triggerCharacters and contains(capabilities[filetype].completionProvider.triggerCharacters, r) then
completionAction(bp)
elseif not contains(ignored, "signature") and capabilities[filetype].signatureHelpProvider and capabilities[filetype].signatureHelpProvider.triggerCharacters and contains(capabilities[filetype].signatureHelpProvider.triggerCharacters, r) then
hoverAction(bp)
end
end
end
-- alias functions for any kind of change to the document
-- @TODO: add missing ones
function onBackspace(bp) onRune(bp); end
function onCut(bp) onRune(bp); end
function onCutLine(bp) onRune(bp); end
function onDuplicateLine(bp) onRune(bp); end
function onDeleteLine(bp) onRune(bp); end
function onDelete(bp) onRune(bp); end
function onUndo(bp) onRune(bp); end
function onRedo(bp) onRune(bp); end
function onIndent(bp) onRune(bp); end
function onIndentSelection(bp) onRune(bp); end
function onPaste(bp) onRune(bp); end
function onSave(bp) onRune(bp); end
function onEscape(bp)
if splitBP ~= nil then
pcall(function () splitBP:Unsplit(); end)
splitBP = nil
end
end
function preInsertNewline(bp)
if bp.Buf.Path == "References found" then
local cur = bp.Buf:GetActiveCursor()
cur:SelectLine()
local data = util.String(cur:GetSelection())
local file, line, character = data:match("(./[^:]+):([^:]+):([^:]+)")
local doc, _ = file:gsub("^file://", "")
buf, _ = buffer.NewBufferFromFile(doc)
bp:AddTab()
micro.CurPane():OpenBuffer(buf)
buf:GetActiveCursor():GotoLoc(buffer.Loc(character * 1, line * 1))
micro.CurPane():Center()
return false
end
end
function preSave(bp)
if config.GetGlobalOption("lsp.formatOnSave") then
onRune(bp)
formatAction(bp, function ()
bp:Save()
end)
end
end
function handleInitialized(buf, filetype)
if cmd[filetype] == nil then return; end
micro.Log("Found running lsp server for ", filetype, "firing textDocument/didOpen...")
local send = withSend(filetype)
local uri = getUriFromBuf(buf)
local content = util.String(buf:Bytes()):gsub("\\", "\\\\"):gsub("\n", "\\n"):gsub("\r", "\\r"):gsub('"', '\\"'):gsub("\t", "\\t")
send("textDocument/didOpen", fmt.Sprintf('{"textDocument": {"uri": "%s", "languageId": "%s", "version": 1, "text": "%s"}}', uri, filetype, content), true)
end
function onBufferOpen(buf)
local filetype = buf:FileType()
micro.Log("ONBUFFEROPEN", filetype)
if filetype ~= "unknown" and rootUri == "" and not cmd[filetype] then return startServer(filetype, handleInitialized); end
if cmd[filetype] then
handleInitialized(buf, filetype)
end
end
function contains(list, x)
for _, v in pairs(list) do
if v == x then return true; end
end
return false
end
function string.starts(String, Start)
return string.sub(String, 1, #Start) == Start
end
function string.ends(String, End)
return string.sub(String, #String - (#End - 1), #String) == End
end
function string.random(CharSet, Length, prefix)
local _CharSet = CharSet or '.'
if _CharSet == '' then
return ''
else
local Result = prefix or ""
math.randomseed(os.time())
for Loop = 1,Length do
local char = math.random(1, #CharSet)
Result = Result .. CharSet:sub(char,char)
end
return Result
end
end
function string.parse(text)
if not text:find('"jsonrpc":') then return {}; end
local start,fin = text:find("\n%s*\n")
local cleanedText = text
if fin ~= nil then
cleanedText = text:sub(fin)
end
local status, res = pcall(json.parse, cleanedText)
if status then
return res
end
return false
end
function isIgnoredMessage(msg)
-- Return true if msg matches one of the ignored starts of messages
-- Useful for linters that show spurious, hard to disable warnings
local ignoreList = mysplit(config.GetGlobalOption("lsp.ignoreMessages"), "|")
for i, ignore in pairs(ignoreList) do
if string.match(msg, ignore) then -- match from start of string
micro.Log("Ignore message: '", msg, "', because it matched: '", ignore, "'.")
return true -- ignore this message, dont show to user
end
end
return false -- show this message to user
end
function onStdout(filetype)
return function (text)
if text:starts("Content-Length:") then
message = text
else
message = message .. text
end
if not text:ends("}") then
return
end
local data = message:parse()
if data == false then
return
end
if data.method == "workspace/configuration" then
-- actually needs to respond with the same ID as the received JSON
local message = fmt.Sprintf('{"jsonrpc": "2.0", "id": %.0f, "result": [{"enable": true}]}', data.id)
shell.JobSend(cmd[filetype], fmt.Sprintf('Content-Length: %.0f\n\n%s', #message, message))
elseif data.method == "textDocument/publishDiagnostics" or data.method == "textDocument\\/publishDiagnostics" then
-- react to server-published event
local bp = micro.CurPane().Buf
bp:ClearMessages("lsp")
bp:AddMessage(buffer.NewMessage("lsp", "", buffer.Loc(0, 10000000), buffer.Loc(0, 10000000), buffer.MTInfo))
local uri = getUriFromBuf(bp)
if data.params.uri == uri then
for _, diagnostic in ipairs(data.params.diagnostics) do
local type = buffer.MTInfo
if diagnostic.severity == 1 then
type = buffer.MTError
elseif diagnostic.severity == 2 then
type = buffer.MTWarning
end
local mstart = buffer.Loc(diagnostic.range.start.character, diagnostic.range.start.line)
local mend = buffer.Loc(diagnostic.range["end"].character, diagnostic.range["end"].line)
if not isIgnoredMessage(diagnostic.message) then
msg = buffer.NewMessage("lsp", diagnostic.message, mstart, mend, type)
bp:AddMessage(msg)
end
end
end
elseif currentAction[filetype] and currentAction[filetype].method and not data.method and currentAction[filetype].response and data.jsonrpc then -- react to custom action event
local bp = micro.CurPane()
micro.Log("Received message for ", filetype, data)
currentAction[filetype].response(bp, data)
currentAction[filetype] = {}
elseif data.method == "window/showMessage" or data.method == "window\\/showMessage" then
if filetype == micro.CurPane().Buf:FileType() then
micro.InfoBar():Message(data.params.message)
else
micro.Log(filetype .. " message " .. data.params.message)
end
elseif data.method == "window/logMessage" or data.method == "window\\/logMessage" then
micro.Log(data.params.message)
elseif message:starts("Content-Length:") then
if message:find('"') and not message:find('"result":null') then
micro.Log("Unhandled message 1", filetype, message)
end
else
-- enable for debugging purposes
micro.Log("Unhandled message 2", filetype, message)
end
end
end
function onStderr(text)
micro.Log("ONSTDERR", text)
--micro.InfoBar():Message(text)
end
function onExit(filetype)
return function (str)
currentAction[filetype] = nil
cmd[filetype] = nil
micro.Log("ONEXIT", filetype, str)
end
end
-- the actual hover action request and response
-- the hoverActionResponse is hooked up in
function hoverAction(bp)
local filetype = bp.Buf:FileType()
if cmd[filetype] ~= nil then
local send = withSend(filetype)
local file = bp.Buf.AbsPath
local line = bp.Buf:GetActiveCursor().Y
local char = bp.Buf:GetActiveCursor().X
currentAction[filetype] = { method = "textDocument/hover", response = hoverActionResponse }
send(currentAction[filetype].method, fmt.Sprintf('{"textDocument": {"uri": "file://%s"}, "position": {"line": %.0f, "character": %.0f}}', file, line, char))
end
end
function hoverActionResponse(buf, data)
if data.result and data.result.contents ~= nil and data.result.contents ~= "" then
if data.result.contents.value then
micro.InfoBar():Message(data.result.contents.value)
elseif #data.result.contents > 0 then
micro.InfoBar():Message(data.result.contents[1].value)
end
end
end
-- the definition action request and response
function definitionAction(bp)
local filetype = bp.Buf:FileType()
if cmd[filetype] == nil then return; end
local send = withSend(filetype)
local file = bp.Buf.AbsPath
local line = bp.Buf:GetActiveCursor().Y
local char = bp.Buf:GetActiveCursor().X
currentAction[filetype] = { method = "textDocument/definition", response = definitionActionResponse }
send(currentAction[filetype].method, fmt.Sprintf('{"textDocument": {"uri": "file://%s"}, "position": {"line": %.0f, "character": %.0f}}', file, line, char))
end
function definitionActionResponse(bp, data)
local results = data.result or data.partialResult
if results == nil then return; end
local file = bp.Buf.AbsPath
if results.uri ~= nil then
-- single result
results = { results }
end
if #results <= 0 then return; end
local uri = (results[1].uri or results[1].targetUri)
local doc = uri:gsub("^file://", "")
local buf = bp.Buf
if file ~= doc then
-- it's from a different file, so open it as a new tab
buf, _ = buffer.NewBufferFromFile(doc)
bp:AddTab()
micro.CurPane():OpenBuffer(buf)
-- shorten the displayed name in status bar
name = buf:GetName()
local wd, _ = go_os.Getwd()
if name:starts(wd) then
buf:SetName("." .. name:sub(#wd + 1, #name + 1))
else
if #name > 30 then
buf:SetName("..." .. name:sub(-30, #name + 1))
end
end
end
local range = results[1].range or results[1].targetSelectionRange
buf:GetActiveCursor():GotoLoc(buffer.Loc(range.start.character, range.start.line))
bp:Center()
end
function completionAction(bp)
local filetype = bp.Buf:FileType()
local send = withSend(filetype)
local file = bp.Buf.AbsPath
local line = bp.Buf:GetActiveCursor().Y
local char = bp.Buf:GetActiveCursor().X
if lastCompletion[1] == file and lastCompletion[2] == line and lastCompletion[3] == char then
completionCursor = completionCursor + 1
else
completionCursor = 0
if bp.Cursor:HasSelection() then
-- we have a selection
-- assume we want to indent the selection
bp:IndentSelection()
return
end
if char == 0 then
-- we are at the very first character of a line
-- assume we want to indent
bp:IndentLine()
return
end
local cur = bp.Buf:GetActiveCursor()
cur:SelectLine()
local lineContent = util.String(cur:GetSelection())
cur:ResetSelection()
cur:GotoLoc(buffer.Loc(char, line))
local startOfLine = "" .. lineContent:sub(1, char)
if startOfLine:match("^%s+$") then
-- we are at the beginning of a line
-- assume we want to indent the line
bp:IndentLine()
return
end
end
if cmd[filetype] == nil then return; end
lastCompletion = {file, line, char}
currentAction[filetype] = { method = "textDocument/completion", response = completionActionResponse }
send(currentAction[filetype].method, fmt.Sprintf('{"textDocument": {"uri": "file://%s"}, "position": {"line": %.0f, "character": %.0f}}', file, line, char))
end
table.filter = function(t, filterIter)
local out = {}
for k, v in pairs(t) do
if filterIter(v, k, t) then table.insert(out, v) end
end
return out
end
function findCommon(input, list)
local commonLen = 0
local prefixList = {}
local str = input.textEdit and input.textEdit.newText or input.label
for i = 1,#str,1 do
local prefix = str:sub(1, i)
prefixList[prefix] = 0
for idx, entry in ipairs(list) do
local currentEntry = entry.textEdit and entry.textEdit.newText or entry.label
if currentEntry:starts(prefix) then
prefixList[prefix] = prefixList[prefix] + 1
end
end
end
local longest = ""
for idx, entry in pairs(prefixList) do
if entry >= #list then
if #longest < #idx then
longest = idx
end
end
end
if #list == 1 then
return list[1].textEdit and list[1].textEdit.newText or list[1].label
end
return longest
end
function completionActionResponse(bp, data)
local results = data.result
if results == nil then
return
end
if results.items then
results = results.items
end
local xy = buffer.Loc(bp.Cursor.X, bp.Cursor.Y)
local start = xy
if bp.Cursor:HasSelection() then
bp.Cursor:DeleteSelection()
end
local found = false
local prefix = ""
local reversed = ""
-- if we have no defined ranges in the result
-- try to find out what our prefix is we want to filter against
if not results[1] or not results[1].textEdit or not results[1].textEdit.range then
if capabilities[bp.Buf:FileType()] and capabilities[bp.Buf:FileType()].completionProvider and capabilities[bp.Buf:FileType()].completionProvider.triggerCharacters then
local cur = bp.Buf:GetActiveCursor()
cur:SelectLine()
local lineContent = util.String(cur:GetSelection())
reversed = string.reverse(lineContent:gsub("\r?\n$", ""):sub(1, xy.X))
local triggerChars = capabilities[bp.Buf:FileType()].completionProvider.triggerCharacters
for i = 1,#reversed,1 do
local char = reversed:sub(i,i)
-- try to find a trigger character or any other non-word character
if contains(triggerChars, char) or contains({" ", ":", "/", "-", "\t", ";"}, char) then
found = true
start = buffer.Loc(#reversed - (i - 1), bp.Cursor.Y)
bp.Cursor:SetSelectionStart(start)
bp.Cursor:SetSelectionEnd(xy)
prefix = util.String(cur:GetSelection())
bp.Cursor:DeleteSelection()
bp.Cursor:ResetSelection()
break
end
end
if not found then
prefix = lineContent:gsub("\r?\n$", '')
end
end
-- if we have found a prefix
if prefix ~= "" then
-- filter it down to what is suggested by the prefix
results = table.filter(results, function (entry)
return entry.label:starts(prefix)
end)
end
end
table.sort(results, function (left, right)
return (left.sortText or left.label) < (right.sortText or right.label)
end)
entry = results[(completionCursor % #results) + 1]
-- if no matching results are found
if entry == nil then
-- reposition cursor and stop
bp.Cursor:GotoLoc(xy)
return
end
local commonStart = ''
local toInsert = entry.textEdit and entry.textEdit.newText or entry.label
local isTabCompletion = config.GetGlobalOption("lsp.tabcompletion")
if isTabCompletion and not entry.textEdit then
commonStart = findCommon(entry, results)
bp.Buf:Insert(start, commonStart)
if prefix ~= commonStart then
return
end
start = buffer.Loc(start.X + #prefix, start.Y)
else
prefix = ''
end
if entry.textEdit and entry.textEdit.range then
start = buffer.Loc(entry.textEdit.range.start.character, entry.textEdit.range.start.line)
bp.Cursor:SetSelectionStart(start)
bp.Cursor:SetSelectionEnd(xy)
bp.Cursor:DeleteSelection()
bp.Cursor:ResetSelection()
elseif capabilities[bp.Buf:FileType()] and capabilities[bp.Buf:FileType()].completionProvider and capabilities[bp.Buf:FileType()].completionProvider.triggerCharacters then
if not found then
-- we found nothing - so assume we need the beginning of the line
if reversed:starts(" ") or reversed:starts("\t") then
-- if we end with some indentation, keep it
start = buffer.Loc(#reversed, bp.Cursor.Y)
else
start = buffer.Loc(0, bp.Cursor.Y)
end
bp.Cursor:SetSelectionStart(start)
bp.Cursor:SetSelectionEnd(xy)
bp.Cursor:DeleteSelection()
bp.Cursor:ResetSelection()
end
end
local inserting = "" .. toInsert:gsub(prefix, "")
bp.Buf:Insert(start, inserting)
if #results > 1 then
if entry.textEdit then
bp.Cursor:GotoLoc(start)
bp.Cursor:SetSelectionStart(start)
else
-- if we had to calculate everything outselves
-- go back to the original location
bp.Cursor:GotoLoc(xy)
bp.Cursor:SetSelectionStart(xy)
end
bp.Cursor:SetSelectionEnd(buffer.Loc(start.X + #toInsert, start.Y))
else
bp.Cursor:GotoLoc(buffer.Loc(start.X + #inserting, start.Y))
end
local startLoc = buffer.Loc(0, 0)
local endLoc = buffer.Loc(0, 0)
local msg = ''
local insertion = ''
if entry.detail or entry.documentation then
insertion = fmt.Sprintf("%s", entry.detail or entry.documentation or '')
for idx, result in ipairs(results) do
if #msg > 0 then
msg = msg .. "\n"
end
local insertion = fmt.Sprintf("%s %s", result.detail or '', result.documentation or '')
if idx == (completionCursor % #results) + 1 then
local msglines = mysplit(msg, "\n")
startLoc = buffer.Loc(0, #msglines)
endLoc = buffer.Loc(#insertion - 1, #msglines)
end
msg = msg .. insertion
end
else
insertion = entry.label
for idx, result in ipairs(results) do
if #msg > 0 then
local msglines = mysplit(msg, "\n")
local lastLine = msglines[#msglines]
local len = #result.label + 4
if #lastLine + len >= bp:GetView().Width then
msg = msg .. "\n "
else
msg = msg .. ' '
end
else
msg = " "
end
if idx == (completionCursor % #results) + 1 then
local msglines = mysplit(msg, "\n")
local prefixLen = 0
if #msglines > 0 then
prefixLen = #msglines[#msglines]
else
prefixLen = #msg
end
startLoc = buffer.Loc(prefixLen or 0, #msglines - 1)
endLoc = buffer.Loc(prefixLen + #result.label, #msglines - 1)
end
msg = msg .. result.label
end
end
if config.GetGlobalOption("lsp.autocompleteDetails") then
if not splitBP then
local tmpName = ("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"):random(32)
local logBuf = buffer.NewBuffer(msg, tmpName)
splitBP = bp:HSplitBuf(logBuf)
bp:NextSplit()
else
splitBP:SelectAll()
splitBP.Cursor:DeleteSelection()
splitBP.Cursor:ResetSelection()
splitBP.Buf:insert(buffer.Loc(1, 1), msg)
end
splitBP.Cursor:ResetSelection()
splitBP.Cursor:SetSelectionStart(startLoc)
splitBP.Cursor:SetSelectionEnd(endLoc)
else
if entry.detail or entry.documentation then
micro.InfoBar():Message(insertion)
else
local cleaned = " " .. msg:gsub("%s+", " ")
local replaced, _ = cleaned:gsub(".*%s" .. insertion .. "%s?", " [" .. insertion .. "] ")
micro.InfoBar():Message(replaced)
end
end
end
function formatAction(bp, callback)
local filetype = bp.Buf:FileType()
if cmd[filetype] == nil then return; end
local send = withSend(filetype)
local file = bp.Buf.AbsPath
currentAction[filetype] = { method = "textDocument/formatting", response = formatActionResponse(callback) }
send(currentAction[filetype].method, fmt.Sprintf('{"textDocument": {"uri": "file://%s"}, "options": {"tabSize": 4, "insertSpaces": true}}', file))
end
function formatActionResponse(callback)
return function (bp, data)
if data.result == nil then return; end
local edits = data.result
-- make sure we apply the changes from back to front
-- this allows for changes to not need position updates
table.sort(edits, function (left, right)
-- go by lines first
return left.range['end'].line > right.range['end'].line or
-- if lines match, go by end character
left.range['end'].line == right.range['end'].line and left.range['end'].character > right.range['end'].character or
-- if they match too, go by start character
left.range['end'].line == right.range['end'].line and left.range['end'].character == right.range['end'].character and left.range.start.line == left.range['end'].line and left.range.start.character > right.range.start.character
end)
-- save original cursor position
local xy = buffer.Loc(bp.Cursor.X, bp.Cursor.Y)
for _idx, edit in ipairs(edits) do
rangeStart = buffer.Loc(edit.range.start.character, edit.range.start.line)
rangeEnd = buffer.Loc(edit.range['end'].character, edit.range['end'].line)
-- apply each change
bp.Cursor:GotoLoc(rangeStart)
bp.Cursor:SetSelectionStart(rangeStart)
bp.Cursor:SetSelectionEnd(rangeEnd)
bp.Cursor:DeleteSelection()
bp.Cursor:ResetSelection()
if edit.newText ~= "" then
bp.Buf:insert(rangeStart, edit.newText)
end
end
-- put the cursor back where it was
bp.Cursor:GotoLoc(xy)
-- if any changes were applied
if #edits > 0 then
-- tell the server about the changed document
onRune(bp)
end
if callback ~= nil then
callback(bp)
end
end
end
-- the references action request and response
function referencesAction(bp)
local filetype = bp.Buf:FileType()
if cmd[filetype] == nil then return; end
local send = withSend(filetype)
local file = bp.Buf.AbsPath
local line = bp.Buf:GetActiveCursor().Y
local char = bp.Buf:GetActiveCursor().X
currentAction[filetype] = { method = "textDocument/references", response = referencesActionResponse }
send(currentAction[filetype].method, fmt.Sprintf('{"textDocument": {"uri": "file://%s"}, "position": {"line": %.0f, "character": %.0f}, "context": {"includeDeclaration":true}}', file, line, char))
end
function referencesActionResponse(bp, data)
if data.result == nil then return; end
local results = data.result or data.partialResult
if results == nil or #results <= 0 then return; end
local file = bp.Buf.AbsPath
local msg = ''
for _idx, ref in ipairs(results) do
if msg ~= '' then msg = msg .. '\n'; end
local doc = (ref.uri or ref.targetUri)
msg = msg .. "." .. doc:sub(#rootUri + 1, #doc) .. ":" .. ref.range.start.line .. ":" .. ref.range.start.character
end
local logBuf = buffer.NewBuffer(msg, "References found")
local splitBP = bp:HSplitBuf(logBuf)
end
--
-- @TODO implement additional functions here...
--
--
-- JSON
--
-- Internal functions.
local function kind_of(obj)
if type(obj) ~= 'table' then return type(obj) end
local i = 1
for _ in pairs(obj) do
if obj[i] ~= nil then i = i + 1 else return 'table' end
end
if i == 1 then return 'table' else return 'array' end
end
local function escape_str(s)
local in_char = {'\\', '"', '/', '\b', '\f', '\n', '\r', '\t'}
local out_char = {'\\', '"', '/', 'b', 'f', 'n', 'r', 't'}
for i, c in ipairs(in_char) do
s = s:gsub(c, '\\' .. out_char[i])
end
return s
end
-- Returns pos, did_find; there are two cases:
-- 1. Delimiter found: pos = pos after leading space + delim; did_find = true.
-- 2. Delimiter not found: pos = pos after leading space; did_find = false.
-- This throws an error if err_if_missing is true and the delim is not found.
local function skip_delim(str, pos, delim, err_if_missing)
pos = pos + #str:match('^%s*', pos)
if str:sub(pos, pos) ~= delim then
if err_if_missing then
error('Expected ' .. delim .. ' near position ' .. pos)
end
return pos, false
end
return pos + 1, true
end
-- Expects the given pos to be the first character after the opening quote.
-- Returns val, pos; the returned pos is after the closing quote character.
local function parse_str_val(str, pos, val)
val = val or ''
local early_end_error = 'End of input found while parsing string.'
if pos > #str then error(early_end_error) end
local c = str:sub(pos, pos)
if c == '"' then return val, pos + 1 end
if c ~= '\\' then return parse_str_val(str, pos + 1, val .. c) end
-- We must have a \ character.
local esc_map = {b = '\b', f = '\f', n = '\n', r = '\r', t = '\t'}
local nextc = str:sub(pos + 1, pos + 1)
if not nextc then error(early_end_error) end
return parse_str_val(str, pos + 2, val .. (esc_map[nextc] or nextc))
end
-- Returns val, pos; the returned pos is after the number's final character.
local function parse_num_val(str, pos)
local num_str = str:match('^-?%d+%.?%d*[eE]?[+-]?%d*', pos)
local val = tonumber(num_str)
if not val then error('Error parsing number at position ' .. pos .. '.') end
return val, pos + #num_str
end
json.null = {} -- This is a one-off table to represent the null value.
function json.parse(str, pos, end_delim)
pos = pos or 1
if pos > #str then error('Reached unexpected end of input.' .. str) end
local pos = pos + #str:match('^%s*', pos) -- Skip whitespace.
local first = str:sub(pos, pos)
if first == '{' then -- Parse an object.
local obj, key, delim_found = {}, true, true
pos = pos + 1
while true do
key, pos = json.parse(str, pos, '}')
if key == nil then return obj, pos end
if not delim_found then error('Comma missing between object items.') end
pos = skip_delim(str, pos, ':', true) -- true -> error if missing.
obj[key], pos = json.parse(str, pos)
pos, delim_found = skip_delim(str, pos, ',')
end
elseif first == '[' then -- Parse an array.
local arr, val, delim_found = {}, true, true
pos = pos + 1
while true do
val, pos = json.parse(str, pos, ']')
if val == nil then return arr, pos end
if not delim_found then error('Comma missing between array items.') end
arr[#arr + 1] = val
pos, delim_found = skip_delim(str, pos, ',')
end
elseif first == '"' then -- Parse a string.
return parse_str_val(str, pos + 1)
elseif first == '-' or first:match('%d') then -- Parse a number.
return parse_num_val(str, pos)
elseif first == end_delim then -- End of an object or array.
return nil, pos + 1
else -- Parse true, false, or null.
local literals = {['true'] = true, ['false'] = false, ['null'] = json.null}
for lit_str, lit_val in pairs(literals) do
local lit_end = pos + #lit_str - 1
if str:sub(pos, lit_end) == lit_str then return lit_val, lit_end + 1 end
end
local pos_info_str = 'position ' .. pos .. ': ' .. str:sub(pos, pos + 10)
error('Invalid json syntax starting at ' .. pos_info_str .. ': ' .. str)
end
end