--- mod-version:3 -- -- LSP client for lite-xl -- @copyright Jefferson Gonzalez -- @license MIT -- -- Note: Annotations syntax documentation which is supported by -- https://github.com/sumneko/lua-language-server can be read here: -- https://emmylua.github.io/annotation.html -- TODO Change the code to make it possible to use more than one LSP server -- for a single file if possible and needed, for eg: -- One lsp may not support goto definition but another one registered -- for the current document filetype may do. local core = require "core" local common = require "core.common" local config = require "core.config" local command = require "core.command" local style = require "core.style" local keymap = require "core.keymap" local translate = require "core.doc.translate" local autocomplete = require "plugins.autocomplete" local Doc = require "core.doc" local DocView = require "core.docview" local StatusView = require "core.statusview" local RootView = require "core.rootview" local LineWrapping -- If the lsp plugin is loaded from users init.lua it will load linewrapping -- even if it was disabled from the settings ui, so we queue this check since -- there is no way to automatically load settings ui before the user module. core.add_thread(function() if config.plugins.linewrapping or type(config.plugins.linewrapping) == "nil" then LineWrapping = require "plugins.linewrapping" end end) local json = require "plugins.lsp.json" local util = require "plugins.lsp.util" local listbox = require "plugins.lsp.listbox" local diagnostics = require "plugins.lsp.diagnostics" local Server = require "plugins.lsp.server" local Timer = require "plugins.lsp.timer" local SymbolResults = require "plugins.lsp.symbolresults" local MessageBox = require "libraries.widget.messagebox" local snippets_found, snippets = pcall(require, "plugins.snippets") ---@type lsp.helpdoc local HelpDoc = require "plugins.lsp.helpdoc" -- -- Plugin settings -- ---Configuration options for the LSP plugin. ---@class config.plugins.lsp ---Set to a file path to log all json ---@field log_file string ---Setting to true prettyfies json for more readability on the log ---but this setting will impact performance so only enable it when ---in need of easy to read json output when developing the plugin. ---@field prettify_json boolean ---Show a symbol hover information when mouse cursor is on top. ---@field mouse_hover boolean ---The amount of time in milliseconds before showing the tooltip. ---@field mouse_hover_delay integer ---Show diagnostic messages ---@field show_diagnostics boolean ---Amount of milliseconds to delay updating the inline diagnostics. ---@field diagnostics_delay number ---Wether to enable snippets processing. ---@field snippets boolean ---Stop servers that aren't needed by any of the open files ---@field stop_unneeded_servers boolean ---Send a server stderr output to lite log ---@field log_server_stderr boolean ---Force verbosity off even if a server is configured with verbosity on ---@field force_verbosity_off boolean ---Yield when reading from LSP which may give you better UI responsiveness ---when receiving large responses, but will affect LSP performance. ---@field more_yielding boolean config.plugins.lsp = common.merge({ mouse_hover = true, mouse_hover_delay = 300, show_diagnostics = true, diagnostics_delay = 500, snippets = true, stop_unneeded_servers = true, log_file = "", prettify_json = false, log_server_stderr = false, force_verbosity_off = false, more_yielding = false, autostart_server = true, -- The config specification used by the settings gui config_spec = { name = "Language Server Protocol", { label = "Mouse Hover", description = "Show a symbol hover information when mouse cursor is on top.", path = "mouse_hover", type = "TOGGLE", default = true }, { label = "Mouse Hover Delay", description = "The amount of time in milliseconds before showing the tooltip.", path = "mouse_hover_delay", type = "NUMBER", default = 300, min = 50, max = 2000 }, { label = "Diagnostics", description = "Show inline diagnostic messages with lint+.", path = "show_diagnostics", type = "TOGGLE", default = false }, { label = "Diagnostics Delay", description = "Amount of milliseconds to delay the update of inline diagnostics.", path = "diagnostics_delay", type = "NUMBER", default = 500, min = 100, max = 10000 }, { label = "Snippets", description = "Snippets processing using lsp_snippets, may need a restart.", path = "snippets", type = "TOGGLE", default = true }, { label = "Autostart Server", description = "Automatically start server when opening a file", path = "autostart_server", type = "TOGGLE", default = true }, { label = "Stop Servers", description = "Stop servers that aren't needed by any of the open files.", path = "stop_unneeded_servers", type = "TOGGLE", default = true }, { label = "Log File", description = "Absolute path to a '.log' file for logging all json.", path = "log_file", type = "FILE", filters = {"%.log$"} }, { label = "Prettify JSON", description = "Prettify json for more readability but impacts performance.", path = "prettify_json", type = "TOGGLE", default = false }, { label = "Log Standard Error", description = "Send a server stderr output to lite log.", path = "log_server_stderr", type = "TOGGLE", default = false }, { label = "Force Verbosity Off", description = "Turn verbosity off even if a server is configured with verbosity on.", path = "force_verbosity_off", type = "TOGGLE", default = false }, { label = "More Yielding", description = "Yield when reading from LSP which may give you better UI responsiveness.", path = "more_yielding", type = "TOGGLE", default = false } } }, config.plugins.lsp) -- -- Main plugin functionality -- local lsp = {} ---List of registered servers ---@type table<string, lsp.server.options> lsp.servers = {} ---List of running servers ---@type table<string, lsp.server> lsp.servers_running = {} ---Flag that indicates if last autocomplete request was a trigger ---to prevent requesting another autocompletion request until the ---autocomplete box is hidden since some lsp servers loose context ---and return wrong results (eg: lua-language-server) ---@type boolean lsp.in_trigger = false ---Flag that indicates if the user typed something on the editor to try and ---call autocomplete only when neccesary. ---@type boolean lsp.user_typed = false ---Used on the hover timer to display hover info ---@class lsp.hover_position ---@field doc core.doc | nil ---@field x number ---@field y number ---@field triggered boolean ---@field utf8_range table | nil lsp.hover_position = {doc = nil, x = -1, y = -1, triggered = false, utf8_range = nil} ---@type lsp.timer lsp.hover_timer = Timer(300, true) lsp.hover_timer.on_timer = function() local doc, line, col = lsp.get_hovered_location(lsp.hover_position.x, lsp.hover_position.y) if not doc then return end lsp.hover_position.triggered = true lsp.hover_position.utf8_range = nil lsp.hover_position.doc = doc lsp.request_hover(doc, line, col) end -- -- Private functions -- ---Generate an lsp location object ---@param doc core.doc ---@param line integer ---@param col integer local function get_buffer_position_params(doc, line, col) return { textDocument = { uri = util.touri(core.project_absolute_path(doc.filename)), }, position = { line = line - 1, character = util.doc_utf8_to_utf16(doc, line, col) - 1 } } end ---Recursive function to generate a list of symbols ready ---to use for the lsp.request_document_symbols() action. ---@param list table<integer, table> ---@param parent? string local function get_symbol_lists(list, parent) local symbols = {} local symbol_names = {} parent = parent or "" parent = #parent > 0 and (parent .. "/") or parent for _, symbol in pairs(list) do -- Include symbol kind to be able to filter by it local symbol_name = parent .. symbol.name .. "||" .. Server.get_symbol_kind(symbol.kind) table.insert(symbol_names, symbol_name) symbols[symbol_name] = { kind = symbol.kind } if symbol.location then symbols[symbol_name].location = symbol.location else if symbol.range then symbols[symbol_name].range = symbol.range end if symbol.uri then symbols[symbol_name].uri = symbol.uri end end if symbol.children and #symbol.children > 0 then local child_symbols, child_names = get_symbol_lists( symbol.children, parent .. symbol.name ) for _, name in pairs(child_names) do table.insert(symbol_names, name) symbols[name] = child_symbols[name] end end end return symbols, symbol_names end local function log(server, message, ...) if server.verbose then core.log("["..server.name.."] " .. message, ...) else core.log_quiet("["..server.name.."] " .. message, ...) end end ---Check if active view is a DocView and return it ---@return core.docview|nil local function get_active_docview() local av = core.active_view if getmetatable(av) == DocView and av.doc and av.doc.filename then return av end return nil end ---Generates a code preview of a location ---@param location table local function get_location_preview(location) local line1, col1 = util.toselection( location.range or location.targetRange ) local filename = core.normalize_to_project_dir( util.tofilename(location.uri or location.targetUri) ) local abs_filename = core.project_absolute_path(filename) local file = io.open(abs_filename) if not file then return "", filename .. ":" .. tostring(line1) .. ":" .. tostring(col1) end local preview = "" -- sometimes the lsp can send the location of a definition where the -- doc comments should be written but if no docs are written the line -- is empty and subsequent line is the one we are interested in. local line_count = 1 for line in file:lines() do if line_count >= line1 then preview = line:gsub("^%s+", "") :gsub("%s+$", "") if preview ~= "" then break else -- change also the location table if location.range then location.range.start.line = location.range.start.line + 1 location.range['end'].line = location.range['end'].line + 1 elseif location.targetRange then location.targetRange.start.line = location.targetRange.start.line + 1 location.targetRange['end'].line = location.targetRange['end'].line + 1 end end end line_count = line_count + 1 end file:close() local position = filename .. ":" .. tostring(line1) .. ":" .. tostring(col1) return preview, position end ---Generate a list ready to use for the lsp.request_references() action. ---@param locations table local function get_references_lists(locations) local references, reference_names = {}, {} for _, location in pairs(locations) do local preview, position = get_location_preview(location) local name = preview .. "||" .. position table.insert(reference_names, name) references[name] = location end return references, reference_names end ---Apply an lsp textEdit to a document if possible. ---@param server lsp.server ---@param doc core.doc ---@param text_edit table ---@param is_snippet boolean ---@param update_cursor_position boolean ---@return boolean True on success local function apply_edit(server, doc, text_edit, is_snippet, update_cursor_position) local range = nil if text_edit.range then range = text_edit.range elseif text_edit.insert then range = text_edit.insert elseif text_edit.replace then range = text_edit.replace end if not range then return false end local text = text_edit.newText local line1, col1, line2, col2 local current_text = "" if not server.capabilities.positionEncoding or server.capabilities.positionEncoding == Server.position_encoding_kind.UTF16 then line1, col1, line2, col2 = util.toselection(range, doc) else line1, col1, line2, col2 = util.toselection(range) core.error( "[LSP] Unsupported position encoding: ", server.capabilities.positionEncoding ) end if lsp.in_trigger then local cline2, ccol2 = doc:get_selection() local cline1, ccol1 = doc:position_offset(line2, col2, translate.start_of_word) current_text = doc:get_text(cline1, ccol1, cline2, ccol2) end doc:remove(line1, col1, line2, col2+#current_text) if is_snippet and snippets_found and config.plugins.lsp.snippets then doc:set_selection(line1, col1, line1, col1) snippets.execute {format = 'lsp', template = text} return true end doc:insert(line1, col1, text) if update_cursor_position then doc:move_to_cursor(nil, #text) end return true end ---Callback given to autocomplete plugin which is executed once for each ---element of the autocomplete box which is hovered with the idea of providing ---better description of the selected element by requesting the LSP server for ---detailed information/documentation. ---@param index integer ---@param item table local function autocomplete_onhover(index, item) local completion_item = item.data.completion_item if item.data.server.verbose then item.data.server:log( "Resolve item: %s", util.jsonprettify(json.encode(completion_item)) ) end -- Only send resolve request if data field (which should contain -- the item id) is available. if completion_item.data then item.data.server:push_request('completionItem/resolve', { params = completion_item, callback = function(server, response) if response.result then local symbol = response.result if symbol.detail and #item.desc <= 0 then item.desc = symbol.detail end if symbol.documentation then if #item.desc > 0 then item.desc = item.desc .. "\n\n" end if type(symbol.documentation) == "table" and symbol.documentation.value then item.desc = item.desc .. symbol.documentation.value if symbol.documentation.kind and symbol.documentation.kind == "markdown" then item.desc = util.strip_markdown(item.desc) end else item.desc = item.desc .. symbol.documentation end end item.desc = item.desc:gsub("[%s\n]+$", "") :gsub("^[%s\n]+", "") :gsub("\n\n\n+", "\n\n") if symbol.additionalTextEdits then completion_item.additionalTextEdits = symbol.additionalTextEdits end if server.verbose then server:log( "Resolve response: %s", util.jsonprettify(json.encode(symbol)) ) end elseif server.verbose then server:log("Resolve returned empty response") end end }) end end ---Callback that handles insertion of an autocompletion item that has ---the information of insertion ---@param index integer ---@param item table local function autocomplete_onselect(index, item) local completion = item.data.completion_item local dv = get_active_docview() local edit_applied = false if completion.textEdit then if dv then local is_snippet = completion.insertTextFormat and completion.insertTextFormat == Server.insert_text_format.Snippet edit_applied = apply_edit(item.data.server, dv.doc, completion.textEdit, is_snippet, true) if edit_applied then -- Retrigger code completion if last char is a trigger -- this is useful for example with clangd when autocompleting -- a #include, if user types < a list of paths will appear -- when selecting a path that ends with / as <AL/ the -- autocompletion will be retriggered to show a list of -- header files that belong to that directory. lsp.in_trigger = false local line, col = dv.doc:get_selection() local char = dv.doc:get_char(line, col-1) local char_prev = dv.doc:get_char(line, col-2) if char:match("%p") or (char == " " and char_prev:match("%p")) then if not util.table_empty(dv.doc.lsp_changes) then lsp.update_document(dv.doc, true) else lsp.request_completion(dv.doc, line, col, true) end end end end elseif dv and snippets_found and config.plugins.lsp.snippets and completion.insertText and completion.insertTextFormat and completion.insertTextFormat == Server.insert_text_format.Snippet then ---@type core.doc local doc = dv.doc if dv then local line2, col2 = doc:get_selection() local line1, col1 = doc:position_offset(line2, col2, translate.start_of_word) doc:set_selection(line1, col1, line2, col2) snippets.execute {format = 'lsp', template = completion.insertText} edit_applied = true end end if edit_applied and completion.additionalTextEdits and #completion.additionalTextEdits > 0 then -- TODO: do we need to sort this? Or is it expected to be already sorted? -- TODO: are the edit ranges considered as if the "main" textEdit was applied already? -- Apply the edits in reverse order, so that their ranges are not shifted -- around by previous edits for i=#completion.additionalTextEdits,1,-1 do local edit = completion.additionalTextEdits[i] apply_edit(item.data.server, dv.doc, edit, false, false) end end return edit_applied end -- -- Public functions -- ---Open a document location returned by LSP ---@param location table function lsp.goto_location(location) local doc_view = core.root_view:open_doc( core.open_doc( common.home_expand( util.tofilename(location.uri or location.targetUri) ) ) ) local line1, col1 = util.toselection( location.range or location.targetRange, doc_view.doc ) doc_view.doc:set_selection(line1, col1, line1, col1) end lsp.get_location_preview = get_location_preview ---Register an LSP server to be launched on demand ---@param options lsp.server.options function lsp.add_server(options) local required_fields = { "name", "language", "file_patterns", "command" } for _, field in pairs(required_fields) do if not options[field] then core.error( "[LSP] You need to provide a '%s' field for the server.", field ) return false end end if snippets_found and config.plugins.lsp.snippets then options.snippets = true end if #options.command <= 0 then core.error("[LSP] Provide a command table list with the lsp command.") return false end -- On Windows using cmd.exe allows us to take advantage of its ability to run -- the correct executable, as well as running scripts. if PLATFORM == "Windows" and not options.windows_skip_cmd then local escaped_commands = { } if type(options.command) == "string" then options.command = { options.command } end -- We need to escape `"` as `"""` for _, v in ipairs(options.command) do table.insert(escaped_commands, '"' .. string.gsub(v, '"', '"""') .. '"') end -- The result should be something like `cmd.exe /C ""first" "second" "third""` options.command = 'cmd.exe /C "' .. table.concat(escaped_commands, " ") .. '"' end if config.plugins.lsp.force_verbosity_off then options.verbose = false end lsp.servers[options.name] = options return true end ---Get valid running lsp servers for a given filename ---@param filename string ---@param initialized boolean ---@return table active_servers function lsp.get_active_servers(filename, initialized) local servers = {} for name, server in pairs(lsp.servers) do if common.match_pattern(filename, server.file_patterns) then if lsp.servers_running[name] then local add_server = true if initialized and ( not lsp.servers_running[name].initialized or not lsp.servers_running[name].capabilities ) then add_server = false end if add_server then table.insert(servers, name) end end end end return servers end -- Used on lsp.get_workspace_settings() local cached_workspace_settings = {} local cached_workspace_settings_timestamp = 0 ---Get table of configuration settings in the following way: ---1. Scan the USERDIR for .lite_lsp.lua or .lite_lsp.json (in that order) ---2. Merge server.settings ---4. Scan workspace if set also for .lite_lsp.lua/json and merge them or ---3. Scan server.path also for .lite_lsp.lua/json and merge them ---Note: settings are cached for 5 seconds for faster retrieval --- on repetitive calls to this function. ---@param server lsp.server ---@param workspace? string ---@return table function lsp.get_workspace_settings(server, workspace) -- Search settings on the following directories, subsequent settings -- overwrite the previous ones local paths = { USERDIR } local cached_index = USERDIR local settings = {} if not workspace and server.path then table.insert(paths, server.path) cached_index = cached_index .. tostring(server.path) elseif workspace then table.insert(paths, workspace) cached_index = cached_index .. tostring(workspace) end if cached_workspace_settings_timestamp > os.time() and cached_workspace_settings[cached_index] then return cached_workspace_settings[cached_index] else local position = 1 for _, path in pairs(paths) do if path then local settings_new = nil path = path:gsub("\\+$", ""):gsub("/+$", "") if util.file_exists(path .. "/.lite_lsp.lua") then local settings_lua = dofile(path .. "/.lite_lsp.lua") if type(settings_lua) == "table" then settings_new = settings_lua end elseif util.file_exists(path .. "/.lite_lsp.json") then local file = io.open(path .. "/.lite_lsp.json", "r") if file then local settings_json = file:read("*a") settings_new = json.decode(settings_json) file:close() end end -- overwrite global settings by those specified in the server if any if position == 1 and server.settings then if settings_new then settings_new = util.deep_merge(settings_new, server.settings) else settings_new = server.settings end end -- overwrite previous settings with new ones if settings_new then settings = util.deep_merge(settings, settings_new) end end position = position + 1 end -- store settings on cache for 5 seconds for fast repeated calls cached_workspace_settings[cached_index] = settings cached_workspace_settings_timestamp = os.time() + 5 end return settings end -- TODO Update workspace folders of already running lsp servers if required --- Start all applicable lsp servers for a given file. --- @param filename string --- @param project_directory string function lsp.start_server(filename, project_directory) for name, server in pairs(lsp.servers) do if common.match_pattern(filename, server.file_patterns) then if not lsp.servers_running[name] then core.log("[LSP]: Starting " .. name) ---@type boolean, lsp.server local success, client = pcall(function() return Server(server) end) if not success then core.error("[LSP]: Unable to start %s:\nCommand: %s\nError: %s", name, common.serialize(server.command), client) goto continue end client.yield_on_reads = config.plugins.lsp.more_yielding lsp.servers_running[name] = client -- We overwrite the default log function to log messages on lite function client:log(message, ...) core.log_quiet( "[LSP/%s]: " .. message .. "\n", self.name, ... ) end function client:on_shutdown() local sname = self.name core.log( "[LSP]: %s was shutdown, revise your configuration", sname ) local last_shutdown = lsp.servers_running[sname].last_shutdown or 0 lsp.servers_running = util.table_remove_key( lsp.servers_running, sname ) if system.get_time() - last_shutdown >= 5 then lsp.start_servers() if lsp.servers_running[sname] then lsp.servers_running[sname].last_shutdown = system.get_time() core.log( "[LSP]: %s automatically restarted", sname ) end end end -- Respond to workspace/configuration request client:add_request_listener( "workspace/configuration", function(server, request) local settings_default = lsp.get_workspace_settings(server) local settings_list = {} for _, item in pairs(request.params.items) do local value = nil -- No workspace was specified so we return from default settings if not item.scopeUri then value = util.table_get_field(settings_default, item.section) -- A workspace was specified so we return from that workspace else local settings_workspace = lsp.get_workspace_settings( server, util.tofilename(item.scopeUri) ) value = util.table_get_field(settings_workspace, item.section) end if not value then server:log("Asking for '%s' config but not set", item.section) else server:log("Asking for '%s' config", item.section) end table.insert(settings_list, value or json.null) end server:push_response(request.method, request.id, settings_list) end ) -- Respond to window/showDocument request client:add_request_listener( "window/showDocument", function(server, request) if request.params.external then MessageBox.info( server.name .. " LSP Server", "Wants to externally open:\n'" .. request.params.uri .. "'", function(_, button_id) if button_id == 1 then util.open_external(request.params.uri) end end, MessageBox.BUTTONS_YES_NO ) else local document = util.tofilename(request.params.uri) ---@type core.docview local doc_view = core.root_view:open_doc( core.open_doc(common.home_expand(document)) ) if request.params.selection then local line1, col1, line2, col2 = util.toselection( request.params.selection, doc_view.doc ) doc_view.doc:set_selection(line1, col1, line2, col2) end if request.params.takeFocus then system.raise_window() end end server:push_response(request.method, request.id, {success=true}) end ) -- Display server messages on lite UI client:add_message_listener( "window/logMessage", function(server, params) if core.log then log(server, "%s", params.message) end end ) -- Register/unregister diagnostic messages client:add_message_listener( "textDocument/publishDiagnostics", function(server, params) local abs_filename = util.tofilename(params.uri) local filename = core.normalize_to_project_dir(abs_filename) if server.verbose then core.log_quiet( "["..server.name.."] %s diagnostics for: %s", filename, params.diagnostics and #params.diagnostics or 0 ) end if params.diagnostics and #params.diagnostics > 0 then local added = diagnostics.add(filename, params.diagnostics) if added and diagnostics.lintplus_found and config.plugins.lsp.show_diagnostics and util.doc_is_open(abs_filename) then -- we delay rendering of diagnostics for 2 seconds to prevent -- the constant reporting of errors while typing. diagnostics.lintplus_populate_delayed(filename) end else diagnostics.clear(filename) diagnostics.lintplus_clear_messages(filename) end end ) -- Register/unregister diagnostic messages client:add_message_listener( "window/showMessage", function(server, params) local log_func = "log_quiet" if params.type == Server.message_type.Error then log_func = "error" elseif params.type == Server.message_type.Warning then log_func = "warn" elseif params.type == Server.message_type.Info then log_func = "log" elseif params.type == Server.message_type.Debug then log_func = "log_quiet" end core[log_func]("["..server.name.."] message: %s", params.message) end ) -- Send settings table after initialization if available. client:add_event_listener("initialized", function(server) if config.plugins.lsp.force_verbosity_off then core.log_quiet("["..server.name.."] " .. "Initialized") else log(server, "Initialized") end local settings = lsp.get_workspace_settings(server) if not util.table_empty(settings) then server:push_notification("workspace/didChangeConfiguration", { params = {settings = settings} }) end -- Send open document request if needed for _, docu in ipairs(core.docs) do if docu.filename then if common.match_pattern(docu.filename, server.file_patterns) then lsp.open_document(docu) end end end end) -- Start the server initialization process client:initialize(project_directory, "Lite XL", VERSION) end end ::continue:: end end ---Stops all running servers. function lsp.stop_servers() for name, _ in pairs(lsp.servers) do if lsp.servers_running[name] then lsp.servers_running[name]:exit() core.log("[LSP] stopped %s", name) lsp.servers_running = util.table_remove_key(lsp.servers_running, name) end end end ---Start only the needed servers by current opened documents. function lsp.start_servers() for _, doc in ipairs(core.docs) do if doc.filename then lsp.start_server(doc.filename, core.project_dir) end end end ---Returns the hovered doc and the hovered position. ---Returns nil if no doc with an LSP activated is under the provided coordinates. ---@param x number ---@param y number ---@return core.doc|nil doc ---@return integer|nil line ---@return integer|nil col function lsp.get_hovered_location(x, y) local n = core.root_view.root_node:get_child_overlapping_point(x, y) if not n then return end local av = n.active_view if not av:extends(DocView) then return end if av and av.doc.lsp_open then ---@type core.doc local doc = av.doc local line, col = av:resolve_screen_position(x, y) local last_x = av:get_col_x_offset(line, #av.doc.lines[line]) local lx, ly = av:get_line_screen_position(line) if x > last_x + lx or y > ly + av:get_line_height() then return end return doc, line, col end end ---Send notification to applicable LSP servers that a document was opened ---@param doc core.doc function lsp.open_document(doc) -- in some rare ocassions this function may return nil when the -- user closed lite-xl with files opened, removed the files from system -- and opens lite-xl again which loads the non existent files. local doc_path = core.project_absolute_path(doc.filename) local file_info = system.get_file_info(doc_path) if not file_info then core.error("[LSP] could not open: %s", tostring(doc.filename)) return end local active_servers = lsp.get_active_servers(doc.filename, true) if #active_servers > 0 then doc.disable_symbols = true -- disable symbol parsing on autocomplete plugin for _, name in pairs(active_servers) do local server = lsp.servers_running[name] if server.capabilities.textDocumentSync.openClose then if server.exit_timer then server.exit_timer:stop() server.exit_timer = nil end if file_info.size / 1024 <= 50 then -- file size is in range so push the notification as usual. server:push_notification('textDocument/didOpen', { params = { textDocument = { uri = util.touri(doc_path), languageId = server:get_language_id(doc), version = doc.clean_change_id, text = table.concat(doc.lines) } }, callback = function() doc.lsp_open = true end }) else -- big files too slow for json encoder, also sending a huge file -- without yielding would stall the ui, and some lsp servers have -- issues with receiving big files in a single chunk. local text = table.concat(doc.lines) :gsub('\\', '\\\\'):gsub("\n", "\\n"):gsub("\r", "\\r") :gsub("\t", "\\t"):gsub('"', '\\"'):gsub('\b', '\\b') :gsub('\f', '\\f') server:push_raw("textDocument/didOpen", { raw_data = '{\n' .. '"jsonrpc": "2.0",\n' .. '"method": "textDocument/didOpen",\n' .. '"params": {\n' .. '"textDocument": {\n' .. '"uri": "'..util.touri(doc_path)..'",\n' .. '"languageId": "'..server:get_language_id(doc)..'",\n' .. '"version": '..doc.clean_change_id..',\n' .. '"text": "'..text..'"\n' .. '}\n' .. '}\n' .. '}\n', callback = function(server) doc.lsp_open = true log(server, "Big file '%s' ready for completion!", doc.filename) end }) log(server, "Processing big file '%s'...", doc.filename) end else doc.lsp_open = true end end end end --- Send notification to applicable LSP servers that a document was saved ---@param doc core.doc function lsp.save_document(doc) if not doc.lsp_open then return end local active_servers = lsp.get_active_servers(doc.filename, true) if #active_servers > 0 then for _, name in pairs(active_servers) do local server = lsp.servers_running[name] local save = server.capabilities.textDocumentSync.save if save then -- Send document content only if required by lsp server if save.includeText then -- If save should include file content then raw is faster for -- huge files that would take too much to encode. local text = table.concat(doc.lines) :gsub('\\', '\\\\'):gsub("\n", "\\n"):gsub("\r", "\\r") :gsub("\t", "\\t"):gsub('"', '\\"'):gsub('\b', '\\b') :gsub('\f', '\\f') server:push_raw("textDocument/didSave", { raw_data = '{\n' .. '"jsonrpc": "2.0",\n' .. '"method": "textDocument/didSave",\n' .. '"params": {\n' .. '"textDocument": {\n' .. '"uri": "'..util.touri(core.project_absolute_path(doc.filename))..'"\n' .. '},\n' .. '"text": "'..text..'"\n' .. '}\n' .. '}\n' }) else server:push_notification('textDocument/didSave', { params = { textDocument = { uri = util.touri(core.project_absolute_path(doc.filename)) } } }) end end end end end --- Send notification to applicable LSP servers that a document was closed ---@param doc core.doc function lsp.close_document(doc) if not doc.lsp_open then return end local active_servers = lsp.get_active_servers(doc.filename, true) if #active_servers > 0 then for _, name in pairs(active_servers) do local server = lsp.servers_running[name] if server.capabilities.textDocumentSync.openClose then server:push_notification('textDocument/didClose', { params = { textDocument = { uri = util.touri(core.project_absolute_path(doc.filename)), languageId = server:get_language_id(doc), version = doc.clean_change_id } } }) end end end end --- Helper for lsp.update_document ---@param doc core.doc local function request_signature_completion(doc) local line1, col1, line2, col2 = doc:get_selection() if line1 == line2 and col1 == col2 then -- First try to display a function signatures and if not possible -- do normal code autocomplete lsp.request_signature( doc, line1, col1, false, lsp.request_completion ) end end ---Send document updates to applicable running LSP servers. ---@param doc core.doc ---@param request_completion? boolean function lsp.update_document(doc, request_completion) if not doc.lsp_open or not doc.lsp_changes or util.table_empty(doc.lsp_changes) then return end for _, name in pairs(lsp.get_active_servers(doc.filename, true)) do local server = lsp.servers_running[name] if not doc.lsp_changes[server] or #doc.lsp_changes[server] <= 0 then goto continue end local sync_kind = server.capabilities.textDocumentSync.change if sync_kind ~= Server.text_document_sync_kind.None and server:can_push() -- ensure we don't loose incremental changes then local completion_callback = nil if request_completion then completion_callback = function() request_signature_completion(doc) end end if sync_kind == Server.text_document_sync_kind.Full and not server.incremental_changes then -- If sync should be done by sending full file content then lets do -- it raw which is faster for big files. local text = table.concat(doc.lines) :gsub('\\', '\\\\'):gsub("\n", "\\n"):gsub("\r", "\\r") :gsub("\t", "\\t"):gsub('"', '\\"'):gsub('\b', '\\b') :gsub('\f', '\\f') server:push_raw("textDocument/didChange", { overwrite = true, raw_data = '{\n' .. '"jsonrpc": "2.0",\n' .. '"method": "textDocument/didChange",\n' .. '"params": {\n' .. '"textDocument": {\n' .. '"uri": "'..util.touri(core.project_absolute_path(doc.filename))..'",\n' .. '"version": '..doc.lsp_version .. "\n" .. '},\n' .. '"contentChanges": [\n' .. '{"text": "'..text..'"}\n' .. "]\n" .. '}\n' .. '}\n', callback = function() doc.lsp_changes[server] = nil if completion_callback then completion_callback() end end }) else lsp.servers_running[name]:push_notification('textDocument/didChange', { overwrite = true, params = { textDocument = { uri = util.touri(core.project_absolute_path(doc.filename)), version = doc.lsp_version, }, contentChanges = doc.lsp_changes[server] }, callback = function() doc.lsp_changes[server] = nil if completion_callback then completion_callback() end end }) end end ::continue:: end end --- Enable or disable diagnostic messages function lsp.toggle_diagnostics() config.plugins.lsp.show_diagnostics = not config.plugins.lsp.show_diagnostics if not config.plugins.lsp.show_diagnostics then diagnostics.lintplus_clear_messages() core.log("[LSP] Diagnostics disabled") else diagnostics.lintplus_populate() core.log("[LSP] Diagnostics enabled") end end --- Send to applicable LSP servers a request for code completion function lsp.request_completion(doc, line, col, forced) if lsp.in_trigger or not doc.lsp_open then return end for _, name in pairs(lsp.get_active_servers(doc.filename, true)) do local server = lsp.servers_running[name] if server.capabilities.completionProvider then local capabilities = lsp.servers_running[name].capabilities local char = doc:get_char(line, col-1) local trigger_char = false local request = get_buffer_position_params(doc, line, col) -- without providing context some language servers like the -- lua-language-server behave poorly and return garbage. if capabilities.completionProvider.triggerCharacters and #capabilities.completionProvider.triggerCharacters > 0 and char:match("%p") and util.intable(char, capabilities.completionProvider.triggerCharacters) then request.context = { triggerKind = Server.completion_trigger_Kind.TriggerCharacter, triggerCharacter = char } trigger_char = true; end if not trigger_char and not autocomplete.can_complete() and not forced then return false end server:push_request('textDocument/completion', { params = request, overwrite = true, callback = function(server, response) lsp.user_typed = false -- don't autocomplete if caret position changed local cline, cchar = doc:get_selection() if cline ~= line or cchar ~= col then return end if server.verbose then server:log( "Completion response received." ) end if not response.result then return end local result = response.result local complete_result = true if result.isIncomplete then if server.verbose then core.log_quiet( "["..server.name.."] " .. "Completion list incomplete" ) end complete_result = false end if not result.items or #result.items <= 0 then -- Workaround for some lsp servers that don't return results -- in the items property but instead on the results it self if #result > 0 then local items = result result = {items = items} else return end end local symbols = { name = lsp.servers_running[name].name, files = lsp.servers_running[name].file_patterns, items = {} } local symbol_count = 1 for _, symbol in ipairs(result.items) do local label = symbol.label or ( symbol.textEdit and symbol.textEdit.newText or symbol.insertText ) local info = server.get_completion_item_kind(symbol.kind) local desc = symbol.detail or "" -- TODO: maybe we should give priority to insertText above if symbol.label and symbol.insertText and #symbol.label > #symbol.insertText then label = symbol.insertText if symbol.label ~= label then desc = symbol.label end if symbol.detail then desc = desc .. ": " .. symbol.detail end end if desc ~= "" then desc = desc .. "\n" end if type(symbol.documentation) == "table" and symbol.documentation.value then desc = desc .. "\n" .. symbol.documentation.value if symbol.documentation.kind and symbol.documentation.kind == "markdown" then desc = util.strip_markdown(desc) if symbol_count % 10 == 0 then coroutine.yield() end end elseif symbol.documentation then desc = desc .. "\n" .. symbol.documentation end desc = desc:gsub("[%s\n]+$", "") :gsub("\n\n\n+", "\n\n") symbols.items[label] = { info = info, desc = desc, data = { server = server, completion_item = symbol }, onselect = autocomplete_onselect } if server.capabilities.completionProvider.resolveProvider and not symbol.documentation then symbols.items[label].onhover = autocomplete_onhover end symbol_count = symbol_count + 1 end if trigger_char and complete_result then lsp.in_trigger = true autocomplete.complete(symbols, function() lsp.in_trigger = false end) else autocomplete.complete(symbols) end end }) end end end --- Send to applicable LSP servers a request for info about a function --- signatures and display them on a tooltip. function lsp.request_signature(doc, line, col, forced, fallback) if not doc.lsp_open then return end local char = doc:get_char(line, col-1) local prev_char = doc:get_char(line, col-2) -- to support ', ' for _, name in pairs(lsp.get_active_servers(doc.filename, true)) do local server = lsp.servers_running[name] if server.capabilities.signatureHelpProvider and ( forced or ( server.capabilities.signatureHelpProvider.triggerCharacters and #server.capabilities.signatureHelpProvider.triggerCharacters > 0 and ( util.intable( char, server.capabilities.signatureHelpProvider.triggerCharacters ) or util.intable( prev_char, server.capabilities.signatureHelpProvider.triggerCharacters ) ) ) ) then server:push_request('textDocument/signatureHelp', { params = get_buffer_position_params(doc, line, col), overwrite = true, callback = function(server, response) -- don't show signature if caret position changed local cline, cchar = doc:get_selection() if cline ~= line or cchar ~= col then return end if response.result and response.result.signatures and #response.result.signatures > 0 then autocomplete.close() listbox.show_signatures(response.result) lsp.user_typed = false elseif fallback then fallback(doc, line, col) end end }) break elseif fallback then fallback(doc, line, col) end end end ---Returns the "selection" for the token that includes the provided position. ---@param doc core.doc ---@param line integer ---@param col integer ---@return integer line1 ---@return integer col2 ---@return integer line2 ---@return integer col2 local function get_token_range(doc, line, col) local col1 = 0 for _, _, text in doc.highlighter:each_token(line) do local text_len = #text local col2 = col1 + text_len if col2 >= col then return line, col1 + 1, line, col2 + 1 end col1 = col2 end return line, col, line, col+1 end ---@type core.node local help_active_node = nil ---@type core.node local help_bottom_node = nil --- Sends a request to applicable LSP servers for information about the --- symbol where the cursor is placed and shows it on a tooltip. function lsp.request_hover(doc, line, col, in_tab) if not doc.lsp_open then return end for _, name in pairs(lsp.get_active_servers(doc.filename, true)) do local server = lsp.servers_running[name] if server.capabilities.hoverProvider then server:push_request('textDocument/hover', { params = get_buffer_position_params(doc, line, col), callback = function(server, response) if response.result and response.result.contents then local range = response.result.range local line1, col1, line2, col2 if range then line1, col1, line2, col2 = util.toselection(range, doc) else line1, col1, line2, col2 = get_token_range(doc, line, col) end lsp.hover_position.utf8_range = { line1 = line1, col1 = col1, line2 = line2, col2 = col2 } local content = response.result.contents local kind = nil local text = "" if type(content) == "table" then if content.value then text = content.value if content.kind then kind = content.kind end else for _, element in pairs(content) do if type(element) == "string" then text = text .. element elseif type(element) == "table" and element.value then text = text .. element.value if not kind and element.kind then kind = element.kind end end end end else -- content should be a string text = content end if text and #text > 0 then text = text:gsub("^[\n%s]+", ""):gsub("[\n%s]+$", "") if not in_tab then if kind == "markdown" then text = util.strip_markdown(text) end listbox.show_text( text, { line = line, col = col } ) else local line1, col1 = translate.start_of_word(doc, line, col) local line2, col2 = translate.end_of_word(doc, line1, col1) local title = doc:get_text(line1, col1, line2, col2):gsub("%s*", "") title = "Help:" .. title .. ".md" ---@type lsp.helpdoc local helpdoc = HelpDoc(title, title) helpdoc:set_text(text) local helpview = DocView(helpdoc) helpview.context = "application" helpview.wrapping_enabled = true if LineWrapping then LineWrapping.update_docview_breaks(helpview) end if not help_bottom_node or ( #help_bottom_node.views == 1 and not help_active_node:get_node_for_view(help_bottom_node.views[1]) ) then help_active_node = core.root_view:get_active_node_default() help_bottom_node = help_active_node:split("down", helpview) else help_bottom_node:add_view(helpview) end end end end end }) break end end end --- Sends a request to applicable LSP servers for a symbol references function lsp.request_references(doc, line, col) if not doc.lsp_open then return end for _, name in pairs(lsp.get_active_servers(doc.filename, true)) do local server = lsp.servers_running[name] if server.capabilities.hoverProvider then local request_params = get_buffer_position_params(doc, line, col) request_params.context = {includeDeclaration = true} server:push_request('textDocument/references', { params = request_params, callback = function(server, response) if response.result and #response.result > 0 then local references, reference_names = get_references_lists(response.result) core.command_view:enter("Filter References", { submit = function(text, item) if item then local reference = references[item.name] lsp.goto_location(reference) end end, suggest = function(text) local res = common.fuzzy_match(reference_names, text) for i, name in ipairs(res) do local reference_info = util.split(name, "||") res[i] = { text = reference_info[1], info = reference_info[2], name = name } end return res end }) else core.log("[LSP] No references found.") end end }) break end break end end ---Sends a request to applicable LSP servers to retrieve the ---hierarchy of calls for the given function under the cursor. function lsp.request_call_hierarchy(doc, line, col) if not doc.lsp_open then return end for _, name in pairs(lsp.get_active_servers(doc.filename, true)) do local server = lsp.servers_running[name] if server.capabilities.callHierarchyProvider then server:push_request('textDocument/prepareCallHierarchy', { params = get_buffer_position_params(doc, line, col), callback = function(server, response) if response.result and #response.result > 0 then -- TODO: Finish implement call hierarchy functionality return end end }) return end end core.log("[LSP] Call hierarchy not supported.") end ---Sends a request to applicable LSP servers to rename a symbol. ---@param doc core.doc ---@param line integer ---@param col integer ---@param new_name string function lsp.request_symbol_rename(doc, line, col, new_name) if not doc.lsp_open then return end local servers_found = false for _, name in pairs(lsp.get_active_servers(doc.filename, true)) do servers_found = true local server = lsp.servers_running[name] if server.capabilities.renameProvider then local request_params = get_buffer_position_params(doc, line, col) request_params.newName = new_name server:push_request('textDocument/rename', { params = request_params, callback = function(server, response) if response.result and #response.result.changes then for file_uri, changes in pairs(response.result.changes) do core.log(file_uri .. " " .. #changes) -- TODO: Finish implement textDocument/rename end end core.log("%s", json.prettify(json.encode(response))) end }) return end end if not servers_found then core.log("[LSP] " .. "No server ready or running") else core.log("[LSP] " .. "Symbols rename not supported") end end ---Sends a request to applicable LSP servers to search for symbol on workspace. ---@param doc core.doc ---@param symbol string function lsp.request_workspace_symbol(doc, symbol) if not doc.lsp_open then return end for _, name in pairs(lsp.get_active_servers(doc.filename, true)) do local server = lsp.servers_running[name] if server.capabilities.workspaceSymbolProvider then local rs = SymbolResults(symbol) core.root_view:get_active_node_default():add_view(rs) server:push_request('workspace/symbol', { params = { query = symbol, -- TODO: implement status notifications but seems not supported -- by tested lsp servers so far. -- workDoneToken = "some-identifier", -- partialResultToken = "some-other-identifier" }, callback = function(server, response) if response.result and #response.result > 0 then for index, result in ipairs(response.result) do rs:add_result(result) if index % 100 == 0 then coroutine.yield() rs.list:resize_to_parent() end end rs.list:resize_to_parent() end rs:stop_searching() end }) break end break end end --- Request a list of symbols for the given document for easy document -- navigation and displays them using core.command_view:enter() function lsp.request_document_symbols(doc) if not doc.lsp_open then return end local servers_found = false local symbols_retrieved = false for _, name in pairs(lsp.get_active_servers(doc.filename, true)) do servers_found = true local server = lsp.servers_running[name] if server.capabilities.documentSymbolProvider then log(server, "Retrieving document symbols...") server:push_request('textDocument/documentSymbol', { params = { textDocument = { uri = util.touri(core.project_absolute_path(doc.filename)), } }, callback = function(server, response) if response.result and response.result and #response.result > 0 then local symbols, symbol_names = get_symbol_lists(response.result) core.command_view:enter("Find Symbol", { submit = function(text, item) if item then local symbol = symbols[item.name] -- The lsp may return a location object with range -- and uri inside of it or just range as part of -- the symbol it self. symbol = symbol.location and symbol.location or symbol if not symbol.uri then local line1, col1 = util.toselection(symbol.range, doc) doc:set_selection(line1, col1, line1, col1) else lsp.goto_location(symbol) end end end, suggest = function(text) local res = common.fuzzy_match(symbol_names, text) for i, name in ipairs(res) do res[i] = { text = util.split(name, "||")[1], info = Server.get_symbol_kind(symbols[name].kind), name = name } end return res end }) end end }) symbols_retrieved = true break end end if not servers_found then core.log("[LSP] " .. "No server running") elseif not symbols_retrieved then core.log("[LSP] " .. "Document symbols not supported") end end --- Format current document if supported by one of the running lsp servers. function lsp.request_document_format(doc) if not doc.lsp_open then return end local servers_found = false local format_executed = false for _, name in pairs(lsp.get_active_servers(doc.filename, true)) do servers_found = true local server = lsp.servers_running[name] if server.capabilities.documentFormattingProvider then local trim_trailing_whitespace = false local trim_newlines = false if type(config.plugins.trimwhitespace) == "table" and config.plugins.trimwhitespace.enabled then trim_trailing_whitespace = true trim_newlines = config.plugins.trimwhitespace.trim_empty_end_lines elseif config.plugins.trimwhitespace then -- Plugin enabled with true trim_trailing_whitespace = true trim_newlines = true end local indent_type, indent_size, indent_confirmed = doc:get_indent_info() if not indent_confirmed then indent_type, indent_size = config.tab_type, config.indent_size end server:push_request('textDocument/formatting', { params = { textDocument = { uri = util.touri(core.project_absolute_path(doc.filename)), }, options = { tabSize = indent_size, insertSpaces = indent_type == "soft", trimTrailingWhitespace = trim_trailing_whitespace, insertFinalNewline = false, trimFinalNewlines = trim_newlines } }, callback = function(server, response) if response.error and response.error.message then log(server, "Error formatting: " .. response.error.message) elseif response.result and #response.result > 0 then -- Apply edits in reverse, as the ranges don't consider -- the intermediate states. -- Consider the TextEdits as already sorted. -- If there are servers that don't sort their TextEdits, -- we'll add sorting code. for i=#response.result,1,-1 do apply_edit(server, doc, response.result[i], false, false) end log(server, "Formatted document") else log(server, "Formatting not required") end end }) format_executed = true break end end if not servers_found then core.log("[LSP] " .. "No server running") elseif not format_executed then core.log("[LSP] " .. "Formatting not supported") end end function lsp.view_document_diagnostics(doc) local diagnostic_messages = diagnostics.get(core.project_absolute_path(doc.filename)) if not diagnostic_messages or #diagnostic_messages <= 0 then core.log("[LSP] %s", "No diagnostic messages found.") return end local diagnostic_labels = { "Error", "Warning", "Info", "Hint" } local indexes, captions = {}, {} for index, diagnostic in pairs(diagnostic_messages) do local line1, col1 = util.toselection(diagnostic.range) local label = diagnostic_labels[diagnostic.severity] .. ": " .. diagnostic.message .. " " .. tostring(line1) .. ":" .. tostring(col1) captions[index] = label indexes[label] = index end core.command_view:enter("Filter Diagnostics", { submit = function(text, item) if item then local diagnostic = diagnostic_messages[item.index] local line1, col1 = util.toselection(diagnostic.range, doc) doc:set_selection(line1, col1, line1, col1) end end, suggest = function(text) local res = common.fuzzy_match(captions, text) for i, name in ipairs(res) do local diagnostic = diagnostic_messages[indexes[name]] local line1, col1 = util.toselection(diagnostic.range) res[i] = { text = diagnostics.lintplus_kinds[diagnostic.severity] .. ": " .. diagnostic.message, info = tostring(line1) .. ":" .. tostring(col1), index = indexes[name] } end return res end }) end function lsp.view_all_diagnostics() if diagnostics.count <= 0 then core.log("[LSP] %s", "No diagnostic messages found.") return end local captions = {} for _, diagnostic in ipairs(diagnostics.list) do table.insert( captions, core.normalize_to_project_dir(diagnostic.filename) ) end core.command_view:enter("Filter Files", { submit = function(text, item) if item then core.root_view:open_doc( core.open_doc( common.home_expand( text ) ) ) end end, suggest = function(text) local res = common.fuzzy_match(captions, text, true) for i, name in ipairs(res) do local diagnostics_count = diagnostics.get_messages_count( core.project_absolute_path(name) ) res[i] = { text = name, info = "Messages: " .. diagnostics_count } end return res end }) end --- Jumps to the definition or implementation of the symbol where the cursor -- is placed if the LSP server supports it function lsp.goto_symbol(doc, line, col, implementation) if not doc.lsp_open then return end for _, name in pairs(lsp.get_active_servers(doc.filename, true)) do local server = lsp.servers_running[name] local method = "" if not implementation then if server.capabilities.definitionProvider then method = method .. "definition" elseif server.capabilities.declarationProvider then method = method .. "declaration" elseif server.capabilities.typeDefinitionProvider then method = method .. "typeDefinition" else log(server, "Goto definition not supported") return end else if server.capabilities.implementationProvider then method = method .. "implementation" else log(server, "Goto implementation not supported") return end end -- Send document updates first lsp.update_document(doc) server:push_request("textDocument/" .. method, { params = get_buffer_position_params(doc, line, col), callback = function(server, response) local location = response.result if not location or not location.uri and #location == 0 then core.log("[LSP] No %s found.", method) return end if not location.uri and #location > 1 then listbox.clear() for _, loc in pairs(location) do local preview, position = get_location_preview(loc) listbox.append { text = preview, info = position, location = loc } end listbox.show_list(nil, function(doc, item) lsp.goto_location(item.location) end) else if not location.uri then location = location[1] end lsp.goto_location(location) end end }) end end -- -- Thread to process server requests and responses -- without blocking entirely the editor. -- core.add_thread(function() while true do local servers_running = false for _,server in pairs(lsp.servers_running) do -- Send raw data to server which is usually big and slow in a -- non blocking way by creating a coroutine just for it. if #server.raw_list > 0 then local raw_send = coroutine.create(function() server:process_raw() end) coroutine.resume(raw_send) while coroutine.status(raw_send) ~= "dead" do -- while sending raw request we only read from lsp to not -- conflict with the written raw data so remember no calls -- here to: server:process_client_responses() -- or server:process_notifications() server:process_errors(config.plugins.lsp.log_server_stderr) server:process_responses() coroutine.yield() coroutine.resume(raw_send) end end if not config.plugins.lsp.more_yielding then server:process_notifications() server:process_requests() server:process_responses() server:process_client_responses() else server:process_notifications() coroutine.yield() server:process_requests() coroutine.yield() server:process_responses() server:process_client_responses() coroutine.yield() end server:process_errors(config.plugins.lsp.log_server_stderr) servers_running = true end if servers_running then local wait = 0.01 if config.plugins.lsp.more_yielding then wait = 0 end coroutine.yield(wait) else coroutine.yield(2) end end end) -- -- Events patching -- local doc_load = Doc.load local doc_save = Doc.save local doc_on_close = Doc.on_close local doc_raw_insert = Doc.raw_insert local doc_raw_remove = Doc.raw_remove local root_view_on_text_input = RootView.on_text_input local root_view_on_mouse_moved = RootView.on_mouse_moved function Doc:load(...) local res = doc_load(self, ...) -- skip new files if self.filename and config.plugins.lsp.autostart_server then diagnostics.lintplus_init_doc(self) core.add_thread(function() lsp.start_server(self.filename, core.project_dir) lsp.open_document(self) end) end return res end function Doc:save(...) local old_filename = self.filename local res = doc_save(self, ...) if old_filename ~= self.filename then -- seems to be a new document so we send open notification diagnostics.lintplus_init_doc(self) core.add_thread(function() lsp.open_document(self) end) else core.add_thread(function() lsp.update_document(self) lsp.save_document(self) end) end return res end function Doc:on_close() doc_on_close(self) -- skip new files if not self.filename then return end core.add_thread(function() lsp.close_document(self) end) if not config.plugins.lsp.stop_unneeded_servers then return end -- Check if any running lsp servers is not needed anymore and stop it for name, server in pairs(lsp.servers_running) do local doc_found = false for _, docu in ipairs(core.docs) do if docu.filename then if common.match_pattern(docu.filename, server.file_patterns) then doc_found = true break end end end if not doc_found and not server.exit_timer then local t = Timer(server.quit_timeout * 1000, true) t.on_timer = function() server:exit() core.log("[LSP] stopped %s", name) lsp.servers_running = util.table_remove_key(lsp.servers_running, name) end t:start() server.exit_timer = t end end end local function add_change(self, text, line1, col1, line2, col2) if not self.lsp_changes then self.lsp_changes = {} self.lsp_version = 0 end local change = { range = {}, text = text} change.range["start"] = {line = line1-1, character = col1-1} change.range["end"] = {line = line2-1, character = col2-1} for _, name in pairs(lsp.get_active_servers(self.filename, true)) do local server = lsp.servers_running[name] if not self.lsp_changes[server] then self.lsp_changes[server] = {} end table.insert(self.lsp_changes[server], change) end -- TODO: this should not be needed but changing documents rapidly causes this if type(self.lsp_version) ~= 'nil' then self.lsp_version = self.lsp_version + 1 else self.lsp_version = 1 end end function Doc:raw_insert(line, col, text, undo_stack, time) doc_raw_insert(self, line, col, text, undo_stack, time) -- skip new files if not self.filename then return end col = util.doc_utf8_to_utf16(self, line, col) if self.lsp_open then add_change(self, text, line, col, line, col) lsp.update_document(self) elseif #lsp.get_active_servers(self.filename, true) > 0 then add_change(self, text, line, col, line, col) end end function Doc:raw_remove(line1, col1, line2, col2, undo_stack, time) local lcol1 = util.doc_utf8_to_utf16(self, line1, col1) local lcol2 = util.doc_utf8_to_utf16(self, line2, col2) doc_raw_remove(self, line1, col1, line2, col2, undo_stack, time) -- skip new files if not self.filename then return end if self.lsp_open then add_change(self, "", line1, lcol1, line2, lcol2) lsp.update_document(self) elseif #lsp.get_active_servers(self.filename, true) > 0 then add_change(self, "", line1, lcol1, line2, lcol2) end end function RootView:on_text_input(text) root_view_on_text_input(self, text) -- this part should actually trigger after Doc:raw_insert and Doc:raw_remove -- so it is safe to trigger autocompletion from here. local av = get_active_docview() if av then lsp.user_typed = true lsp.update_document(av.doc, true) end end function RootView:on_mouse_moved(x, y, dx, dy) root_view_on_mouse_moved(self, x, y, dx, dy) if not config.plugins.lsp.mouse_hover then return end lsp.hover_position.x = x lsp.hover_position.y = y if lsp.hover_position.triggered then local doc, line, col = lsp.get_hovered_location(x, y) if doc == lsp.hover_position.doc and lsp.hover_position.utf8_range then local utf8_range = lsp.hover_position.utf8_range local line1, col1, line2, col2 = utf8_range.line1, utf8_range.col1, utf8_range.line2, utf8_range.col2 if (line > line1 or (line == line1 and col >= col1)) and (line < line2 or (line == line2 and col <= col2)) then return end end listbox.hide() lsp.hover_position.triggered = false end lsp.hover_timer:set_interval(config.plugins.lsp.mouse_hover_delay) lsp.hover_timer:restart() end -- -- Add status view item to show document diagnostics count -- core.status_view:add_item({ predicate = function() local dv = get_active_docview() if dv then local filename = core.project_absolute_path(dv.doc.filename) local diagnostic_messages = diagnostics.get(filename) if diagnostic_messages and #diagnostic_messages > 0 then return true end end return false end, name = "lsp:diagnostics", alignment = StatusView.Item.RIGHT, get_item = function() local dv = get_active_docview() if dv then local filename = core.project_absolute_path(dv.doc.filename) local diagnostic_messages = diagnostics.get(filename) if diagnostic_messages and #diagnostic_messages > 0 then return { style.warn, style.icon_font, "!", style.font, " " .. tostring(#diagnostic_messages) } end end return {} end, command = "lsp:view-document-diagnostics", position = 1, tooltip = "LSP Diagnostics", separator = core.status_view.separator2 }) -- -- Register autocomplete icons -- if autocomplete.add_icon then local autocomplete_icons = { { name = "Text", color = "keyword", icon = '' }, -- U+F77E { name = "Method", color = "function", icon = '' }, -- U+F6A6 { name = "Function", color = "function", icon = '' }, -- U+F794 { name = "Constructor", color = "literal", icon = '' }, -- U+F423 { name = "Field", color = "keyword2", icon = 'ﰠ' }, -- U+FC20 { name = "Variable", color = "keyword2", icon = '' }, -- U+F52A { name = "Class", color = "literal", icon = 'ﴯ' }, -- U+FD2F { name = "Interface", color = "literal", icon = '' }, -- U+F0E8 { name = "Module", color = "literal", icon = '' }, -- U+F487 { name = "Property", color = "keyword2", icon = 'ﰠ' }, -- U+FC20 { name = "Unit", color = "number", icon = '塞' }, -- U+F96C { name = "Value", color = "string", icon = '' }, -- U+F89F { name = "Enum", color = "keyword2", icon = '' }, -- U+F15D { name = "Keyword", color = "keyword", icon = '' }, -- U+F80A { name = "Snippet", color = "keyword", icon = '' }, -- U+F44F { name = "Color", color = "string", icon = '' }, -- U+F8D7 { name = "File", color = "string", icon = '' }, -- U+F718 { name = "Reference", color = "string", icon = '' }, -- U+F706 { name = "Folder", color = "string", icon = '' }, -- U+F74A { name = "EnumMember", color = "number", icon = '' }, -- U+F15D { name = "Constant", color = "number", icon = '' }, -- U+F8FE { name = "Struct", color = "keyword2", icon = 'פּ' }, -- U+FB44 { name = "Event", color = "keyword", icon = '' }, -- U+F0E7 { name = "Operator", color = "operator", icon = '' }, -- U+F694 { name = "Unknown", color = "keyword", icon = '' }, -- U+F128 { name = "TypeParameter", color = "literal", icon = '' } -- U+EA92 } -- We add the font here to let it automatically scale by the scale plugin style.syntax_fonts["lsp_symbols"] = renderer.font.load( USERDIR .. "/plugins/lsp/fonts/symbols.ttf", 15 * SCALE ) for _, icon in ipairs(autocomplete_icons) do autocomplete.add_icon( icon.name, icon.icon, style.syntax_fonts["lsp_symbols"], icon.color ) end end -- -- Commands -- command.add( function() local dv = get_active_docview() return dv ~= nil and dv.doc.lsp_open, dv and dv.doc or nil end, { ["lsp:complete"] = function(doc) local line1, col1, line2, col2 = doc:get_selection() if line1 == line2 and col1 == col2 then lsp.request_completion(doc, line1, col1, true) end end, ["lsp:goto-definition"] = function(doc) local line1, col1, line2 = doc:get_selection() if line1 == line2 then lsp.goto_symbol(doc, line1, col1) end end, ["lsp:goto-implementation"] = function(doc) local line1, col1, line2 = doc:get_selection() if line1 == line2 then lsp.goto_symbol(doc, line1, col1, true) end end, ["lsp:show-signature"] = function(doc) local line1, col1, line2, col2 = doc:get_selection() if line1 == line2 and col1 == col2 then lsp.request_signature(doc, line1, col1, true) end end, ["lsp:show-symbol-info"] = function(doc) local line1, col1, line2 = doc:get_selection() if line1 == line2 then lsp.request_hover(doc, line1, col1) end end, ["lsp:show-symbol-info-in-tab"] = function(doc) local line1, col1, line2 = doc:get_selection() if line1 == line2 then lsp.request_hover(doc, line1, col1, true) end end, ["lsp:view-call-hierarchy"] = function(doc) local line1, col1, line2 = doc:get_selection() if line1 == line2 then lsp.request_call_hierarchy(doc, line1, col1) end end, ["lsp:view-document-symbols"] = function(doc) lsp.request_document_symbols(doc) end, ["lsp:format-document"] = function(doc) lsp.request_document_format(doc) end, ["lsp:view-document-diagnostics"] = function(doc) lsp.view_document_diagnostics(doc) end, ["lsp:rename-symbol"] = function(doc) local symbol = doc:get_text(doc:get_selection()) local line1, col1, line2 = doc:get_selection() if #symbol > 0 and line1 == line2 then core.command_view:enter("New Symbol Name", { text = symbol, submit = function(new_name) lsp.request_symbol_rename(doc, line1, col1, new_name) end }) else core.log("Please select a symbol on the document to rename.") end end, ["lsp:find-references"] = function(doc) local line1, col1, line2 = doc:get_selection() if line1 == line2 then lsp.request_references(doc, line1, col1) end end }) command.add(nil, { ["lsp:view-all-diagnostics"] = function() lsp.view_all_diagnostics() end, ["lsp:find-workspace-symbol"] = function() local dv = get_active_docview() local doc = dv and dv.doc or nil local symbol = doc and doc:get_text(doc:get_selection()) or "" core.command_view:enter("Find Workspace Symbol", { text = symbol, submit = function(query) lsp.request_workspace_symbol(doc, query) end }) end, ["lsp:toggle-diagnostics"] = function() if not diagnostics.lintplus_found then core.error("[LSP] Please install lintplus for diagnostics rendering.") return end lsp.toggle_diagnostics() end, ["lsp:stop-servers"] = function() lsp.stop_servers() end, ["lsp:start-servers"] = function() lsp.start_servers() end, ["lsp:restart-servers"] = function() lsp.stop_servers() lsp.start_servers() end }) -- -- Default Keybindings -- keymap.add { ["ctrl+space"] = "lsp:complete", ["ctrl+shift+space"] = "lsp:show-signature", ["alt+a"] = "lsp:show-symbol-info", ["alt+shift+a"] = "lsp:show-symbol-info-in-tab", ["alt+d"] = "lsp:goto-definition", ["alt+shift+d"] = "lsp:goto-implementation", ["alt+s"] = "lsp:view-document-symbols", ["alt+shift+s"] = "lsp:find-workspace-symbol", ["alt+f"] = "lsp:find-references", ["alt+shift+f"] = "lsp:format-document", ["alt+e"] = "lsp:view-document-diagnostics", ["ctrl+alt+e"] = "lsp:view-all-diagnostics", ["alt+shift+e"] = "lsp:toggle-diagnostics", ["alt+c"] = "lsp:view-call-hierarchy", ["alt+r"] = "lsp:rename-symbol", } -- -- Register context menu items -- local function lsp_predicate(_, _, also_in_symbol) local dv = get_active_docview() if dv then local doc = dv.doc if #lsp.get_active_servers(doc.filename, true) < 1 then return false elseif not also_in_symbol then return true end -- Make sure the cursor is place near a document symbol (word) local linem, colm = doc:get_selection() local linel, coll = doc:position_offset(linem, colm, translate.start_of_word) local liner, colr = doc:position_offset(linem, colm, translate.end_of_word) local word_left = doc:get_text(linel, coll, linem, colm) local word_right = doc:get_text(linem, colm, liner, colr) if #word_left > 0 or #word_right > 0 then return true end end return false end local function lsp_predicate_symbols() return lsp_predicate(nil, nil, true) end local menu_found, menu = pcall(require, "plugins.contextmenu") if menu_found then menu:register(lsp_predicate_symbols, { menu.DIVIDER, { text = "Show Symbol Info", command = "lsp:show-symbol-info" }, { text = "Show Symbol Info in Tab", command = "lsp:show-symbol-info-in-tab" }, { text = "Goto Definition", command = "lsp:goto-definition" }, { text = "Goto Implementation", command = "lsp:goto-implementation" }, { text = "Find References", command = "lsp:find-references" } }) menu:register(lsp_predicate, { menu.DIVIDER, { text = "Document Symbols", command = "lsp:view-document-symbols" }, { text = "Document Diagnostics", command = "lsp:view-document-diagnostics" }, { text = "Toggle Diagnostics", command = "lsp:toggle-diagnostics" }, { text = "Format Document", command = "lsp:format-document" }, }) local menu_show = menu.show function menu:show(...) lsp.hover_timer:stop() lsp.hover_timer:reset() listbox.hide() lsp.hover_position.triggered = false menu_show(self, ...) end end return lsp