diff options
author | Josh Rahm <joshuarahm@gmail.com> | 2023-01-25 17:57:01 +0000 |
---|---|---|
committer | Josh Rahm <joshuarahm@gmail.com> | 2023-01-25 17:57:01 +0000 |
commit | 9837de570c5972f98e74848edc97c297a13136ea (patch) | |
tree | cc948611912d116a3f98a744e690d3d7b6e2f59a /runtime/lua | |
parent | c367400b73d207833d51e09d663f969ffab37531 (diff) | |
parent | 3c48d3c83fc21dbc0841f9210f04bdb073d73cd1 (diff) | |
download | rneovim-9837de570c5972f98e74848edc97c297a13136ea.tar.gz rneovim-9837de570c5972f98e74848edc97c297a13136ea.tar.bz2 rneovim-9837de570c5972f98e74848edc97c297a13136ea.zip |
Merge remote-tracking branch 'upstream/master' into colorcolchar
Diffstat (limited to 'runtime/lua')
37 files changed, 3907 insertions, 601 deletions
diff --git a/runtime/lua/_vim9script.lua b/runtime/lua/_vim9script.lua new file mode 100644 index 0000000000..363d061451 --- /dev/null +++ b/runtime/lua/_vim9script.lua @@ -0,0 +1,650 @@ +------------------------------------------------------------------------------- +-- This file is auto generated by vim9jit. Do not edit by hand. +-- All content is in the source repository. +-- Bugs should be reported to: github.com/tjdevries/vim9jit +-- +-- In addition, this file is considered "private" by neovim. You should +-- not expect any of the APIs, functions, etc to be stable. They are subject +-- to change at any time. +------------------------------------------------------------------------------- + +local vim9 = (function() + local M = {} + + M.ternary = function(cond, if_true, if_false) + if cond then + if type(if_true) == 'function' then + return if_true() + else + return if_true + end + else + if type(if_false) == 'function' then + return if_false() + else + return if_false + end + end + end + + M.fn_ref = function(module, name, copied, ...) + for _, val in ipairs({ ... }) do + table.insert(copied, val) + end + + local funcref = name + if type(funcref) == 'function' then + return funcref(unpack(copied)) + elseif type(funcref) == 'string' then + if vim.fn.exists('*' .. funcref) == 1 then + return vim.fn[funcref](unpack(copied)) + end + + if module[funcref] then + module[funcref](unpack(copied)) + end + + error('unknown function: ' .. funcref) + else + error(string.format('unable to call funcref: %s', funcref)) + end + end + + M.fn_mut = function(name, args, info) + local result = vim.fn._Vim9ScriptFn(name, args) + for idx, val in pairs(result[2]) do + M.replace(args[idx], val) + end + + -- Substitute returning the reference to the + -- returned value + if info.replace then + return args[info.replace + 1] + end + + return result[1] + end + + M.replace = function(orig, new) + if type(orig) == 'table' and type(new) == 'table' then + for k in pairs(orig) do + orig[k] = nil + end + + for k, v in pairs(new) do + orig[k] = v + end + + return orig + end + + return new + end + + M.index = function(obj, idx) + if vim.tbl_islist(obj) then + if idx < 0 then + return obj[#obj + idx + 1] + else + return obj[idx + 1] + end + elseif type(obj) == 'table' then + return obj[idx] + elseif type(obj) == 'string' then + return string.sub(obj, idx + 1, idx + 1) + end + + error('invalid type for indexing: ' .. vim.inspect(obj)) + end + + M.index_expr = function(idx) + if type(idx) == 'string' then + return idx + elseif type(idx) == 'number' then + return idx + 1 + else + error(string.format('not yet handled: %s', vim.inspect(idx))) + end + end + + M.slice = function(obj, start, finish) + if start == nil then + start = 0 + end + + if start < 0 then + start = #obj + start + end + assert(type(start) == 'number') + + if finish == nil then + finish = #obj + end + + if finish < 0 then + finish = #obj + finish + end + assert(type(finish) == 'number') + + local slicer + if vim.tbl_islist(obj) then + slicer = vim.list_slice + elseif type(obj) == 'string' then + slicer = string.sub + else + error('invalid type for slicing: ' .. vim.inspect(obj)) + end + + return slicer(obj, start + 1, finish + 1) + end + + -- Currently unused, but this could be used to embed vim9jit within a + -- running nvim application and transpile "on the fly" as files are + -- sourced. There would still need to be some work done to make that + -- work correctly with imports and what not, but overall it could + -- work well for calling ":source X" from within a vimscript/vim9script + -- function + M.make_source_cmd = function() + local group = vim.api.nvim_create_augroup('vim9script-source', {}) + vim.api.nvim_create_autocmd('SourceCmd', { + pattern = '*.vim', + group = group, + callback = function(a) + local file = vim.fn.readfile(a.file) + for _, line in ipairs(file) do + -- TODO: Or starts with def <something> + -- You can use def in legacy vim files + if vim.startswith(line, 'vim9script') then + -- TODO: Use the rust lib to actually + -- generate the corresponding lua code and then + -- execute that (instead of sourcing it directly) + return + end + end + + vim.api.nvim_exec(table.concat(file, '\n'), false) + end, + }) + end + + M.iter = function(expr) + if vim.tbl_islist(expr) then + return ipairs(expr) + else + return pairs(expr) + end + end + + M.ITER_DEFAULT = 0 + M.ITER_CONTINUE = 1 + M.ITER_BREAK = 2 + M.ITER_RETURN = 3 + + return M +end)() + +vim.cmd([[ +function! _Vim9ScriptFn(name, args) abort + try + let ret = function(a:name, a:args)() + catch + echo "Failed..." + echo a:name + echo a:args + + throw v:errmsg + endtry + + return [ret, a:args] +endfunction +]]) + +vim9['autoload'] = (function() + return function(path) + return loadfile(path)() + end +end)() +vim9['bool'] = (function() + return function(...) + return vim9.convert.to_vim_bool(...) + end +end)() +vim9['convert'] = (function() + local M = {} + + M.decl_bool = function(val) + if type(val) == 'boolean' then + return val + elseif type(val) == 'number' then + if val == 0 then + return false + elseif val == 1 then + return true + else + error(string.format('bad number passed to bool declaration: %s', val)) + end + end + + error(string.format('invalid bool declaration: %s', vim.inspect(val))) + end + + M.decl_dict = function(val) + if type(val) == 'nil' then + return vim.empty_dict() + elseif type(val) == 'table' then + if vim.tbl_isempty(val) then + return vim.empty_dict() + elseif vim.tbl_islist(val) then + error(string.format('Cannot pass list to dictionary? %s', vim.inspect(val))) + else + return val + end + end + + error(string.format('invalid dict declaration: %s', vim.inspect(val))) + end + + M.to_vim_bool = function(val) + if type(val) == 'boolean' then + return val + elseif type(val) == 'number' then + return val ~= 0 + elseif type(val) == 'string' then + return string.len(val) ~= 0 + elseif type(val) == 'table' then + return not vim.tbl_isempty(val) + elseif val == nil then + return false + end + + error('unhandled type: ' .. vim.inspect(val)) + end + + return M +end)() +vim9['fn'] = (function() + local M = {} + + M.insert = function(list, item, idx) + if idx == nil then + idx = 1 + end + + table.insert(list, idx + 1, item) + + return list + end + + M.extend = function(left, right, expr3) + if expr3 ~= nil then + error("haven't written this code yet") + end + + if vim.tbl_islist(right) then + vim.list_extend(left, right) + return left + else + -- local result = vim.tbl_extend(left, right) + for k, v in pairs(right) do + left[k] = v + end + + return left + end + end + + M.add = function(list, item) + table.insert(list, item) + return list + end + + M.has_key = function(obj, key) + return not not obj[key] + end + + M.prop_type_add = function(...) + local args = { ... } + print('[prop_type_add]', vim.inspect(args)) + end + + do + local has_overrides = { + -- We do have vim9script ;) that's this plugin + ['vim9script'] = true, + + -- Include some vim patches that are sometimes required by variuos vim9script plugins + -- that we implement via vim9jit + [ [[patch-8.2.2261]] ] = true, + [ [[patch-8.2.4257]] ] = true, + } + + M.has = function(patch) + if has_overrides[patch] then + return true + end + + return vim.fn.has(patch) + end + end + + --[=[ +Currently missing patch, can be removed in the future. + +readdirex({directory} [, {expr} [, {dict}]]) *readdirex()* + Extended version of |readdir()|. + Return a list of Dictionaries with file and directory + information in {directory}. + This is useful if you want to get the attributes of file and + directory at the same time as getting a list of a directory. + This is much faster than calling |readdir()| then calling + |getfperm()|, |getfsize()|, |getftime()| and |getftype()| for + each file and directory especially on MS-Windows. + The list will by default be sorted by name (case sensitive), + the sorting can be changed by using the optional {dict} + argument, see |readdir()|. + + The Dictionary for file and directory information has the + following items: + group Group name of the entry. (Only on Unix) + name Name of the entry. + perm Permissions of the entry. See |getfperm()|. + size Size of the entry. See |getfsize()|. + time Timestamp of the entry. See |getftime()|. + type Type of the entry. + On Unix, almost same as |getftype()| except: + Symlink to a dir "linkd" + Other symlink "link" + On MS-Windows: + Normal file "file" + Directory "dir" + Junction "junction" + Symlink to a dir "linkd" + Other symlink "link" + Other reparse point "reparse" + user User name of the entry's owner. (Only on Unix) + On Unix, if the entry is a symlink, the Dictionary includes + the information of the target (except the "type" item). + On MS-Windows, it includes the information of the symlink + itself because of performance reasons. +--]=] + M.readdirex = function(dir) + local files = vim.fn.readdir(dir) + local direx = {} + for _, f in ipairs(files) do + table.insert(direx, { + name = f, + type = vim.fn.getftype(f), + }) + end + + return direx + end + + M.mapnew = function(tbl, expr) + return vim.fn.map(tbl, expr) + end + + M.typename = function(val) + local ty = type(val) + if ty == 'string' then + return 'string' + elseif ty == 'boolean' then + return 'bool' + elseif ty == 'number' then + return 'number' + else + error(string.format('typename: %s', val)) + end + end + + -- Popup menu stuff: Could be rolled into other plugin later + -- but currently is here for testing purposes (and implements + -- some very simple compat layers at the moment) + do + local pos_map = { + topleft = 'NW', + topright = 'NE', + botleft = 'SW', + botright = 'SE', + } + + M.popup_menu = function(_, options) + -- print "OPTIONS:" + + local buf = vim.api.nvim_create_buf(false, true) + local win = vim.api.nvim_open_win(buf, true, { + relative = 'editor', + style = 'minimal', + anchor = pos_map[options.pos], + height = options.maxheight or options.minheight, + width = options.maxwidth or options.minwidth, + row = options.line, + col = options.col, + }) + + if options.filter then + local loop + loop = function() + vim.cmd([[redraw!]]) + local ok, ch = pcall(vim.fn.getcharstr) + if not ok then + return + end -- interrupted + + if ch == '<C-C>' then + return + end + + if not require('vim9script').bool(options.filter(nil, ch)) then + vim.cmd.normal(ch) + end + + vim.schedule(loop) + end + + vim.schedule(loop) + end + + return win + end + + M.popup_settext = function(id, text) + if type(text) == 'string' then + -- text = vim.split(text, "\n") + error("Haven't handled string yet") + end + + local lines = {} + for _, obj in ipairs(text) do + table.insert(lines, obj.text) + end + + vim.api.nvim_buf_set_lines(vim.api.nvim_win_get_buf(id), 0, -1, false, lines) + end + + M.popup_filter_menu = function() + print('ok, just pretend we filtered the menu') + end + + M.popup_setoptions = function(id, _) + print('setting options...', id) + end + end + + M = setmetatable(M, { + __index = vim.fn, + }) + + return M +end)() +vim9['heredoc'] = (function() + local M = {} + + M.trim = function(lines) + local min_whitespace = 9999 + for _, line in ipairs(lines) do + local _, finish = string.find(line, '^%s*') + min_whitespace = math.min(min_whitespace, finish) + end + + local trimmed_lines = {} + for _, line in ipairs(lines) do + table.insert(trimmed_lines, string.sub(line, min_whitespace + 1)) + end + + return trimmed_lines + end + + return M +end)() +vim9['import'] = (function() + local imported = {} + imported.autoload = setmetatable({}, { + __index = function(_, name) + local luaname = 'autoload/' .. string.gsub(name, '%.vim$', '.lua') + local runtime_file = vim.api.nvim_get_runtime_file(luaname, false)[1] + if not runtime_file then + error('unable to find autoload file:' .. name) + end + + return imported.absolute[vim.fn.fnamemodify(runtime_file, ':p')] + end, + }) + + imported.absolute = setmetatable({}, { + __index = function(self, name) + if vim.loop.fs_stat(name) then + local result = loadfile(name)() + rawset(self, name, result) + + return result + end + + error(string.format('unabled to find absolute file: %s', name)) + end, + }) + + return function(info) + local name = info.name + + if info.autoload then + return imported.autoload[info.name] + end + + local debug_info = debug.getinfo(2, 'S') + local sourcing_path = vim.fn.fnamemodify(string.sub(debug_info.source, 2), ':p') + + -- Relative paths + if vim.startswith(name, '../') or vim.startswith(name, './') then + local luaname = string.gsub(name, '%.vim$', '.lua') + local directory = vim.fn.fnamemodify(sourcing_path, ':h') + local search = directory .. '/' .. luaname + return imported.absolute[search] + end + + if vim.startswith(name, '/') then + error('absolute path') + -- local luaname = string.gsub(name, "%.vim", ".lua") + -- local runtime_file = vim.api.nvim_get_runtime_file(luaname, false)[1] + -- if runtime_file then + -- runtime_file = vim.fn.fnamemodify(runtime_file, ":p") + -- return loadfile(runtime_file)() + -- end + end + + error('Unhandled case' .. vim.inspect(info) .. vim.inspect(debug_info)) + end +end)() +vim9['ops'] = (function() + local lib = vim9 + + local M = {} + + M['And'] = function(left, right) + return lib.bool(left) and lib.bool(right) + end + + M['Or'] = function(left, right) + return lib.bool(left) or lib.bool(right) + end + + M['Plus'] = function(left, right) + return left + right + end + + M['Multiply'] = function(left, right) + return left * right + end + + M['Divide'] = function(left, right) + return left / right + end + + M['StringConcat'] = function(left, right) + return left .. right + end + + M['EqualTo'] = function(left, right) + return left == right + end + + M['NotEqualTo'] = function(left, right) + return not M['EqualTo'](left, right) + end + + M['LessThan'] = function(left, right) + return left < right + end + + M['LessThanOrEqual'] = function(left, right) + return left <= right + end + + M['GreaterThan'] = function(left, right) + return left > right + end + + M['GreaterThanOrEqual'] = function(left, right) + return left >= right + end + + M['RegexpMatches'] = function(left, right) + return not not vim.regex(right):match_str(left) + end + + M['RegexpMatchesIns'] = function(left, right) + return not not vim.regex('\\c' .. right):match_str(left) + end + + M['NotRegexpMatches'] = function(left, right) + return not M['RegexpMatches'](left, right) + end + + M['Modulo'] = function(left, right) + return left % right + end + + M['Minus'] = function(left, right) + -- TODO: This is not right :) + return left - right + end + + return M +end)() +vim9['prefix'] = (function() + local lib = vim9 + + local M = {} + + M['Minus'] = function(right) + return -right + end + + M['Bang'] = function(right) + return not lib.bool(right) + end + + return M +end)() + +return vim9 diff --git a/runtime/lua/editorconfig.lua b/runtime/lua/editorconfig.lua new file mode 100644 index 0000000000..2079006234 --- /dev/null +++ b/runtime/lua/editorconfig.lua @@ -0,0 +1,246 @@ +local M = {} + +M.properties = {} + +--- Modified version of the builtin assert that does not include error position information +--- +---@param v any Condition +---@param message string Error message to display if condition is false or nil +---@return any v if not false or nil, otherwise an error is displayed +--- +---@private +local function assert(v, message) + return v or error(message, 0) +end + +--- Show a warning message +--- +---@param msg string Message to show +--- +---@private +local function warn(msg, ...) + vim.notify(string.format(msg, ...), vim.log.levels.WARN, { + title = 'editorconfig', + }) +end + +function M.properties.charset(bufnr, val) + assert( + vim.tbl_contains({ 'utf-8', 'utf-8-bom', 'latin1', 'utf-16be', 'utf-16le' }, val), + 'charset must be one of "utf-8", "utf-8-bom", "latin1", "utf-16be", or "utf-16le"' + ) + if val == 'utf-8' or val == 'utf-8-bom' then + vim.bo[bufnr].fileencoding = 'utf-8' + vim.bo[bufnr].bomb = val == 'utf-8-bom' + elseif val == 'utf-16be' then + vim.bo[bufnr].fileencoding = 'utf-16' + else + vim.bo[bufnr].fileencoding = val + end +end + +function M.properties.end_of_line(bufnr, val) + vim.bo[bufnr].fileformat = assert( + ({ lf = 'unix', crlf = 'dos', cr = 'mac' })[val], + 'end_of_line must be one of "lf", "crlf", or "cr"' + ) +end + +function M.properties.indent_style(bufnr, val, opts) + assert(val == 'tab' or val == 'space', 'indent_style must be either "tab" or "space"') + vim.bo[bufnr].expandtab = val == 'space' + if val == 'tab' and not opts.indent_size then + vim.bo[bufnr].shiftwidth = 0 + vim.bo[bufnr].softtabstop = 0 + end +end + +function M.properties.indent_size(bufnr, val, opts) + if val == 'tab' then + vim.bo[bufnr].shiftwidth = 0 + vim.bo[bufnr].softtabstop = 0 + else + local n = assert(tonumber(val), 'indent_size must be a number') + vim.bo[bufnr].shiftwidth = n + vim.bo[bufnr].softtabstop = -1 + if not opts.tab_width then + vim.bo[bufnr].tabstop = n + end + end +end + +function M.properties.tab_width(bufnr, val) + vim.bo[bufnr].tabstop = assert(tonumber(val), 'tab_width must be a number') +end + +function M.properties.max_line_length(bufnr, val) + local n = tonumber(val) + if n then + vim.bo[bufnr].textwidth = n + else + assert(val == 'off', 'max_line_length must be a number or "off"') + vim.bo[bufnr].textwidth = 0 + end +end + +function M.properties.trim_trailing_whitespace(bufnr, val) + assert( + val == 'true' or val == 'false', + 'trim_trailing_whitespace must be either "true" or "false"' + ) + if val == 'true' then + vim.api.nvim_create_autocmd('BufWritePre', { + group = 'editorconfig', + buffer = bufnr, + callback = function() + local view = vim.fn.winsaveview() + vim.api.nvim_command('silent! undojoin') + vim.api.nvim_command('silent keepjumps keeppatterns %s/\\s\\+$//e') + vim.fn.winrestview(view) + end, + }) + else + vim.api.nvim_clear_autocmds({ + event = 'BufWritePre', + group = 'editorconfig', + buffer = bufnr, + }) + end +end + +function M.properties.insert_final_newline(bufnr, val) + assert(val == 'true' or val == 'false', 'insert_final_newline must be either "true" or "false"') + vim.bo[bufnr].fixendofline = val == 'true' + vim.bo[bufnr].endofline = val == 'true' +end + +--- Modified version of |glob2regpat()| that does not match path separators on *. +--- +--- This function replaces single instances of * with the regex pattern [^/]*. However, the star in +--- the replacement pattern also gets interpreted by glob2regpat, so we insert a placeholder, pass +--- it through glob2regpat, then replace the placeholder with the actual regex pattern. +--- +---@param glob string Glob to convert into a regular expression +---@return string Regular expression +--- +---@private +local function glob2regpat(glob) + local placeholder = '@@PLACEHOLDER@@' + return ( + string.gsub( + vim.fn.glob2regpat( + vim.fn.substitute( + string.gsub(glob, '{(%d+)%.%.(%d+)}', '[%1-%2]'), + '\\*\\@<!\\*\\*\\@!', + placeholder, + 'g' + ) + ), + placeholder, + '[^/]*' + ) + ) +end + +--- Parse a single line in an EditorConfig file +--- +---@param line string Line +---@return string|nil If the line contains a pattern, the glob pattern +---@return string|nil If the line contains a key-value pair, the key +---@return string|nil If the line contains a key-value pair, the value +--- +---@private +local function parse_line(line) + if line:find('^%s*[^ #;]') then + local glob = (line:match('%b[]') or ''):match('^%s*%[(.*)%]%s*$') + if glob then + return glob, nil, nil + end + + local key, val = line:match('^%s*([^:= ][^:=]-)%s*[:=]%s*(.-)%s*$') + if key ~= nil and val ~= nil then + return nil, key:lower(), val:lower() + end + end +end + +--- Parse options from an .editorconfig file +--- +---@param filepath string File path of the file to apply EditorConfig settings to +---@param dir string Current directory +---@return table Table of options to apply to the given file +--- +---@private +local function parse(filepath, dir) + local pat = nil + local opts = {} + local f = io.open(dir .. '/.editorconfig') + if f then + for line in f:lines() do + local glob, key, val = parse_line(line) + if glob then + glob = glob:find('/') and (dir .. '/' .. glob:gsub('^/', '')) or ('**/' .. glob) + local ok, regpat = pcall(glob2regpat, glob) + if ok then + pat = vim.regex(regpat) + else + pat = nil + warn('editorconfig: Error occurred while parsing glob pattern "%s": %s', glob, regpat) + end + elseif key ~= nil and val ~= nil then + if key == 'root' then + opts.root = val == 'true' + elseif pat and pat:match_str(filepath) then + opts[key] = val + end + end + end + f:close() + end + return opts +end + +--- Configure the given buffer with options from an .editorconfig file +--- +---@param bufnr number Buffer number to configure +--- +---@private +function M.config(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + local path = vim.fs.normalize(vim.api.nvim_buf_get_name(bufnr)) + if vim.bo[bufnr].buftype ~= '' or not vim.bo[bufnr].modifiable or path == '' then + return + end + + local opts = {} + for parent in vim.fs.parents(path) do + for k, v in pairs(parse(path, parent)) do + if opts[k] == nil then + opts[k] = v + end + end + + if opts.root then + break + end + end + + local applied = {} + for opt, val in pairs(opts) do + if val ~= 'unset' then + local func = M.properties[opt] + if func then + local ok, err = pcall(func, bufnr, val, opts) + if ok then + applied[opt] = val + else + warn('editorconfig: invalid value for option %s: %s. %s', opt, val, err) + end + end + end + end + + vim.b[bufnr].editorconfig = applied +end + +return M diff --git a/runtime/lua/man.lua b/runtime/lua/man.lua index 6477786dbb..732a4ab92e 100644 --- a/runtime/lua/man.lua +++ b/runtime/lua/man.lua @@ -12,7 +12,7 @@ local function man_error(msg) end -- Run a system command and timeout after 30 seconds. -local function system(cmd, silent, env) +local function system(cmd_, silent, env) local stdout_data = {} local stderr_data = {} local stdout = vim.loop.new_pipe(false) @@ -21,11 +21,23 @@ local function system(cmd, silent, env) local done = false local exit_code + -- We use the `env` command here rather than the env option to vim.loop.spawn since spawn will + -- completely overwrite the environment when we just want to modify the existing one. + -- + -- Overwriting mainly causes problems NixOS which relies heavily on a non-standard environment. + local cmd + if env then + cmd = { 'env' } + vim.list_extend(cmd, env) + vim.list_extend(cmd, cmd_) + else + cmd = cmd_ + end + local handle handle = vim.loop.spawn(cmd[1], { args = vim.list_slice(cmd, 2), stdio = { nil, stdout, stderr }, - env = env, }, function(code) exit_code = code stdout:close() @@ -283,6 +295,14 @@ local function get_path(sect, name, silent) return end + -- `man -w /some/path` will return `/some/path` for any existent file, which + -- stops us from actually determining if a path has a corresponding man file. + -- Since `:Man /some/path/to/man/file` isn't supported anyway, we should just + -- error out here if we detect this is the case. + if sect == '' and #results == 1 and results[1] == name then + return + end + -- find any that match the specified name local namematches = vim.tbl_filter(function(v) return fn.fnamemodify(v, ':t'):match(name) @@ -400,6 +420,10 @@ local function extract_sect_and_name_path(path) end local function find_man() + if vim.bo.filetype == 'man' then + return true + end + local win = 1 while win <= fn.winnr('$') do local buf = fn.winbufnr(win) @@ -449,7 +473,7 @@ local function get_page(path, silent) end local function put_page(page) - vim.bo.modified = true + vim.bo.modifiable = true vim.bo.readonly = false vim.bo.swapfile = false @@ -604,11 +628,6 @@ function M.goto_tag(pattern, _, _) end end - if vim.o.cscopetag then - -- return only a single entry so we work well with :cstag (#11675) - structured = { structured[1] } - end - return vim.tbl_map(function(entry) return { name = entry.name, diff --git a/runtime/lua/man/health.lua b/runtime/lua/man/health.lua deleted file mode 100644 index 11d7148216..0000000000 --- a/runtime/lua/man/health.lua +++ /dev/null @@ -1,20 +0,0 @@ -local M = {} - -local report_ok = vim.fn['health#report_ok'] -local report_error = vim.fn['health#report_error'] - -local function check_runtime_file(name) - local path = vim.env.VIMRUNTIME .. '/' .. name - if vim.loop.fs_stat(path) then - report_error(string.format('%s detected. Please delete %s', name, path)) - else - report_ok(string.format('%s not in $VIMRUNTIME', name)) - end -end - -function M.check() - check_runtime_file('plugin/man.vim') - check_runtime_file('autoload/man.vim') -end - -return M diff --git a/runtime/lua/nvim/health.lua b/runtime/lua/nvim/health.lua new file mode 100644 index 0000000000..b76106f241 --- /dev/null +++ b/runtime/lua/nvim/health.lua @@ -0,0 +1,406 @@ +local M = {} +local health = require('vim.health') + +local fn_bool = function(key) + return function(...) + return vim.fn[key](...) == 1 + end +end + +local has = fn_bool('has') +local executable = fn_bool('executable') +local empty = fn_bool('empty') +local filereadable = fn_bool('filereadable') +local filewritable = fn_bool('filewritable') + +local shell_error = function() + return vim.v.shell_error ~= 0 +end + +local suggest_faq = 'https://github.com/neovim/neovim/wiki/Building-Neovim#optimized-builds' + +local function check_runtime() + health.report_start('Runtime') + -- Files from an old installation. + local bad_files = { + ['plugin/man.vim'] = false, + ['scripts.vim'] = false, + ['autoload/man.vim'] = false, + } + local bad_files_msg = '' + for k, _ in pairs(bad_files) do + local path = ('%s/%s'):format(vim.env.VIMRUNTIME, k) + if vim.loop.fs_stat(path) then + bad_files[k] = true + bad_files_msg = ('%s%s\n'):format(bad_files_msg, path) + end + end + + local ok = (bad_files_msg == '') + local info = ok and health.report_ok or health.report_info + info(string.format('$VIMRUNTIME: %s', vim.env.VIMRUNTIME)) + if not ok then + health.report_error( + string.format( + '$VIMRUNTIME has files from an old installation (this can cause weird behavior):\n%s', + bad_files_msg + ), + { 'Delete $VIMRUNTIME (or uninstall Nvim), then reinstall Nvim.' } + ) + end +end + +local function check_config() + health.report_start('Configuration') + local ok = true + + local vimrc = ( + empty(vim.env.MYVIMRC) and vim.fn.stdpath('config') .. '/init.vim' or vim.env.MYVIMRC + ) + if not filereadable(vimrc) then + ok = false + local has_vim = filereadable(vim.fn.expand('~/.vimrc')) + health.report_warn( + (-1 == vim.fn.getfsize(vimrc) and 'Missing' or 'Unreadable') .. ' user config file: ' .. vimrc, + { has_vim and ':help nvim-from-vim' or ':help init.vim' } + ) + end + + -- If $VIM is empty we don't care. Else make sure it is valid. + if not empty(vim.env.VIM) and not filereadable(vim.env.VIM .. '/runtime/doc/nvim.txt') then + ok = false + health.report_error('$VIM is invalid: ' .. vim.env.VIM) + end + + if vim.env.NVIM_TUI_ENABLE_CURSOR_SHAPE then + ok = false + health.report_warn('$NVIM_TUI_ENABLE_CURSOR_SHAPE is ignored in Nvim 0.2+', { + "Use the 'guicursor' option to configure cursor shape. :help 'guicursor'", + 'https://github.com/neovim/neovim/wiki/Following-HEAD#20170402', + }) + end + + if vim.v.ctype == 'C' then + ok = false + health.report_error( + 'Locale does not support UTF-8. Unicode characters may not display correctly.' + .. ('\n$LANG=%s $LC_ALL=%s $LC_CTYPE=%s'):format( + vim.env.LANG, + vim.env.LC_ALL, + vim.env.LC_CTYPE + ), + { + 'If using tmux, try the -u option.', + 'Ensure that your terminal/shell/tmux/etc inherits the environment, or set $LANG explicitly.', + 'Configure your system locale.', + } + ) + end + + if vim.o.paste == 1 then + ok = false + health.report_error( + "'paste' is enabled. This option is only for pasting text.\nIt should not be set in your config.", + { + 'Remove `set paste` from your init.vim, if applicable.', + 'Check `:verbose set paste?` to see if a plugin or script set the option.', + } + ) + end + + local writeable = true + local shadaopt = vim.fn.split(vim.o.shada, ',') + local shadafile = ( + empty(vim.o.shada) and vim.o.shada + or vim.fn.substitute(vim.fn.matchstr(shadaopt[#shadaopt], '^n.\\+'), '^n', '', '') + ) + shadafile = ( + empty(vim.o.shadafile) + and (empty(shadafile) and vim.fn.stdpath('state') .. '/shada/main.shada' or vim.fn.expand( + shadafile + )) + or (vim.o.shadafile == 'NONE' and '' or vim.o.shadafile) + ) + if not empty(shadafile) and empty(vim.fn.glob(shadafile)) then + -- Since this may be the first time Nvim has been run, try to create a shada file. + if not pcall(vim.cmd.wshada) then + writeable = false + end + end + if + not writeable + or (not empty(shadafile) and (not filereadable(shadafile) or not filewritable(shadafile))) + then + ok = false + health.report_error( + 'shada file is not ' + .. ((not writeable or filereadable(shadafile)) and 'writeable' or 'readable') + .. ':\n' + .. shadafile + ) + end + + if ok then + health.report_ok('no issues found') + end +end + +local function check_performance() + health.report_start('Performance') + + -- Check buildtype + local buildtype = vim.fn.matchstr(vim.fn.execute('version'), [[\v\cbuild type:?\s*[^\n\r\t ]+]]) + if empty(buildtype) then + health.report_error('failed to get build type from :version') + elseif vim.regex([[\v(MinSizeRel|Release|RelWithDebInfo)]]):match_str(buildtype) then + health.report_ok(buildtype) + else + health.report_info(buildtype) + health.report_warn( + 'Non-optimized ' .. (has('debug') and '(DEBUG) ' or '') .. 'build. Nvim will be slower.', + { + 'Install a different Nvim package, or rebuild with `CMAKE_BUILD_TYPE=RelWithDebInfo`.', + suggest_faq, + } + ) + end + + -- check for slow shell invocation + local slow_cmd_time = 1.5 + local start_time = vim.fn.reltime() + vim.fn.system('echo') + local elapsed_time = vim.fn.reltimefloat(vim.fn.reltime(start_time)) + if elapsed_time > slow_cmd_time then + health.report_warn( + 'Slow shell invocation (took ' .. vim.fn.printf('%.2f', elapsed_time) .. ' seconds).' + ) + end +end + +-- Load the remote plugin manifest file and check for unregistered plugins +local function check_rplugin_manifest() + health.report_start('Remote Plugins') + + local existing_rplugins = {} + for _, item in ipairs(vim.fn['remote#host#PluginsForHost']('python')) do + existing_rplugins[item.path] = 'python' + end + + for item in ipairs(vim.fn['remote#host#PluginsForHost']('python3')) do + existing_rplugins[item.path] = 'python3' + end + + local require_update = false + local handle_path = function(path) + local python_glob = vim.fn.glob(path .. '/rplugin/python*', true, true) + if empty(python_glob) then + return + end + + local python_dir = python_glob[1] + local python_version = vim.fn.fnamemodify(python_dir, ':t') + + local scripts = vim.fn.glob(python_dir .. '/*.py', true, true) + vim.list_extend(scripts, vim.fn.glob(python_dir .. '/*/__init__.py', true, true)) + + for script in ipairs(scripts) do + local contents = vim.fn.join(vim.fn.readfile(script)) + if vim.regex([[\<\%(from\|import\)\s\+neovim\>]]):match_str(contents) then + if vim.regex([[[\/]__init__\.py$]]):match_str(script) then + script = vim.fn.tr(vim.fn.fnamemodify(script, ':h'), '\\', '/') + end + if not existing_rplugins[script] then + local msg = vim.fn.printf('"%s" is not registered.', vim.fn.fnamemodify(path, ':t')) + if python_version == 'pythonx' then + if not has('python3') then + msg = msg .. ' (python3 not available)' + end + elseif not has(python_version) then + msg = msg .. vim.fn.printf(' (%s not available)', python_version) + else + require_update = true + end + + health.report_warn(msg) + end + + break + end + end + end + + for _, path in ipairs(vim.fn.map(vim.fn.split(vim.o.runtimepath, ','), 'resolve(v:val)')) do + handle_path(path) + end + + if require_update then + health.report_warn('Out of date', { 'Run `:UpdateRemotePlugins`' }) + else + health.report_ok('Up to date') + end +end + +local function check_tmux() + if empty(vim.env.TMUX) or not executable('tmux') then + return + end + + local get_tmux_option = function(option) + local cmd = 'tmux show-option -qvg ' .. option -- try global scope + local out = vim.fn.system(vim.fn.split(cmd)) + local val = vim.fn.substitute(out, [[\v(\s|\r|\n)]], '', 'g') + if shell_error() then + health.report_error('command failed: ' .. cmd .. '\n' .. out) + return 'error' + elseif empty(val) then + cmd = 'tmux show-option -qvgs ' .. option -- try session scope + out = vim.fn.system(vim.fn.split(cmd)) + val = vim.fn.substitute(out, [[\v(\s|\r|\n)]], '', 'g') + if shell_error() then + health.report_error('command failed: ' .. cmd .. '\n' .. out) + return 'error' + end + end + return val + end + + health.report_start('tmux') + + -- check escape-time + local suggestions = + { 'set escape-time in ~/.tmux.conf:\nset-option -sg escape-time 10', suggest_faq } + local tmux_esc_time = get_tmux_option('escape-time') + if tmux_esc_time ~= 'error' then + if empty(tmux_esc_time) then + health.report_error('`escape-time` is not set', suggestions) + elseif tonumber(tmux_esc_time) > 300 then + health.report_error( + '`escape-time` (' .. tmux_esc_time .. ') is higher than 300ms', + suggestions + ) + else + health.report_ok('escape-time: ' .. tmux_esc_time) + end + end + + -- check focus-events + local tmux_focus_events = get_tmux_option('focus-events') + if tmux_focus_events ~= 'error' then + if empty(tmux_focus_events) or tmux_focus_events ~= 'on' then + health.report_warn( + "`focus-events` is not enabled. |'autoread'| may not work.", + { '(tmux 1.9+ only) Set `focus-events` in ~/.tmux.conf:\nset-option -g focus-events on' } + ) + else + health.report_ok('focus-events: ' .. tmux_focus_events) + end + end + + -- check default-terminal and $TERM + health.report_info('$TERM: ' .. vim.env.TERM) + local cmd = 'tmux show-option -qvg default-terminal' + local out = vim.fn.system(vim.fn.split(cmd)) + local tmux_default_term = vim.fn.substitute(out, [[\v(\s|\r|\n)]], '', 'g') + if empty(tmux_default_term) then + cmd = 'tmux show-option -qvgs default-terminal' + out = vim.fn.system(vim.fn.split(cmd)) + tmux_default_term = vim.fn.substitute(out, [[\v(\s|\r|\n)]], '', 'g') + end + + if shell_error() then + health.report_error('command failed: ' .. cmd .. '\n' .. out) + elseif tmux_default_term ~= vim.env.TERM then + health.report_info('default-terminal: ' .. tmux_default_term) + health.report_error( + '$TERM differs from the tmux `default-terminal` setting. Colors might look wrong.', + { '$TERM may have been set by some rc (.bashrc, .zshrc, ...).' } + ) + elseif not vim.regex([[\v(tmux-256color|screen-256color)]]):match_str(vim.env.TERM) then + health.report_error( + '$TERM should be "screen-256color" or "tmux-256color" in tmux. Colors might look wrong.', + { + 'Set default-terminal in ~/.tmux.conf:\nset-option -g default-terminal "screen-256color"', + suggest_faq, + } + ) + end + + -- check for RGB capabilities + local info = vim.fn.system({ 'tmux', 'display-message', '-p', '#{client_termfeatures}' }) + info = vim.split(vim.trim(info), ',', { trimempty = true }) + if not vim.tbl_contains(info, 'RGB') then + local has_rgb = false + if #info == 0 then + -- client_termfeatures may not be supported; fallback to checking show-messages + info = vim.fn.system({ 'tmux', 'show-messages', '-JT' }) + has_rgb = info:find(' Tc: (flag) true', 1, true) or info:find(' RGB: (flag) true', 1, true) + end + if not has_rgb then + health.report_warn( + "Neither Tc nor RGB capability set. True colors are disabled. |'termguicolors'| won't work properly.", + { + "Put this in your ~/.tmux.conf and replace XXX by your $TERM outside of tmux:\nset-option -sa terminal-features ',XXX:RGB'", + "For older tmux versions use this instead:\nset-option -ga terminal-overrides ',XXX:Tc'", + } + ) + end + end +end + +local function check_terminal() + if not executable('infocmp') then + return + end + + health.report_start('terminal') + local cmd = 'infocmp -L' + local out = vim.fn.system(vim.fn.split(cmd)) + local kbs_entry = vim.fn.matchstr(out, 'key_backspace=[^,[:space:]]*') + local kdch1_entry = vim.fn.matchstr(out, 'key_dc=[^,[:space:]]*') + + if + shell_error() + and ( + not has('win32') + or empty( + vim.fn.matchstr( + out, + [[infocmp: couldn't open terminfo file .\+\%(conemu\|vtpcon\|win32con\)]] + ) + ) + ) + then + health.report_error('command failed: ' .. cmd .. '\n' .. out) + else + health.report_info( + vim.fn.printf( + 'key_backspace (kbs) terminfo entry: `%s`', + (empty(kbs_entry) and '? (not found)' or kbs_entry) + ) + ) + + health.report_info( + vim.fn.printf( + 'key_dc (kdch1) terminfo entry: `%s`', + (empty(kbs_entry) and '? (not found)' or kdch1_entry) + ) + ) + end + + for env_var in ipairs({ 'XTERM_VERSION', 'VTE_VERSION', 'TERM_PROGRAM', 'COLORTERM', 'SSH_TTY' }) do + if vim.env[env_var] then + health.report_info(vim.fn.printf('$%s="%s"', env_var, vim.env[env_var])) + end + end +end + +function M.check() + check_config() + check_runtime() + check_performance() + check_rplugin_manifest() + check_terminal() + check_tmux() +end + +return M diff --git a/runtime/lua/vim/_editor.lua b/runtime/lua/vim/_editor.lua index 28e5897aa9..da8764fbd4 100644 --- a/runtime/lua/vim/_editor.lua +++ b/runtime/lua/vim/_editor.lua @@ -36,6 +36,7 @@ for k, v in pairs({ ui = true, health = true, fs = true, + secure = true, }) do vim._submodules[k] = v end @@ -122,7 +123,7 @@ do --- (such as the |TUI|) pastes text into the editor. --- --- Example: To remove ANSI color codes when pasting: - --- <pre> + --- <pre>lua --- vim.paste = (function(overridden) --- return function(lines, phase) --- for i,line in ipairs(lines) do @@ -167,7 +168,8 @@ do local line1 = lines[1]:gsub('(%c)', '\022%1') -- nvim_input() is affected by mappings, -- so use nvim_feedkeys() with "n" flag to ignore mappings. - vim.api.nvim_feedkeys(line1, 'n', true) + -- "t" flag is also needed so the pasted text is saved in cmdline history. + vim.api.nvim_feedkeys(line1, 'nt', true) end return true end @@ -278,7 +280,7 @@ end --- command. --- --- Example: ---- <pre> +--- <pre>lua --- vim.cmd('echo 42') --- vim.cmd([[ --- augroup My_group @@ -389,7 +391,7 @@ end ---@param pos2 integer[] (line, column) tuple marking end of region ---@param regtype string type of selection, see |setreg()| ---@param inclusive boolean indicating whether the selection is end-inclusive ----@return table<integer, {}> region lua table of the form {linenr = {startcol,endcol}} +---@return table region Table of the form `{linenr = {startcol,endcol}}` function vim.region(bufnr, pos1, pos2, regtype, inclusive) if not vim.api.nvim_buf_is_loaded(bufnr) then vim.fn.bufload(bufnr) @@ -416,11 +418,16 @@ function vim.region(bufnr, pos1, pos2, regtype, inclusive) c2 = c1 + regtype:sub(2) -- and adjust for non-ASCII characters bufline = vim.api.nvim_buf_get_lines(bufnr, l, l + 1, true)[1] - if c1 < #bufline then + local utflen = vim.str_utfindex(bufline, #bufline) + if c1 <= utflen then c1 = vim.str_byteindex(bufline, c1) + else + c1 = #bufline + 1 end - if c2 < #bufline then + if c2 <= utflen then c2 = vim.str_byteindex(bufline, c2) + else + c2 = #bufline + 1 end else c1 = (l == pos1[1]) and pos1[2] or 0 @@ -739,7 +746,7 @@ end ---Prints given arguments in human-readable format. ---Example: ----<pre> +---<pre>lua --- -- Print highlight group Normal and store it's contents in a variable. --- local hl_normal = vim.pretty_print(vim.api.nvim_get_hl_by_name("Normal", true)) ---</pre> diff --git a/runtime/lua/vim/_init_packages.lua b/runtime/lua/vim/_init_packages.lua index 19c8608732..0c4ee8636d 100644 --- a/runtime/lua/vim/_init_packages.lua +++ b/runtime/lua/vim/_init_packages.lua @@ -56,6 +56,9 @@ setmetatable(vim, { if vim._submodules[key] then t[key] = require('vim.' .. key) return t[key] + elseif key == 'inspect_pos' or key == 'show_pos' then + require('vim._inspector') + return t[key] elseif vim.startswith(key, 'uri_') then local val = require('vim.uri')[key] if val ~= nil then diff --git a/runtime/lua/vim/_inspector.lua b/runtime/lua/vim/_inspector.lua new file mode 100644 index 0000000000..f46a525910 --- /dev/null +++ b/runtime/lua/vim/_inspector.lua @@ -0,0 +1,238 @@ +---@class InspectorFilter +---@field syntax boolean include syntax based highlight groups (defaults to true) +---@field treesitter boolean include treesitter based highlight groups (defaults to true) +---@field extmarks boolean|"all" include extmarks. When `all`, then extmarks without a `hl_group` will also be included (defaults to true) +---@field semantic_tokens boolean include semantic tokens (defaults to true) +local defaults = { + syntax = true, + treesitter = true, + extmarks = true, + semantic_tokens = true, +} + +---Get all the items at a given buffer position. +--- +---Can also be pretty-printed with `:Inspect!`. *:Inspect!* +--- +---@param bufnr? number defaults to the current buffer +---@param row? number row to inspect, 0-based. Defaults to the row of the current cursor +---@param col? number col to inspect, 0-based. Defaults to the col of the current cursor +---@param filter? InspectorFilter (table|nil) a table with key-value pairs to filter the items +--- - syntax (boolean): include syntax based highlight groups (defaults to true) +--- - treesitter (boolean): include treesitter based highlight groups (defaults to true) +--- - extmarks (boolean|"all"): include extmarks. When `all`, then extmarks without a `hl_group` will also be included (defaults to true) +--- - semantic_tokens (boolean): include semantic tokens (defaults to true) +---@return {treesitter:table,syntax:table,extmarks:table,semantic_tokens:table,buffer:number,col:number,row:number} (table) a table with the following key-value pairs. Items are in "traversal order": +--- - treesitter: a list of treesitter captures +--- - syntax: a list of syntax groups +--- - semantic_tokens: a list of semantic tokens +--- - extmarks: a list of extmarks +--- - buffer: the buffer used to get the items +--- - row: the row used to get the items +--- - col: the col used to get the items +function vim.inspect_pos(bufnr, row, col, filter) + filter = vim.tbl_deep_extend('force', defaults, filter or {}) + + bufnr = bufnr or 0 + if row == nil or col == nil then + -- get the row/col from the first window displaying the buffer + local win = bufnr == 0 and vim.api.nvim_get_current_win() or vim.fn.bufwinid(bufnr) + if win == -1 then + error('row/col is required for buffers not visible in a window') + end + local cursor = vim.api.nvim_win_get_cursor(win) + row, col = cursor[1] - 1, cursor[2] + end + bufnr = bufnr == 0 and vim.api.nvim_get_current_buf() or bufnr + + local results = { + treesitter = {}, + syntax = {}, + extmarks = {}, + semantic_tokens = {}, + buffer = bufnr, + row = row, + col = col, + } + + -- resolve hl links + ---@private + local function resolve_hl(data) + if data.hl_group then + local hlid = vim.api.nvim_get_hl_id_by_name(data.hl_group) + local name = vim.fn.synIDattr(vim.fn.synIDtrans(hlid), 'name') + data.hl_group_link = name + end + return data + end + + -- treesitter + if filter.treesitter then + for _, capture in pairs(vim.treesitter.get_captures_at_pos(bufnr, row, col)) do + capture.hl_group = '@' .. capture.capture + table.insert(results.treesitter, resolve_hl(capture)) + end + end + + -- syntax + if filter.syntax then + for _, i1 in ipairs(vim.fn.synstack(row + 1, col + 1)) do + table.insert(results.syntax, resolve_hl({ hl_group = vim.fn.synIDattr(i1, 'name') })) + end + end + + -- semantic tokens + if filter.semantic_tokens then + for _, token in ipairs(vim.lsp.semantic_tokens.get_at_pos(bufnr, row, col) or {}) do + token.hl_groups = { + type = resolve_hl({ hl_group = '@' .. token.type }), + modifiers = vim.tbl_map(function(modifier) + return resolve_hl({ hl_group = '@' .. modifier }) + end, token.modifiers or {}), + } + table.insert(results.semantic_tokens, token) + end + end + + -- extmarks + if filter.extmarks then + for ns, nsid in pairs(vim.api.nvim_get_namespaces()) do + if ns:find('vim_lsp_semantic_tokens') ~= 1 then + local extmarks = vim.api.nvim_buf_get_extmarks(bufnr, nsid, 0, -1, { details = true }) + for _, extmark in ipairs(extmarks) do + extmark = { + ns_id = nsid, + ns = ns, + id = extmark[1], + row = extmark[2], + col = extmark[3], + opts = resolve_hl(extmark[4]), + } + local end_row = extmark.opts.end_row or extmark.row -- inclusive + local end_col = extmark.opts.end_col or (extmark.col + 1) -- exclusive + if + (filter.extmarks == 'all' or extmark.opts.hl_group) -- filter hl_group + and (row >= extmark.row and row <= end_row) -- within the rows of the extmark + and (row > extmark.row or col >= extmark.col) -- either not the first row, or in range of the col + and (row < end_row or col < end_col) -- either not in the last row or in range of the col + then + table.insert(results.extmarks, extmark) + end + end + end + end + end + return results +end + +---Show all the items at a given buffer position. +--- +---Can also be shown with `:Inspect`. *:Inspect* +--- +---@param bufnr? number defaults to the current buffer +---@param row? number row to inspect, 0-based. Defaults to the row of the current cursor +---@param col? number col to inspect, 0-based. Defaults to the col of the current cursor +---@param filter? InspectorFilter (table|nil) see |vim.inspect_pos()| +function vim.show_pos(bufnr, row, col, filter) + local items = vim.inspect_pos(bufnr, row, col, filter) + + local lines = { {} } + + ---@private + local function append(str, hl) + table.insert(lines[#lines], { str, hl }) + end + + ---@private + local function nl() + table.insert(lines, {}) + end + + ---@private + local function item(data, comment) + append(' - ') + append(data.hl_group, data.hl_group) + append(' ') + if data.hl_group ~= data.hl_group_link then + append('links to ', 'MoreMsg') + append(data.hl_group_link, data.hl_group_link) + append(' ') + end + if comment then + append(comment, 'Comment') + end + nl() + end + + -- treesitter + if #items.treesitter > 0 then + append('Treesitter', 'Title') + nl() + for _, capture in ipairs(items.treesitter) do + item(capture, capture.lang) + end + nl() + end + + if #items.semantic_tokens > 0 then + append('Semantic Tokens', 'Title') + nl() + for _, token in ipairs(items.semantic_tokens) do + local client = vim.lsp.get_client_by_id(token.client_id) + client = client and (' (' .. client.name .. ')') or '' + item(token.hl_groups.type, 'type' .. client) + for _, modifier in ipairs(token.hl_groups.modifiers) do + item(modifier, 'modifier' .. client) + end + end + nl() + end + + -- syntax + if #items.syntax > 0 then + append('Syntax', 'Title') + nl() + for _, syn in ipairs(items.syntax) do + item(syn) + end + nl() + end + -- extmarks + if #items.extmarks > 0 then + append('Extmarks', 'Title') + nl() + for _, extmark in ipairs(items.extmarks) do + if extmark.opts.hl_group then + item(extmark.opts, extmark.ns) + else + append(' - ') + append(extmark.ns, 'Comment') + nl() + end + end + nl() + end + + if #lines[#lines] == 0 then + table.remove(lines) + end + + local chunks = {} + for _, line in ipairs(lines) do + vim.list_extend(chunks, line) + table.insert(chunks, { '\n' }) + end + if #chunks == 0 then + chunks = { + { + 'No items found at position ' + .. items.row + .. ',' + .. items.col + .. ' in buffer ' + .. items.buffer, + }, + } + end + vim.api.nvim_echo(chunks, false, {}) +end diff --git a/runtime/lua/vim/_meta.lua b/runtime/lua/vim/_meta.lua index 9c7972873e..104f29c4c0 100644 --- a/runtime/lua/vim/_meta.lua +++ b/runtime/lua/vim/_meta.lua @@ -526,7 +526,7 @@ local function create_option_accessor(scope) return setmetatable({}, { __index = function(_, k) - return make_option(k, a.nvim_get_option_value(k, { scope = scope })) + return make_option(k, a.nvim_get_option_value(k, {})) end, __newindex = function(_, k, v) diff --git a/runtime/lua/vim/diagnostic.lua b/runtime/lua/vim/diagnostic.lua index 84091fbf0e..6fd000a029 100644 --- a/runtime/lua/vim/diagnostic.lua +++ b/runtime/lua/vim/diagnostic.lua @@ -1,7 +1,8 @@ -local if_nil = vim.F.if_nil +local api, if_nil = vim.api, vim.F.if_nil local M = {} +---@enum DiagnosticSeverity M.severity = { ERROR = 1, WARN = 2, @@ -47,11 +48,11 @@ local bufnr_and_namespace_cacher_mt = { local diagnostic_cache do - local group = vim.api.nvim_create_augroup('DiagnosticBufWipeout', {}) + local group = api.nvim_create_augroup('DiagnosticBufWipeout', {}) diagnostic_cache = setmetatable({}, { __index = function(t, bufnr) assert(bufnr > 0, 'Invalid buffer number') - vim.api.nvim_create_autocmd('BufWipeout', { + api.nvim_create_autocmd('BufWipeout', { group = group, buffer = bufnr, callback = function() @@ -245,25 +246,12 @@ end)() ---@private local function get_bufnr(bufnr) if not bufnr or bufnr == 0 then - return vim.api.nvim_get_current_buf() + return api.nvim_get_current_buf() end return bufnr end ---@private -local function is_disabled(namespace, bufnr) - local ns = M.get_namespace(namespace) - if ns.disabled then - return true - end - - if type(diagnostic_disabled[bufnr]) == 'table' then - return diagnostic_disabled[bufnr][namespace] - end - return diagnostic_disabled[bufnr] -end - ----@private local function diagnostic_lines(diagnostics) if not diagnostics then return {} @@ -299,7 +287,7 @@ end ---@private local function restore_extmarks(bufnr, last) for ns, extmarks in pairs(diagnostic_cache_extmarks[bufnr]) do - local extmarks_current = vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) + local extmarks_current = api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) local found = {} for _, extmark in ipairs(extmarks_current) do -- nvim_buf_set_lines will move any extmark to the line after the last @@ -312,7 +300,7 @@ local function restore_extmarks(bufnr, last) if not found[extmark[1]] then local opts = extmark[4] opts.id = extmark[1] - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, extmark[2], extmark[3], opts) + pcall(api.nvim_buf_set_extmark, bufnr, ns, extmark[2], extmark[3], opts) end end end @@ -322,7 +310,7 @@ end local function save_extmarks(namespace, bufnr) bufnr = get_bufnr(bufnr) if not diagnostic_attached_buffers[bufnr] then - vim.api.nvim_buf_attach(bufnr, false, { + api.nvim_buf_attach(bufnr, false, { on_lines = function(_, _, _, _, _, last) restore_extmarks(bufnr, last - 1) end, @@ -333,7 +321,7 @@ local function save_extmarks(namespace, bufnr) diagnostic_attached_buffers[bufnr] = true end diagnostic_cache_extmarks[bufnr][namespace] = - vim.api.nvim_buf_get_extmarks(bufnr, namespace, 0, -1, { details = true }) + api.nvim_buf_get_extmarks(bufnr, namespace, 0, -1, { details = true }) end local registered_autocmds = {} @@ -366,8 +354,8 @@ local function schedule_display(namespace, bufnr, args) local key = make_augroup_key(namespace, bufnr) if not registered_autocmds[key] then - local group = vim.api.nvim_create_augroup(key, { clear = true }) - vim.api.nvim_create_autocmd(insert_leave_auto_cmds, { + local group = api.nvim_create_augroup(key, { clear = true }) + api.nvim_create_autocmd(insert_leave_auto_cmds, { group = group, buffer = bufnr, callback = function() @@ -384,7 +372,7 @@ local function clear_scheduled_display(namespace, bufnr) local key = make_augroup_key(namespace, bufnr) if registered_autocmds[key] then - vim.api.nvim_del_augroup_by_name(key) + api.nvim_del_augroup_by_name(key) registered_autocmds[key] = nil end end @@ -399,7 +387,7 @@ local function get_diagnostics(bufnr, opts, clamp) -- Memoized results of buf_line_count per bufnr local buf_line_count = setmetatable({}, { __index = function(t, k) - t[k] = vim.api.nvim_buf_line_count(k) + t[k] = api.nvim_buf_line_count(k) return rawget(t, k) end, }) @@ -407,7 +395,7 @@ local function get_diagnostics(bufnr, opts, clamp) ---@private local function add(b, d) if not opts.lnum or d.lnum == opts.lnum then - if clamp and vim.api.nvim_buf_is_loaded(b) then + if clamp and api.nvim_buf_is_loaded(b) then local line_count = buf_line_count[b] - 1 if d.lnum > line_count @@ -428,32 +416,31 @@ local function get_diagnostics(bufnr, opts, clamp) end end + ---@private + local function add_all_diags(buf, diags) + for _, diagnostic in pairs(diags) do + add(buf, diagnostic) + end + end + if namespace == nil and bufnr == nil then for b, t in pairs(diagnostic_cache) do for _, v in pairs(t) do - for _, diagnostic in pairs(v) do - add(b, diagnostic) - end + add_all_diags(b, v) end end elseif namespace == nil then bufnr = get_bufnr(bufnr) for iter_namespace in pairs(diagnostic_cache[bufnr]) do - for _, diagnostic in pairs(diagnostic_cache[bufnr][iter_namespace]) do - add(bufnr, diagnostic) - end + add_all_diags(bufnr, diagnostic_cache[bufnr][iter_namespace]) end elseif bufnr == nil then for b, t in pairs(diagnostic_cache) do - for _, diagnostic in pairs(t[namespace] or {}) do - add(b, diagnostic) - end + add_all_diags(b, t[namespace] or {}) end else bufnr = get_bufnr(bufnr) - for _, diagnostic in pairs(diagnostic_cache[bufnr][namespace] or {}) do - add(bufnr, diagnostic) - end + add_all_diags(bufnr, diagnostic_cache[bufnr][namespace] or {}) end if opts.severity then @@ -471,7 +458,7 @@ local function set_list(loclist, opts) local winnr = opts.winnr or 0 local bufnr if loclist then - bufnr = vim.api.nvim_win_get_buf(winnr) + bufnr = api.nvim_win_get_buf(winnr) end -- Don't clamp line numbers since the quickfix list can already handle line -- numbers beyond the end of the buffer @@ -483,7 +470,7 @@ local function set_list(loclist, opts) vim.fn.setqflist({}, ' ', { title = title, items = items }) end if open then - vim.api.nvim_command(loclist and 'lopen' or 'botright copen') + api.nvim_command(loclist and 'lwindow' or 'botright cwindow') end end @@ -492,7 +479,7 @@ local function next_diagnostic(position, search_forward, bufnr, opts, namespace) position[1] = position[1] - 1 bufnr = get_bufnr(bufnr) local wrap = vim.F.if_nil(opts.wrap, true) - local line_count = vim.api.nvim_buf_line_count(bufnr) + local line_count = api.nvim_buf_line_count(bufnr) local diagnostics = get_diagnostics(bufnr, vim.tbl_extend('keep', opts, { namespace = namespace }), true) local line_diagnostics = diagnostic_lines(diagnostics) @@ -506,7 +493,7 @@ local function next_diagnostic(position, search_forward, bufnr, opts, namespace) lnum = (lnum + line_count) % line_count end if line_diagnostics[lnum] and not vim.tbl_isempty(line_diagnostics[lnum]) then - local line_length = #vim.api.nvim_buf_get_lines(bufnr, lnum, lnum + 1, true)[1] + local line_length = #api.nvim_buf_get_lines(bufnr, lnum, lnum + 1, true)[1] local sort_diagnostics, is_next if search_forward then sort_diagnostics = function(a, b) @@ -542,17 +529,17 @@ local function diagnostic_move_pos(opts, pos) opts = opts or {} local float = vim.F.if_nil(opts.float, true) - local win_id = opts.win_id or vim.api.nvim_get_current_win() + local win_id = opts.win_id or api.nvim_get_current_win() if not pos then - vim.api.nvim_echo({ { 'No more valid diagnostics to move to', 'WarningMsg' } }, true, {}) + api.nvim_echo({ { 'No more valid diagnostics to move to', 'WarningMsg' } }, true, {}) return end - vim.api.nvim_win_call(win_id, function() + api.nvim_win_call(win_id, function() -- Save position in the window's jumplist vim.cmd("normal! m'") - vim.api.nvim_win_set_cursor(win_id, { pos[1] + 1, pos[2] }) + api.nvim_win_set_cursor(win_id, { pos[1] + 1, pos[2] }) -- Open folds under the cursor vim.cmd('normal! zv') end) @@ -561,7 +548,7 @@ local function diagnostic_move_pos(opts, pos) local float_opts = type(float) == 'table' and float or {} vim.schedule(function() M.open_float(vim.tbl_extend('keep', float_opts, { - bufnr = vim.api.nvim_win_get_buf(win_id), + bufnr = api.nvim_win_get_buf(win_id), scope = 'cursor', focus = false, })) @@ -578,12 +565,12 @@ end --- followed by namespace configuration, and finally global configuration. --- --- For example, if a user enables virtual text globally with ---- <pre> +--- <pre>lua --- vim.diagnostic.config({ virtual_text = true }) --- </pre> --- --- and a diagnostic producer sets diagnostics with ---- <pre> +--- <pre>lua --- vim.diagnostic.set(ns, 0, diagnostics, { virtual_text = false }) --- </pre> --- @@ -613,16 +600,20 @@ end --- * spacing: (number) Amount of empty spaces inserted at the beginning --- of the virtual text. --- * prefix: (string) Prepend diagnostic message with prefix. +--- * suffix: (string or function) Append diagnostic message with suffix. +--- If a function, it must have the signature (diagnostic) -> +--- string, where {diagnostic} is of type |diagnostic-structure|. +--- This can be used to render an LSP diagnostic error code. --- * format: (function) A function that takes a diagnostic as input and --- returns a string. The return value is the text used to display --- the diagnostic. Example: ---- <pre> ---- function(diagnostic) ---- if diagnostic.severity == vim.diagnostic.severity.ERROR then ---- return string.format("E: %s", diagnostic.message) +--- <pre>lua +--- function(diagnostic) +--- if diagnostic.severity == vim.diagnostic.severity.ERROR then +--- return string.format("E: %s", diagnostic.message) +--- end +--- return diagnostic.message --- end ---- return diagnostic.message ---- end --- </pre> --- - signs: (default true) Use signs for diagnostics. Options: --- * severity: Only show signs for diagnostics matching the given severity @@ -666,13 +657,13 @@ function M.config(opts, namespace) if namespace then for bufnr, v in pairs(diagnostic_cache) do - if vim.api.nvim_buf_is_loaded(bufnr) and v[namespace] then + if api.nvim_buf_is_loaded(bufnr) and v[namespace] then M.show(namespace, bufnr) end end else for bufnr, v in pairs(diagnostic_cache) do - if vim.api.nvim_buf_is_loaded(bufnr) then + if api.nvim_buf_is_loaded(bufnr) then for ns in pairs(v) do M.show(ns, bufnr) end @@ -707,11 +698,11 @@ function M.set(namespace, bufnr, diagnostics, opts) set_diagnostic_cache(namespace, bufnr, diagnostics) end - if vim.api.nvim_buf_is_loaded(bufnr) then + if api.nvim_buf_is_loaded(bufnr) then M.show(namespace, bufnr, nil, opts) end - vim.api.nvim_exec_autocmds('DiagnosticChanged', { + api.nvim_exec_autocmds('DiagnosticChanged', { modeline = false, buffer = bufnr, data = { diagnostics = diagnostics }, @@ -726,7 +717,7 @@ function M.get_namespace(namespace) vim.validate({ namespace = { namespace, 'n' } }) if not all_namespaces[namespace] then local name - for k, v in pairs(vim.api.nvim_get_namespaces()) do + for k, v in pairs(api.nvim_get_namespaces()) do if namespace == v then name = k break @@ -751,6 +742,18 @@ function M.get_namespaces() return vim.deepcopy(all_namespaces) end +---@class Diagnostic +---@field buffer number +---@field lnum number 0-indexed +---@field end_lnum nil|number 0-indexed +---@field col number 0-indexed +---@field end_col nil|number 0-indexed +---@field severity DiagnosticSeverity +---@field message string +---@field source nil|string +---@field code nil|string +---@field user_data nil|any arbitrary data plugins can add + --- Get current diagnostics. --- ---@param bufnr number|nil Buffer number to get diagnostics from. Use 0 for @@ -759,7 +762,7 @@ end --- - namespace: (number) Limit diagnostics to the given namespace. --- - lnum: (number) Limit diagnostics to the given line number. --- - severity: See |diagnostic-severity|. ----@return table A list of diagnostic items |diagnostic-structure|. +---@return Diagnostic[] table A list of diagnostic items |diagnostic-structure|. function M.get(bufnr, opts) vim.validate({ bufnr = { bufnr, 'n', true }, @@ -771,22 +774,23 @@ end --- Get the previous diagnostic closest to the cursor position. --- ----@param opts table See |vim.diagnostic.goto_next()| ----@return table Previous diagnostic +---@param opts nil|table See |vim.diagnostic.goto_next()| +---@return Diagnostic|nil Previous diagnostic function M.get_prev(opts) opts = opts or {} - local win_id = opts.win_id or vim.api.nvim_get_current_win() - local bufnr = vim.api.nvim_win_get_buf(win_id) - local cursor_position = opts.cursor_position or vim.api.nvim_win_get_cursor(win_id) + local win_id = opts.win_id or api.nvim_get_current_win() + local bufnr = api.nvim_win_get_buf(win_id) + local cursor_position = opts.cursor_position or api.nvim_win_get_cursor(win_id) return next_diagnostic(cursor_position, false, bufnr, opts, opts.namespace) end --- Return the position of the previous diagnostic in the current buffer. --- ----@param opts table See |vim.diagnostic.goto_next()| ----@return table Previous diagnostic position as a (row, col) tuple. +---@param opts table|nil See |vim.diagnostic.goto_next()| +---@return table|false Previous diagnostic position as a (row, col) tuple or false if there is no +--- prior diagnostic function M.get_prev_pos(opts) local prev = M.get_prev(opts) if not prev then @@ -797,29 +801,30 @@ function M.get_prev_pos(opts) end --- Move to the previous diagnostic in the current buffer. ----@param opts table See |vim.diagnostic.goto_next()| +---@param opts table|nil See |vim.diagnostic.goto_next()| function M.goto_prev(opts) return diagnostic_move_pos(opts, M.get_prev_pos(opts)) end --- Get the next diagnostic closest to the cursor position. --- ----@param opts table See |vim.diagnostic.goto_next()| ----@return table Next diagnostic +---@param opts table|nil See |vim.diagnostic.goto_next()| +---@return Diagnostic|nil Next diagnostic function M.get_next(opts) opts = opts or {} - local win_id = opts.win_id or vim.api.nvim_get_current_win() - local bufnr = vim.api.nvim_win_get_buf(win_id) - local cursor_position = opts.cursor_position or vim.api.nvim_win_get_cursor(win_id) + local win_id = opts.win_id or api.nvim_get_current_win() + local bufnr = api.nvim_win_get_buf(win_id) + local cursor_position = opts.cursor_position or api.nvim_win_get_cursor(win_id) return next_diagnostic(cursor_position, true, bufnr, opts, opts.namespace) end --- Return the position of the next diagnostic in the current buffer. --- ----@param opts table See |vim.diagnostic.goto_next()| ----@return table Next diagnostic position as a (row, col) tuple. +---@param opts table|nil See |vim.diagnostic.goto_next()| +---@return table|false Next diagnostic position as a (row, col) tuple or false if no next +--- diagnostic. function M.get_next_pos(opts) local next = M.get_next(opts) if not next then @@ -903,7 +908,7 @@ M.handlers.signs = { end, hide = function(namespace, bufnr) local ns = M.get_namespace(namespace) - if ns.user_data.sign_group then + if ns.user_data.sign_group and api.nvim_buf_is_valid(bufnr) then vim.fn.sign_unplace(ns.user_data.sign_group, { buffer = bufnr }) end end, @@ -931,7 +936,7 @@ M.handlers.underline = { local ns = M.get_namespace(namespace) if not ns.user_data.underline_ns then - ns.user_data.underline_ns = vim.api.nvim_create_namespace('') + ns.user_data.underline_ns = api.nvim_create_namespace('') end local underline_ns = ns.user_data.underline_ns @@ -958,7 +963,9 @@ M.handlers.underline = { local ns = M.get_namespace(namespace) if ns.user_data.underline_ns then diagnostic_cache_extmarks[bufnr][ns.user_data.underline_ns] = {} - vim.api.nvim_buf_clear_namespace(bufnr, ns.user_data.underline_ns, 0, -1) + if api.nvim_buf_is_valid(bufnr) then + api.nvim_buf_clear_namespace(bufnr, ns.user_data.underline_ns, 0, -1) + end end end, } @@ -997,7 +1004,7 @@ M.handlers.virtual_text = { local ns = M.get_namespace(namespace) if not ns.user_data.virt_text_ns then - ns.user_data.virt_text_ns = vim.api.nvim_create_namespace('') + ns.user_data.virt_text_ns = api.nvim_create_namespace('') end local virt_text_ns = ns.user_data.virt_text_ns @@ -1009,7 +1016,7 @@ M.handlers.virtual_text = { local virt_texts = M._get_virt_text_chunks(line_diagnostics, opts.virtual_text) if virt_texts then - vim.api.nvim_buf_set_extmark(bufnr, virt_text_ns, line, 0, { + api.nvim_buf_set_extmark(bufnr, virt_text_ns, line, 0, { hl_mode = 'combine', virt_text = virt_texts, }) @@ -1021,7 +1028,9 @@ M.handlers.virtual_text = { local ns = M.get_namespace(namespace) if ns.user_data.virt_text_ns then diagnostic_cache_extmarks[bufnr][ns.user_data.virt_text_ns] = {} - vim.api.nvim_buf_clear_namespace(bufnr, ns.user_data.virt_text_ns, 0, -1) + if api.nvim_buf_is_valid(bufnr) then + api.nvim_buf_clear_namespace(bufnr, ns.user_data.virt_text_ns, 0, -1) + end end end, } @@ -1039,6 +1048,7 @@ function M._get_virt_text_chunks(line_diags, opts) opts = opts or {} local prefix = opts.prefix or 'â– ' + local suffix = opts.suffix or '' local spacing = opts.spacing or 4 -- Create a little more space between virtual text and contents @@ -1052,8 +1062,11 @@ function M._get_virt_text_chunks(line_diags, opts) -- TODO(tjdevries): Allow different servers to be shown first somehow? -- TODO(tjdevries): Display server name associated with these? if last.message then + if type(suffix) == 'function' then + suffix = suffix(last) or '' + end table.insert(virt_texts, { - string.format('%s %s', prefix, last.message:gsub('\r', ''):gsub('\n', ' ')), + string.format('%s %s%s', prefix, last.message:gsub('\r', ''):gsub('\n', ' '), suffix), virtual_text_highlight_map[last.severity], }) @@ -1093,6 +1106,27 @@ function M.hide(namespace, bufnr) end end +--- Check whether diagnostics are disabled in a given buffer. +--- +---@param bufnr number|nil Buffer number, or 0 for current buffer. +---@param namespace number|nil Diagnostic namespace. When omitted, checks if +--- all diagnostics are disabled in {bufnr}. +--- Otherwise, only checks if diagnostics from +--- {namespace} are disabled. +---@return boolean +function M.is_disabled(bufnr, namespace) + bufnr = get_bufnr(bufnr) + if namespace and M.get_namespace(namespace).disabled then + return true + end + + if type(diagnostic_disabled[bufnr]) == 'table' then + return diagnostic_disabled[bufnr][namespace] + end + + return diagnostic_disabled[bufnr] ~= nil +end + --- Display diagnostics for the given namespace and buffer. --- ---@param namespace number|nil Diagnostic namespace. When omitted, show @@ -1136,7 +1170,7 @@ function M.show(namespace, bufnr, diagnostics, opts) return end - if is_disabled(namespace, bufnr) then + if M.is_disabled(bufnr, namespace) then return end @@ -1153,7 +1187,7 @@ function M.show(namespace, bufnr, diagnostics, opts) if opts.update_in_insert then clear_scheduled_display(namespace, bufnr) else - local mode = vim.api.nvim_get_mode() + local mode = api.nvim_get_mode() if string.sub(mode.mode, 1, 1) == 'i' then schedule_display(namespace, bufnr, opts) return @@ -1220,7 +1254,9 @@ end --- string, it is prepended to each diagnostic in the window with no --- highlight. --- Overrides the setting from |vim.diagnostic.config()|. ----@return tuple ({float_bufnr}, {win_id}) +--- - suffix: Same as {prefix}, but appends the text to the diagnostic instead of +--- prepending it. Overrides the setting from |vim.diagnostic.config()|. +---@return number|nil, number|nil: ({float_bufnr}, {win_id}) function M.open_float(opts, ...) -- Support old (bufnr, opts) signature local bufnr @@ -1251,7 +1287,7 @@ function M.open_float(opts, ...) local lnum, col if scope == 'line' or scope == 'cursor' then if not opts.pos then - local pos = vim.api.nvim_win_get_cursor(0) + local pos = api.nvim_win_get_cursor(0) lnum = pos[1] - 1 col = pos[2] elseif type(opts.pos) == 'number' then @@ -1273,7 +1309,7 @@ function M.open_float(opts, ...) end, diagnostics) elseif scope == 'cursor' then -- LSP servers can send diagnostics with `end_col` past the length of the line - local line_length = #vim.api.nvim_buf_get_lines(bufnr, lnum, lnum + 1, true)[1] + local line_length = #api.nvim_buf_get_lines(bufnr, lnum, lnum + 1, true)[1] diagnostics = vim.tbl_filter(function(d) return d.lnum == lnum and math.min(d.col, line_length - 1) <= col @@ -1305,9 +1341,7 @@ function M.open_float(opts, ...) vim.validate({ header = { header, - function(v) - return type(v) == 'string' or type(v) == 'table' - end, + { 'string', 'table' }, "'string' or 'table'", }, }) @@ -1315,11 +1349,11 @@ function M.open_float(opts, ...) -- Don't insert any lines for an empty string if string.len(if_nil(header[1], '')) > 0 then table.insert(lines, header[1]) - table.insert(highlights, { 0, header[2] or 'Bold' }) + table.insert(highlights, { hlname = header[2] or 'Bold' }) end elseif #header > 0 then table.insert(lines, header) - table.insert(highlights, { 0, 'Bold' }) + table.insert(highlights, { hlname = 'Bold' }) end end @@ -1341,9 +1375,7 @@ function M.open_float(opts, ...) vim.validate({ prefix = { prefix_opt, - function(v) - return type(v) == 'string' or type(v) == 'table' or type(v) == 'function' - end, + { 'string', 'table', 'function' }, "'string' or 'table' or 'function'", }, }) @@ -1354,18 +1386,52 @@ function M.open_float(opts, ...) end end + local suffix_opt = if_nil(opts.suffix, function(diagnostic) + return diagnostic.code and string.format(' [%s]', diagnostic.code) or '' + end) + + local suffix, suffix_hl_group + if suffix_opt then + vim.validate({ + suffix = { + suffix_opt, + { 'string', 'table', 'function' }, + "'string' or 'table' or 'function'", + }, + }) + if type(suffix_opt) == 'string' then + suffix, suffix_hl_group = suffix_opt, 'NormalFloat' + elseif type(suffix_opt) == 'table' then + suffix, suffix_hl_group = suffix_opt[1] or '', suffix_opt[2] or 'NormalFloat' + end + end + for i, diagnostic in ipairs(diagnostics) do if prefix_opt and type(prefix_opt) == 'function' then prefix, prefix_hl_group = prefix_opt(diagnostic, i, #diagnostics) prefix, prefix_hl_group = prefix or '', prefix_hl_group or 'NormalFloat' end + if suffix_opt and type(suffix_opt) == 'function' then + suffix, suffix_hl_group = suffix_opt(diagnostic, i, #diagnostics) + suffix, suffix_hl_group = suffix or '', suffix_hl_group or 'NormalFloat' + end local hiname = floating_highlight_map[diagnostic.severity] local message_lines = vim.split(diagnostic.message, '\n') - table.insert(lines, prefix .. message_lines[1]) - table.insert(highlights, { #prefix, hiname, prefix_hl_group }) - for j = 2, #message_lines do - table.insert(lines, string.rep(' ', #prefix) .. message_lines[j]) - table.insert(highlights, { 0, hiname }) + for j = 1, #message_lines do + local pre = j == 1 and prefix or string.rep(' ', #prefix) + local suf = j == #message_lines and suffix or '' + table.insert(lines, pre .. message_lines[j] .. suf) + table.insert(highlights, { + hlname = hiname, + prefix = { + length = j == 1 and #prefix or 0, + hlname = prefix_hl_group, + }, + suffix = { + length = j == #message_lines and #suffix or 0, + hlname = suffix_hl_group, + }, + }) end end @@ -1374,12 +1440,17 @@ function M.open_float(opts, ...) opts.focus_id = scope end local float_bufnr, winnr = require('vim.lsp.util').open_floating_preview(lines, 'plaintext', opts) - for i, hi in ipairs(highlights) do - local prefixlen, hiname, prefix_hiname = unpack(hi) - if prefix_hiname then - vim.api.nvim_buf_add_highlight(float_bufnr, -1, prefix_hiname, i - 1, 0, prefixlen) + for i, hl in ipairs(highlights) do + local line = lines[i] + local prefix_len = hl.prefix and hl.prefix.length or 0 + local suffix_len = hl.suffix and hl.suffix.length or 0 + if prefix_len > 0 then + api.nvim_buf_add_highlight(float_bufnr, -1, hl.prefix.hlname, i - 1, 0, prefix_len) + end + api.nvim_buf_add_highlight(float_bufnr, -1, hl.hlname, i - 1, prefix_len, #line - suffix_len) + if suffix_len > 0 then + api.nvim_buf_add_highlight(float_bufnr, -1, hl.suffix.hlname, i - 1, #line - suffix_len, -1) end - vim.api.nvim_buf_add_highlight(float_bufnr, -1, hiname, i - 1, prefixlen, -1) end return float_bufnr, winnr @@ -1410,11 +1481,15 @@ function M.reset(namespace, bufnr) M.hide(iter_namespace, iter_bufnr) end - vim.api.nvim_exec_autocmds('DiagnosticChanged', { - modeline = false, - buffer = iter_bufnr, - data = { diagnostics = {} }, - }) + if api.nvim_buf_is_valid(iter_bufnr) then + api.nvim_exec_autocmds('DiagnosticChanged', { + modeline = false, + buffer = iter_bufnr, + data = { diagnostics = {} }, + }) + else + diagnostic_cache[iter_bufnr] = nil + end end end @@ -1517,11 +1592,11 @@ end --- --- This can be parsed into a diagnostic |diagnostic-structure| --- with: ---- <pre> ---- local s = "WARNING filename:27:3: Variable 'foo' does not exist" ---- local pattern = "^(%w+) %w+:(%d+):(%d+): (.+)$" ---- local groups = { "severity", "lnum", "col", "message" } ---- vim.diagnostic.match(s, pattern, groups, { WARNING = vim.diagnostic.WARN }) +--- <pre>lua +--- local s = "WARNING filename:27:3: Variable 'foo' does not exist" +--- local pattern = "^(%w+) %w+:(%d+):(%d+): (.+)$" +--- local groups = { "severity", "lnum", "col", "message" } +--- vim.diagnostic.match(s, pattern, groups, { WARNING = vim.diagnostic.WARN }) --- </pre> --- ---@param str string String to parse diagnostics from. @@ -1533,7 +1608,7 @@ end ---@param defaults table|nil Table of default values for any fields not listed in {groups}. --- When omitted, numeric values default to 0 and "severity" defaults to --- ERROR. ----@return diagnostic |diagnostic-structure| or `nil` if {pat} fails to match {str}. +---@return Diagnostic|nil: |diagnostic-structure| or `nil` if {pat} fails to match {str}. function M.match(str, pat, groups, severity_map, defaults) vim.validate({ str = { str, 's' }, @@ -1580,7 +1655,7 @@ local errlist_type_map = { --- passed to |setqflist()| or |setloclist()|. --- ---@param diagnostics table List of diagnostics |diagnostic-structure|. ----@return array of quickfix list items |setqflist-what| +---@return table[] of quickfix list items |setqflist-what| function M.toqflist(diagnostics) vim.validate({ diagnostics = { @@ -1605,7 +1680,11 @@ function M.toqflist(diagnostics) end table.sort(list, function(a, b) if a.bufnr == b.bufnr then - return a.lnum < b.lnum + if a.lnum == b.lnum then + return a.col < b.col + else + return a.lnum < b.lnum + end else return a.bufnr < b.bufnr end @@ -1617,7 +1696,7 @@ end --- ---@param list table A list of quickfix items from |getqflist()| or --- |getloclist()|. ----@return array of diagnostics |diagnostic-structure| +---@return Diagnostic[] array of |diagnostic-structure| function M.fromqflist(list) vim.validate({ list = { diff --git a/runtime/lua/vim/filetype.lua b/runtime/lua/vim/filetype.lua index e204fe8ae2..9293c828b8 100644 --- a/runtime/lua/vim/filetype.lua +++ b/runtime/lua/vim/filetype.lua @@ -170,7 +170,7 @@ local extension = { return require('vim.filetype.detect').bindzone(bufnr, 'dcl') end, db = function(path, bufnr) - return require('vim.filetype.detect').bindzone(bufnr, '') + return require('vim.filetype.detect').bindzone(bufnr) end, bicep = 'bicep', bb = 'bitbake', @@ -190,6 +190,7 @@ local extension = { BUILD = 'bzl', qc = 'c', cabal = 'cabal', + capnp = 'capnp', cdl = 'cdl', toc = 'cdrtoc', cfc = 'cf', @@ -257,6 +258,7 @@ local extension = { cc = function(path, bufnr) return vim.g.cynlib_syntax_for_cc and 'cynlib' or 'cpp' end, + cql = 'cqlang', crm = 'crm', csx = 'cs', cs = 'cs', @@ -326,11 +328,7 @@ local extension = { end, eex = 'eelixir', leex = 'eelixir', - am = function(path, bufnr) - if not path:lower():find('makefile%.am$') then - return 'elf' - end - end, + am = 'elf', exs = 'elixir', elm = 'elm', elv = 'elvish', @@ -418,6 +416,7 @@ local extension = { fs = function(path, bufnr) return require('vim.filetype.detect').fs(bufnr) end, + fsh = 'fsh', fsi = 'fsharp', fsx = 'fsharp', fusion = 'fusion', @@ -555,6 +554,7 @@ local extension = { jov = 'jovial', jovial = 'jovial', properties = 'jproperties', + jq = 'jq', slnf = 'json', json = 'json', jsonp = 'json', @@ -564,7 +564,7 @@ local extension = { json5 = 'json5', jsonc = 'jsonc', jsonnet = 'jsonnet', - libjsonnet = 'jsonnet', + libsonnet = 'jsonnet', jsp = 'jsp', jl = 'julia', kv = 'kivy', @@ -611,7 +611,9 @@ local extension = { c = function(path, bufnr) return require('vim.filetype.detect').lpc(bufnr) end, - lsl = 'lsl', + lsl = function(path, bufnr) + return require('vim.filetype.detect').lsl(bufnr) + end, lss = 'lss', nse = 'lua', rockspec = 'lua', @@ -656,6 +658,9 @@ local extension = { dmt = 'maxima', wxm = 'maxima', mel = 'mel', + mmd = 'mermaid', + mmdc = 'mermaid', + mermaid = 'mermaid', mf = 'mf', mgl = 'mgl', mgp = 'mgp', @@ -674,6 +679,7 @@ local extension = { DEF = 'modula2', ['m2'] = 'modula2', mi = 'modula2', + lm3 = 'modula3', ssc = 'monk', monk = 'monk', tsc = 'monk', @@ -722,6 +728,10 @@ local extension = { nsi = 'nsis', nsh = 'nsis', obj = 'obj', + obl = 'obse', + obse = 'obse', + oblivion = 'obse', + obscript = 'obse', mlt = 'ocaml', mly = 'ocaml', mll = 'ocaml', @@ -735,6 +745,7 @@ local extension = { opam = 'opam', ['or'] = 'openroad', scad = 'openscad', + ovpn = 'openvpn', ora = 'ora', org = 'org', org_archive = 'org', @@ -935,12 +946,14 @@ local extension = { ice = 'slice', score = 'slrnsc', sol = 'solidity', + smali = 'smali', tpl = 'smarty', ihlp = 'smcl', smcl = 'smcl', hlp = 'smcl', smith = 'smith', smt = 'smith', + smithy = 'smithy', sml = 'sml', spt = 'snobol4', sno = 'snobol4', @@ -1009,6 +1022,7 @@ local extension = { texinfo = 'texinfo', text = 'text', tfvars = 'terraform-vars', + thrift = 'thrift', tla = 'tla', tli = 'tli', toml = 'toml', @@ -1053,6 +1067,7 @@ local extension = { hdl = 'vhdl', vho = 'vhdl', vbe = 'vhdl', + tape = 'vhs', vim = 'vim', vba = 'vim', mar = 'vmasm', @@ -1062,6 +1077,7 @@ local extension = { vue = 'vue', wat = 'wast', wast = 'wast', + wdl = 'wdl', wm = 'webmacro', wbt = 'winbatch', wml = 'wml', @@ -1108,6 +1124,7 @@ local extension = { yang = 'yang', ['z8a'] = 'z8a', zig = 'zig', + zir = 'zir', zu = 'zimbu', zut = 'zimbutempl', zsh = 'zsh', @@ -1306,6 +1323,7 @@ local filename = { ['GNUmakefile.am'] = 'automake', ['named.root'] = 'bindzone', WORKSPACE = 'bzl', + ['WORKSPACE.bzlmod'] = 'bzl', BUILD = 'bzl', ['cabal.project'] = 'cabalproject', ['cabal.config'] = 'cabalconfig', @@ -1361,13 +1379,13 @@ local filename = { npmrc = 'dosini', ['/etc/yum.conf'] = 'dosini', ['.npmrc'] = 'dosini', - ['.editorconfig'] = 'dosini', ['/etc/pacman.conf'] = 'confini', ['mpv.conf'] = 'confini', dune = 'dune', jbuild = 'dune', ['dune-workspace'] = 'dune', ['dune-project'] = 'dune', + ['.editorconfig'] = 'editorconfig', ['elinks.conf'] = 'elinks', ['mix.lock'] = 'elixir', ['filter-rules'] = 'elmfilt', @@ -1410,6 +1428,7 @@ local filename = { gnashpluginrc = 'gnash', gnashrc = 'gnash', ['.gnuplot'] = 'gnuplot', + ['go.sum'] = 'gosum', ['go.work'] = 'gowork', ['.gprc'] = 'gp', ['/.gnupg/gpg.conf'] = 'gpg', @@ -1439,11 +1458,15 @@ local filename = { ['ipf.conf'] = 'ipfilter', ['ipf6.conf'] = 'ipfilter', ['ipf.rules'] = 'ipfilter', - ['.eslintrc'] = 'json', - ['.babelrc'] = 'json', ['Pipfile.lock'] = 'json', ['.firebaserc'] = 'json', ['.prettierrc'] = 'json', + ['.babelrc'] = 'jsonc', + ['.eslintrc'] = 'jsonc', + ['.hintrc'] = 'jsonc', + ['.jsfmtrc'] = 'jsonc', + ['.jshintrc'] = 'jsonc', + ['.swrc'] = 'jsonc', Kconfig = 'kconfig', ['Kconfig.debug'] = 'kconfig', ['lftp.conf'] = 'lftp', @@ -1458,6 +1481,9 @@ local filename = { ['.sawfishrc'] = 'lisp', ['/etc/login.access'] = 'loginaccess', ['/etc/login.defs'] = 'logindefs', + ['.lsl'] = function(path, bufnr) + return require('vim.filetype.detect').lsl(bufnr) + end, ['.luacheckrc'] = 'lua', ['lynx.cfg'] = 'lynx', ['m3overrides'] = 'm3build', @@ -1508,6 +1534,7 @@ local filename = { ['.latexmkrc'] = 'perl', ['pf.conf'] = 'pf', ['main.cf'] = 'pfmain', + ['main.cf.proto'] = 'pfmain', pinerc = 'pine', ['.pinercex'] = 'pine', ['.pinerc'] = 'pine', @@ -1540,6 +1567,9 @@ local filename = { ['.pythonstartup'] = 'python', ['.pythonrc'] = 'python', SConstruct = 'python', + ['.Rprofile'] = 'r', + ['Rprofile'] = 'r', + ['Rprofile.site'] = 'r', ratpoisonrc = 'ratpoison', ['.ratpoisonrc'] = 'ratpoison', inputrc = 'readline', @@ -1666,6 +1696,8 @@ local filename = { fglrxrc = 'xml', ['/etc/blkid.tab'] = 'xml', ['/etc/blkid.tab.old'] = 'xml', + ['.clang-format'] = 'yaml', + ['.clang-tidy'] = 'yaml', ['/etc/zprofile'] = 'zsh', ['.zlogin'] = 'zsh', ['.zlogout'] = 'zsh', @@ -1779,6 +1811,8 @@ local pattern = { ['.*/etc/DIR_COLORS'] = 'dircolors', ['.*/etc/dnsmasq%.conf'] = 'dnsmasq', ['php%.ini%-.*'] = 'dosini', + ['.*/%.aws/config'] = 'confini', + ['.*/%.aws/credentials'] = 'confini', ['.*/etc/pacman%.conf'] = 'confini', ['.*/etc/yum%.conf'] = 'dosini', ['.*lvs'] = 'dracula', @@ -1885,7 +1919,9 @@ local pattern = { ['Prl.*%..*'] = starsetf('jam'), ['.*%.properties_..'] = 'jproperties', ['.*%.properties_.._..'] = 'jproperties', + ['org%.eclipse%..*%.prefs'] = 'jproperties', ['.*%.properties_.._.._.*'] = starsetf('jproperties'), + ['[jt]sconfig.*%.json'] = 'jsonc', ['Kconfig%..*'] = starsetf('kconfig'), ['.*%.[Ss][Uu][Bb]'] = 'krl', ['lilo%.conf.*'] = starsetf('lilo'), @@ -2039,6 +2075,7 @@ local pattern = { ['.*%.ml%.cppo'] = 'ocaml', ['.*%.mli%.cppo'] = 'ocaml', ['.*%.opam%.template'] = 'opam', + ['.*/openvpn/.*/.*%.conf'] = 'openvpn', ['.*%.[Oo][Pp][Ll]'] = 'opl', ['.*/etc/pam%.conf'] = 'pamconf', ['.*/etc/pam%.d/.*'] = starsetf('pamconf'), @@ -2281,10 +2318,8 @@ end --- --- See $VIMRUNTIME/lua/vim/filetype.lua for more examples. --- ---- Note that Lua filetype detection is disabled when |g:do_legacy_filetype| is set. ---- --- Example: ---- <pre> +--- <pre>lua --- vim.filetype.add({ --- extension = { --- foo = 'fooscript', @@ -2319,8 +2354,8 @@ end --- }) --- </pre> --- ---- To add a fallback match on contents (see |new-filetype-scripts|), use ---- <pre> +--- To add a fallback match on contents, use +--- <pre>lua --- vim.filetype.add { --- pattern = { --- ['.*'] = { @@ -2432,7 +2467,7 @@ end --- Each of the three options is specified using a key to the single argument of this function. --- Example: --- ---- <pre> +--- <pre>lua --- -- Using a buffer number --- vim.filetype.match({ buf = 42 }) --- diff --git a/runtime/lua/vim/filetype/detect.lua b/runtime/lua/vim/filetype/detect.lua index 7fc7f1b7ca..edffdde9c7 100644 --- a/runtime/lua/vim/filetype/detect.lua +++ b/runtime/lua/vim/filetype/detect.lua @@ -181,7 +181,7 @@ function M.cls(bufnr) return vim.g.filetype_cls end local line = getlines(bufnr, 1) - if line:find('^%%') then + if line:find('^[%%\\]') then return 'tex' elseif line:find('^#') and line:lower():find('rexx') then return 'rexx' @@ -634,6 +634,19 @@ function M.lpc(bufnr) return 'c' end +function M.lsl(bufnr) + if vim.g.filetype_lsl then + return vim.g.filetype_lsl + end + + local line = nextnonblank(bufnr, 1) + if findany(line, { '^%s*%%', ':%s*trait%s*$' }) then + return 'larch' + else + return 'lsl' + end +end + function M.m(bufnr) if vim.g.filetype_m then return vim.g.filetype_m @@ -1084,11 +1097,10 @@ function M.sc(bufnr) for _, line in ipairs(getlines(bufnr, 1, 25)) do if findany(line, { - '[A-Za-z0-9]*%s:%s[A-Za-z0-9]', 'var%s<', 'classvar%s<', '%^this.*', - '|%w*|', + '|%w+|', '%+%s%w*%s{', '%*ar%s', }) @@ -1153,13 +1165,14 @@ function M.sh(path, contents, name) vim.b[b].is_bash = nil vim.b[b].is_sh = nil end - elseif vim.g.bash_is_sh or matchregex(name, [[\<bash\>]]) or matchregex(name, [[\<bash2\>]]) then + elseif vim.g.bash_is_sh or matchregex(name, [[\<\(bash\|bash2\)\>]]) then on_detect = function(b) vim.b[b].is_bash = 1 vim.b[b].is_kornshell = nil vim.b[b].is_sh = nil end - elseif matchregex(name, [[\<sh\>]]) then + -- Ubuntu links sh to dash + elseif matchregex(name, [[\<\(sh\|dash\)\>]]) then on_detect = function(b) vim.b[b].is_sh = 1 vim.b[b].is_kornshell = nil @@ -1243,17 +1256,15 @@ end -- 2. Check the first 1000 non-comment lines for LaTeX or ConTeXt keywords. -- 3. Default to "plain" or to g:tex_flavor, can be set in user's vimrc. function M.tex(path, bufnr) - local format = getlines(bufnr, 1):find('^%%&%s*(%a+)') - if format then + local matched, _, format = getlines(bufnr, 1):find('^%%&%s*(%a+)') + if matched then format = format:lower():gsub('pdf', '', 1) - if format == 'tex' then - return 'tex' - elseif format == 'plaintex' then - return 'plaintex' - end elseif path:lower():find('tex/context/.*/.*%.tex') then return 'context' else + -- Default value, may be changed later: + format = vim.g.tex_flavor or 'plaintex' + local lpat = [[documentclass\>\|usepackage\>\|begin{\|newcommand\>\|renewcommand\>]] local cpat = [[start\a\+\|setup\a\+\|usemodule\|enablemode\|enableregime\|setvariables\|useencoding\|usesymbols\|stelle\a\+\|verwende\a\+\|stel\a\+\|gebruik\a\+\|usa\a\+\|imposta\a\+\|regle\a\+\|utilisemodule\>]] @@ -1262,26 +1273,25 @@ function M.tex(path, bufnr) -- Find first non-comment line if not l:find('^%s*%%%S') then -- Check the next thousand lines for a LaTeX or ConTeXt keyword. - for _, line in ipairs(getlines(bufnr, i + 1, i + 1000)) do - local lpat_match, cpat_match = - matchregex(line, [[\c^\s*\\\%(]] .. lpat .. [[\)\|^\s*\\\(]] .. cpat .. [[\)]]) - if lpat_match then + for _, line in ipairs(getlines(bufnr, i, i + 1000)) do + if matchregex(line, [[\c^\s*\\\%(]] .. lpat .. [[\)]]) then return 'tex' - elseif cpat_match then + elseif matchregex(line, [[\c^\s*\\\%(]] .. cpat .. [[\)]]) then return 'context' end end end end - -- TODO: add AMSTeX, RevTex, others? - if not vim.g.tex_flavor or vim.g.tex_flavor == 'plain' then - return 'plaintex' - elseif vim.g.tex_flavor == 'context' then - return 'context' - else - -- Probably LaTeX - return 'tex' - end + end -- if matched + + -- Translation from formats to file types. TODO: add AMSTeX, RevTex, others? + if format == 'plain' then + return 'plaintex' + elseif format == 'plaintex' or format == 'context' then + return format + else + -- Probably LaTeX + return 'tex' end end @@ -1447,8 +1457,8 @@ local function match_from_hashbang(contents, path) name = 'wish' end - if matchregex(name, [[^\(bash\d*\|\|ksh\d*\|sh\)\>]]) then - -- Bourne-like shell scripts: bash bash2 ksh ksh93 sh + if matchregex(name, [[^\(bash\d*\|dash\|ksh\d*\|sh\)\>]]) then + -- Bourne-like shell scripts: bash bash2 dash ksh ksh93 sh return require('vim.filetype.detect').sh(path, contents, first_line) elseif matchregex(name, [[^csh\>]]) then return require('vim.filetype.detect').shell(path, contents, vim.g.filetype_csh or 'csh') diff --git a/runtime/lua/vim/fs.lua b/runtime/lua/vim/fs.lua index 7bd635d8b6..a0d2c4c339 100644 --- a/runtime/lua/vim/fs.lua +++ b/runtime/lua/vim/fs.lua @@ -1,9 +1,11 @@ local M = {} +local iswin = vim.loop.os_uname().sysname == 'Windows_NT' + --- Iterate over all the parents of the given file or directory. --- --- Example: ---- <pre> +--- <pre>lua --- local root_dir --- for dir in vim.fs.parents(vim.api.nvim_buf_get_name(0)) do --- if vim.fn.isdirectory(dir .. "/.git") == 1 then @@ -40,7 +42,19 @@ function M.dirname(file) if file == nil then return nil end - return vim.fn.fnamemodify(file, ':h') + vim.validate({ file = { file, 's' } }) + if iswin and file:match('^%w:[\\/]?$') then + return (file:gsub('\\', '/')) + elseif not file:match('[\\/]') then + return '.' + elseif file == '/' or file:match('^/[^/]+$') then + return '/' + end + local dir = file:match('[/\\]$') and file:sub(1, #file - 1) or file:match('^([/\\]?.+)[/\\]') + if iswin and dir:match('^%w:$') then + return dir .. '/' + end + return (dir:gsub('\\', '/')) end --- Return the basename of the given file or directory @@ -48,21 +62,75 @@ end ---@param file (string) File or directory ---@return (string) Basename of {file} function M.basename(file) - return vim.fn.fnamemodify(file, ':t') + if file == nil then + return nil + end + vim.validate({ file = { file, 's' } }) + if iswin and file:match('^%w:[\\/]?$') then + return '' + end + return file:match('[/\\]$') and '' or (file:match('[^\\/]*$'):gsub('\\', '/')) +end + +---@private +local function join_paths(...) + return (table.concat({ ... }, '/'):gsub('//+', '/')) end --- Return an iterator over the files and directories located in {path} --- ---@param path (string) An absolute or relative path to the directory to iterate --- over. The path is first normalized |vim.fs.normalize()|. +--- @param opts table|nil Optional keyword arguments: +--- - depth: integer|nil How deep the traverse (default 1) +--- - skip: (fun(dir_name: string): boolean)|nil Predicate +--- to control traversal. Return false to stop searching the current directory. +--- Only useful when depth > 1 +--- ---@return Iterator over files and directories in {path}. Each iteration yields --- two values: name and type. Each "name" is the basename of the file or --- directory relative to {path}. Type is one of "file" or "directory". -function M.dir(path) - return function(fs) - return vim.loop.fs_scandir_next(fs) - end, - vim.loop.fs_scandir(M.normalize(path)) +function M.dir(path, opts) + opts = opts or {} + + vim.validate({ + path = { path, { 'string' } }, + depth = { opts.depth, { 'number' }, true }, + skip = { opts.skip, { 'function' }, true }, + }) + + if not opts.depth or opts.depth == 1 then + return function(fs) + return vim.loop.fs_scandir_next(fs) + end, + vim.loop.fs_scandir(M.normalize(path)) + end + + --- @async + return coroutine.wrap(function() + local dirs = { { path, 1 } } + while #dirs > 0 do + local dir0, level = unpack(table.remove(dirs, 1)) + local dir = level == 1 and dir0 or join_paths(path, dir0) + local fs = vim.loop.fs_scandir(M.normalize(dir)) + while fs do + local name, t = vim.loop.fs_scandir_next(fs) + if not name then + break + end + local f = level == 1 and name or join_paths(dir0, name) + coroutine.yield(f, t) + if + opts.depth + and level < opts.depth + and t == 'directory' + and (not opts.skip or opts.skip(f) ~= false) + then + dirs[#dirs + 1] = { f, level + 1 } + end + end + end + end) end --- Find files or directories in the given path. @@ -73,17 +141,18 @@ end --- searches are recursive and may search through many directories! If {stop} --- is non-nil, then the search stops when the directory given in {stop} is --- reached. The search terminates when {limit} (default 1) matches are found. ---- The search can be narrowed to find only files or or only directories by +--- The search can be narrowed to find only files or only directories by --- specifying {type} to be "file" or "directory", respectively. --- ---@param names (string|table|fun(name: string): boolean) Names of the files --- and directories to find. --- Must be base names, paths and globs are not supported. ---- If a function it is called per file and dir within the ---- traversed directories to test if they match. +--- The function is called per file and directory within the +--- traversed directories to test if they match {names}. +--- ---@param opts (table) Optional keyword arguments: --- - path (string): Path to begin searching from. If ---- omitted, the current working directory is used. +--- omitted, the |current-directory| is used. --- - upward (boolean, default false): If true, search --- upward through parent directories. Otherwise, --- search through child directories @@ -92,12 +161,12 @@ end --- reached. The directory itself is not searched. --- - type (string): Find only files ("file") or --- directories ("directory"). If omitted, both ---- files and directories that match {name} are +--- files and directories that match {names} are --- included. --- - limit (number, default 1): Stop the search after --- finding this many matches. Use `math.huge` to --- place no limit on the number of matches. ----@return (table) The paths of all matching files or directories +---@return (table) Normalized paths |vim.fs.normalize()| of all matching files or directories function M.find(names, opts) opts = opts or {} vim.validate({ @@ -133,7 +202,7 @@ function M.find(names, opts) local t = {} for name, type in M.dir(p) do if names(name) and (not opts.type or opts.type == type) then - table.insert(t, p .. '/' .. name) + table.insert(t, join_paths(p, name)) end end return t @@ -142,7 +211,7 @@ function M.find(names, opts) test = function(p) local t = {} for _, name in ipairs(names) do - local f = p .. '/' .. name + local f = join_paths(p, name) local stat = vim.loop.fs_stat(f) if stat and (not opts.type or opts.type == stat.type) then t[#t + 1] = f @@ -179,7 +248,7 @@ function M.find(names, opts) end for other, type_ in M.dir(dir) do - local f = dir .. '/' .. other + local f = join_paths(dir, other) if type(names) == 'function' then if names(other) and (not opts.type or opts.type == type_) then if add(f) then @@ -211,23 +280,29 @@ end --- backslash (\\) characters are converted to forward slashes (/). Environment --- variables are also expanded. --- ---- Example: ---- <pre> ---- vim.fs.normalize('C:\\Users\\jdoe') ---- => 'C:/Users/jdoe' +--- Examples: +--- <pre>lua +--- vim.fs.normalize('C:\\\\Users\\\\jdoe') +--- --> 'C:/Users/jdoe' --- ---- vim.fs.normalize('~/src/neovim') ---- => '/home/jdoe/src/neovim' +--- vim.fs.normalize('~/src/neovim') +--- --> '/home/jdoe/src/neovim' --- ---- vim.fs.normalize('$XDG_CONFIG_HOME/nvim/init.vim') ---- => '/Users/jdoe/.config/nvim/init.vim' +--- vim.fs.normalize('$XDG_CONFIG_HOME/nvim/init.vim') +--- --> '/Users/jdoe/.config/nvim/init.vim' --- </pre> --- ---@param path (string) Path to normalize ---@return (string) Normalized path function M.normalize(path) vim.validate({ path = { path, 's' } }) - return (path:gsub('^~/', vim.env.HOME .. '/'):gsub('%$([%w_]+)', vim.env):gsub('\\', '/')) + return ( + path + :gsub('^~$', vim.loop.os_homedir()) + :gsub('^~/', vim.loop.os_homedir() .. '/') + :gsub('%$([%w_]+)', vim.loop.os_getenv) + :gsub('\\', '/') + ) end return M diff --git a/runtime/lua/vim/health.lua b/runtime/lua/vim/health.lua index b875da0abc..044880e076 100644 --- a/runtime/lua/vim/health.lua +++ b/runtime/lua/vim/health.lua @@ -23,7 +23,20 @@ end local path2name = function(path) if path:match('%.lua$') then -- Lua: transform "../lua/vim/lsp/health.lua" into "vim.lsp" - return path:gsub('.-lua[%\\%/]', '', 1):gsub('[%\\%/]', '.'):gsub('%.health.-$', '') + + -- Get full path, make sure all slashes are '/' + path = vim.fs.normalize(path) + + -- Remove everything up to the last /lua/ folder + path = path:gsub('^.*/lua/', '') + + -- Remove the filename (health.lua) + path = vim.fn.fnamemodify(path, ':h') + + -- Change slashes to dots + path = path:gsub('/', '.') + + return path else -- Vim: transform "../autoload/health/provider.vim" into "provider" return vim.fn.fnamemodify(path, ':t:r') diff --git a/runtime/lua/vim/highlight.lua b/runtime/lua/vim/highlight.lua index 0fde515bd9..20ad48dd27 100644 --- a/runtime/lua/vim/highlight.lua +++ b/runtime/lua/vim/highlight.lua @@ -5,6 +5,7 @@ local M = {} M.priorities = { syntax = 50, treesitter = 100, + semantic_tokens = 125, diagnostics = 150, user = 200, } diff --git a/runtime/lua/vim/keymap.lua b/runtime/lua/vim/keymap.lua index af41794c53..ef1c66ea20 100644 --- a/runtime/lua/vim/keymap.lua +++ b/runtime/lua/vim/keymap.lua @@ -2,7 +2,7 @@ local keymap = {} --- Add a new |mapping|. --- Examples: ---- <pre> +--- <pre>lua --- -- Can add mapping to Lua functions --- vim.keymap.set('n', 'lhs', function() print("real lua function") end) --- @@ -21,13 +21,13 @@ local keymap = {} --- </pre> --- --- Note that in a mapping like: ---- <pre> +--- <pre>lua --- vim.keymap.set('n', 'asdf', require('jkl').my_fun) --- </pre> --- --- the ``require('jkl')`` gets evaluated during this call in order to access the function. --- If you want to avoid this cost at startup you can wrap it in a function, for example: ---- <pre> +--- <pre>lua --- vim.keymap.set('n', 'asdf', function() return require('jkl').my_fun() end) --- </pre> --- @@ -93,7 +93,7 @@ end --- Remove an existing mapping. --- Examples: ---- <pre> +--- <pre>lua --- vim.keymap.del('n', 'lhs') --- --- vim.keymap.del({'n', 'i', 'v'}, '<leader>w', { buffer = 5 }) diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index 199964e24e..c5392ac154 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -4,6 +4,7 @@ local lsp_rpc = require('vim.lsp.rpc') local protocol = require('vim.lsp.protocol') local util = require('vim.lsp.util') local sync = require('vim.lsp.sync') +local semantic_tokens = require('vim.lsp.semantic_tokens') local api = vim.api local nvim_err_writeln, nvim_buf_get_lines, nvim_command, nvim_buf_get_option, nvim_exec_autocmds = @@ -25,6 +26,7 @@ local lsp = { buf = require('vim.lsp.buf'), diagnostic = require('vim.lsp.diagnostic'), codelens = require('vim.lsp.codelens'), + semantic_tokens = semantic_tokens, util = util, -- Allow raw RPC access. @@ -56,6 +58,8 @@ lsp._request_name_to_capability = { ['textDocument/formatting'] = { 'documentFormattingProvider' }, ['textDocument/completion'] = { 'completionProvider' }, ['textDocument/documentHighlight'] = { 'documentHighlightProvider' }, + ['textDocument/semanticTokens/full'] = { 'semanticTokensProvider' }, + ['textDocument/semanticTokens/full/delta'] = { 'semanticTokensProvider' }, } -- TODO improve handling of scratch buffers with LSP attached. @@ -63,7 +67,7 @@ lsp._request_name_to_capability = { ---@private --- Concatenates and writes a list of strings to the Vim error buffer. --- ----@param {...} (List of strings) List to write to the buffer +---@param {...} table[] List to write to the buffer local function err_message(...) nvim_err_writeln(table.concat(vim.tbl_flatten({ ... }))) nvim_command('redraw') @@ -72,7 +76,7 @@ end ---@private --- Returns the buffer number for the given {bufnr}. --- ----@param bufnr (number) Buffer number to resolve. Defaults to the current +---@param bufnr (number|nil) Buffer number to resolve. Defaults to the current ---buffer if not given. ---@returns bufnr (number) Number of requested buffer local function resolve_bufnr(bufnr) @@ -240,9 +244,9 @@ end ---@private --- Augments a validator function with support for optional (nil) values. --- ----@param fn (function(v)) The original validator function; should return a +---@param fn (fun(v)) The original validator function; should return a ---bool. ----@returns (function(v)) The augmented function. Also returns true if {v} is +---@returns (fun(v)) The augmented function. Also returns true if {v} is ---`nil`. local function optional_validator(fn) return function(v) @@ -813,8 +817,7 @@ end --- Attaches the current buffer to the client. --- --- Example: ---- ---- <pre> +--- <pre>lua --- vim.lsp.start({ --- name = 'my-server-name', --- cmd = {'name-of-language-server-executable'}, @@ -824,23 +827,16 @@ end --- --- See |vim.lsp.start_client()| for all available options. The most important are: --- ---- `name` is an arbitrary name for the LSP client. It should be unique per ---- language server. ---- ---- `cmd` the command as list - used to start the language server. ---- The command must be present in the `$PATH` environment variable or an ---- absolute path to the executable. Shell constructs like `~` are *NOT* expanded. ---- ---- `root_dir` path to the project root. ---- By default this is used to decide if an existing client should be re-used. ---- The example above uses |vim.fs.find()| and |vim.fs.dirname()| to detect the ---- root by traversing the file system upwards starting ---- from the current directory until either a `pyproject.toml` or `setup.py` ---- file is found. ---- ---- `workspace_folders` a list of { uri:string, name: string } tables. ---- The project root folders used by the language server. ---- If `nil` the property is derived from the `root_dir` for convenience. +--- - `name` arbitrary name for the LSP client. Should be unique per language server. +--- - `cmd` command (in list form) used to start the language server. Must be absolute, or found on +--- `$PATH`. Shell constructs like `~` are not expanded. +--- - `root_dir` path to the project root. By default this is used to decide if an existing client +--- should be re-used. The example above uses |vim.fs.find()| and |vim.fs.dirname()| to detect the +--- root by traversing the file system upwards starting from the current directory until either +--- a `pyproject.toml` or `setup.py` file is found. +--- - `workspace_folders` list of `{ uri:string, name: string }` tables specifying the project root +--- folders used by the language server. If `nil` the property is derived from `root_dir` for +--- convenience. --- --- Language servers use this information to discover metadata like the --- dependencies of your project and they tend to index the contents within the @@ -901,115 +897,114 @@ end -- --- Starts and initializes a client with the given configuration. --- ---- Parameter `cmd` is required. ---- ---- The following parameters describe fields in the {config} table. ---- ---- ----@param cmd: (table|string|fun(dispatchers: table):table) command string or ---- list treated like |jobstart()|. The command must launch the language server ---- process. `cmd` can also be a function that creates an RPC client. ---- The function receives a dispatchers table and must return a table with the ---- functions `request`, `notify`, `is_closing` and `terminate` ---- See |vim.lsp.rpc.request()| and |vim.lsp.rpc.notify()| ---- For TCP there is a built-in rpc client factory: |vim.lsp.rpc.connect()| ---- ----@param cmd_cwd: (string, default=|getcwd()|) Directory to launch ---- the `cmd` process. Not related to `root_dir`. ---- ----@param cmd_env: (table) Environment flags to pass to the LSP on ---- spawn. Can be specified using keys like a map or as a list with `k=v` ---- pairs or both. Non-string values are coerced to string. ---- Example: ---- <pre> ---- { "PRODUCTION=true"; "TEST=123"; PORT = 8080; HOST = "0.0.0.0"; } ---- </pre> ---- ----@param detached: (boolean, default true) Daemonize the server process so that it runs in a ---- separate process group from Nvim. Nvim will shutdown the process on exit, but if Nvim fails to ---- exit cleanly this could leave behind orphaned server processes. ---- ----@param workspace_folders (table) List of workspace folders passed to the ---- language server. For backwards compatibility rootUri and rootPath will be ---- derived from the first workspace folder in this list. See `workspaceFolders` in ---- the LSP spec. +--- Field `cmd` in {config} is required. +--- +---@param config (table) Configuration for the server: +--- - cmd: (table|string|fun(dispatchers: table):table) command string or +--- list treated like |jobstart()|. The command must launch the language server +--- process. `cmd` can also be a function that creates an RPC client. +--- The function receives a dispatchers table and must return a table with the +--- functions `request`, `notify`, `is_closing` and `terminate` +--- See |vim.lsp.rpc.request()| and |vim.lsp.rpc.notify()| +--- For TCP there is a built-in rpc client factory: |vim.lsp.rpc.connect()| +--- +--- - cmd_cwd: (string, default=|getcwd()|) Directory to launch +--- the `cmd` process. Not related to `root_dir`. +--- +--- - cmd_env: (table) Environment flags to pass to the LSP on +--- spawn. Can be specified using keys like a map or as a list with `k=v` +--- pairs or both. Non-string values are coerced to string. +--- Example: +--- <pre> +--- { "PRODUCTION=true"; "TEST=123"; PORT = 8080; HOST = "0.0.0.0"; } +--- </pre> +--- +--- - detached: (boolean, default true) Daemonize the server process so that it runs in a +--- separate process group from Nvim. Nvim will shutdown the process on exit, but if Nvim fails to +--- exit cleanly this could leave behind orphaned server processes. +--- +--- - workspace_folders: (table) List of workspace folders passed to the +--- language server. For backwards compatibility rootUri and rootPath will be +--- derived from the first workspace folder in this list. See `workspaceFolders` in +--- the LSP spec. +--- +--- - capabilities: Map overriding the default capabilities defined by +--- |vim.lsp.protocol.make_client_capabilities()|, passed to the language +--- server on initialization. Hint: use make_client_capabilities() and modify +--- its result. +--- - Note: To send an empty dictionary use +--- `{[vim.type_idx]=vim.types.dictionary}`, else it will be encoded as an +--- array. +--- +--- - handlers: Map of language server method names to |lsp-handler| +--- +--- - settings: Map with language server specific settings. These are +--- returned to the language server if requested via `workspace/configuration`. +--- Keys are case-sensitive. +--- +--- - commands: table Table that maps string of clientside commands to user-defined functions. +--- Commands passed to start_client take precedence over the global command registry. Each key +--- must be a unique command name, and the value is a function which is called if any LSP action +--- (code action, code lenses, ...) triggers the command. +--- +--- - init_options Values to pass in the initialization request +--- as `initializationOptions`. See `initialize` in the LSP spec. +--- +--- - name: (string, default=client-id) Name in log messages. +--- +--- - get_language_id: function(bufnr, filetype) -> language ID as string. +--- Defaults to the filetype. +--- +--- - offset_encoding: (default="utf-16") One of "utf-8", "utf-16", +--- or "utf-32" which is the encoding that the LSP server expects. Client does +--- not verify this is correct. +--- +--- - on_error: Callback with parameters (code, ...), invoked +--- when the client operation throws an error. `code` is a number describing +--- the error. Other arguments may be passed depending on the error kind. See +--- `vim.lsp.rpc.client_errors` for possible errors. +--- Use `vim.lsp.rpc.client_errors[code]` to get human-friendly name. +--- +--- - before_init: Callback with parameters (initialize_params, config) +--- invoked before the LSP "initialize" phase, where `params` contains the +--- parameters being sent to the server and `config` is the config that was +--- passed to |vim.lsp.start_client()|. You can use this to modify parameters before +--- they are sent. +--- +--- - on_init: Callback (client, initialize_result) invoked after LSP +--- "initialize", where `result` is a table of `capabilities` and anything else +--- the server may send. For example, clangd sends +--- `initialize_result.offsetEncoding` if `capabilities.offsetEncoding` was +--- sent to it. You can only modify the `client.offset_encoding` here before +--- any notifications are sent. Most language servers expect to be sent client specified settings after +--- initialization. Neovim does not make this assumption. A +--- `workspace/didChangeConfiguration` notification should be sent +--- to the server during on_init. +--- +--- - on_exit Callback (code, signal, client_id) invoked on client +--- exit. +--- - code: exit code of the process +--- - signal: number describing the signal used to terminate (if any) +--- - client_id: client handle --- ----@param capabilities Map overriding the default capabilities defined by ---- |vim.lsp.protocol.make_client_capabilities()|, passed to the language ---- server on initialization. Hint: use make_client_capabilities() and modify ---- its result. ---- - Note: To send an empty dictionary use ---- `{[vim.type_idx]=vim.types.dictionary}`, else it will be encoded as an ---- array. +--- - on_attach: Callback (client, bufnr) invoked when client +--- attaches to a buffer. --- ----@param handlers Map of language server method names to |lsp-handler| +--- - trace: ("off" | "messages" | "verbose" | nil) passed directly to the language +--- server in the initialize request. Invalid/empty values will default to "off" --- ----@param settings Map with language server specific settings. These are ---- returned to the language server if requested via `workspace/configuration`. ---- Keys are case-sensitive. +--- - flags: A table with flags for the client. The current (experimental) flags are: +--- - allow_incremental_sync (bool, default true): Allow using incremental sync for buffer edits +--- - debounce_text_changes (number, default 150): Debounce didChange +--- notifications to the server by the given number in milliseconds. No debounce +--- occurs if nil +--- - exit_timeout (number|boolean, default false): Milliseconds to wait for server to +--- exit cleanly after sending the "shutdown" request before sending kill -15. +--- If set to false, nvim exits immediately after sending the "shutdown" request to the server. --- ----@param commands table Table that maps string of clientside commands to user-defined functions. ---- Commands passed to start_client take precedence over the global command registry. Each key ---- must be a unique command name, and the value is a function which is called if any LSP action ---- (code action, code lenses, ...) triggers the command. ---- ----@param init_options Values to pass in the initialization request ---- as `initializationOptions`. See `initialize` in the LSP spec. ---- ----@param name (string, default=client-id) Name in log messages. ---- ----@param get_language_id function(bufnr, filetype) -> language ID as string. ---- Defaults to the filetype. ---- ----@param offset_encoding (default="utf-16") One of "utf-8", "utf-16", ---- or "utf-32" which is the encoding that the LSP server expects. Client does ---- not verify this is correct. ---- ----@param on_error Callback with parameters (code, ...), invoked ---- when the client operation throws an error. `code` is a number describing ---- the error. Other arguments may be passed depending on the error kind. See ---- `vim.lsp.rpc.client_errors` for possible errors. ---- Use `vim.lsp.rpc.client_errors[code]` to get human-friendly name. ---- ----@param before_init Callback with parameters (initialize_params, config) ---- invoked before the LSP "initialize" phase, where `params` contains the ---- parameters being sent to the server and `config` is the config that was ---- passed to |vim.lsp.start_client()|. You can use this to modify parameters before ---- they are sent. ---- ----@param on_init Callback (client, initialize_result) invoked after LSP ---- "initialize", where `result` is a table of `capabilities` and anything else ---- the server may send. For example, clangd sends ---- `initialize_result.offsetEncoding` if `capabilities.offsetEncoding` was ---- sent to it. You can only modify the `client.offset_encoding` here before ---- any notifications are sent. Most language servers expect to be sent client specified settings after ---- initialization. Neovim does not make this assumption. A ---- `workspace/didChangeConfiguration` notification should be sent ---- to the server during on_init. ---- ----@param on_exit Callback (code, signal, client_id) invoked on client ---- exit. ---- - code: exit code of the process ---- - signal: number describing the signal used to terminate (if any) ---- - client_id: client handle ---- ----@param on_attach Callback (client, bufnr) invoked when client ---- attaches to a buffer. ---- ----@param trace: "off" | "messages" | "verbose" | nil passed directly to the language ---- server in the initialize request. Invalid/empty values will default to "off" ----@param flags: A table with flags for the client. The current (experimental) flags are: ---- - allow_incremental_sync (bool, default true): Allow using incremental sync for buffer edits ---- - debounce_text_changes (number, default 150): Debounce didChange ---- notifications to the server by the given number in milliseconds. No debounce ---- occurs if nil ---- - exit_timeout (number|boolean, default false): Milliseconds to wait for server to ---- exit cleanly after sending the "shutdown" request before sending kill -15. ---- If set to false, nvim exits immediately after sending the "shutdown" request to the server. ---- ----@param root_dir string Directory where the LSP ---- server will base its workspaceFolders, rootUri, and rootPath ---- on initialization. +--- - root_dir: (string) Directory where the LSP +--- server will base its workspaceFolders, rootUri, and rootPath +--- on initialization. --- ---@returns Client id. |vim.lsp.get_client_by_id()| Note: client may not be --- fully initialized. Use `on_init` to do any actions once @@ -1293,8 +1288,6 @@ function lsp.start_client(config) client.initialized = true uninitialized_clients[client_id] = nil client.workspace_folders = workspace_folders - -- TODO(mjlbach): Backwards compatibility, to be removed in 0.7 - client.workspaceFolders = client.workspace_folders -- These are the cleaned up capabilities we use for dynamically deciding -- when to send certain events to clients. @@ -1372,7 +1365,7 @@ function lsp.start_client(config) --- ---@param method (string) LSP method name. ---@param params (table) LSP request params. - ---@param handler (function, optional) Response |lsp-handler| for this method. + ---@param handler (function|nil) Response |lsp-handler| for this method. ---@param bufnr (number) Buffer handle (0 for current). ---@returns ({status}, [request_id]): {status} is a bool indicating ---whether the request was successful. If it is `false`, then it will @@ -1383,8 +1376,10 @@ function lsp.start_client(config) ---@see |vim.lsp.buf_request()| function client.request(method, params, handler, bufnr) if not handler then - handler = resolve_handler(method) - or error(string.format('not found: %q request handler for client %q.', method, client.name)) + handler = assert( + resolve_handler(method), + string.format('not found: %q request handler for client %q.', method, client.name) + ) end -- Ensure pending didChange notifications are sent so that the server doesn't operate on a stale state changetracking.flush(client, bufnr) @@ -1402,7 +1397,7 @@ function lsp.start_client(config) nvim_exec_autocmds('User', { pattern = 'LspRequest', modeline = false }) end) - if success then + if success and request_id then client.requests[request_id] = { type = 'pending', bufnr = bufnr, method = method } nvim_exec_autocmds('User', { pattern = 'LspRequest', modeline = false }) end @@ -1417,8 +1412,8 @@ function lsp.start_client(config) --- ---@param method (string) LSP method name. ---@param params (table) LSP request params. - ---@param timeout_ms (number, optional, default=1000) Maximum time in - ---milliseconds to wait for a result. + ---@param timeout_ms (number|nil) Maximum time in milliseconds to wait for + --- a result. Defaults to 1000 ---@param bufnr (number) Buffer handle (0 for current). ---@returns { err=err, result=result }, a dictionary, where `err` and `result` come from the |lsp-handler|. ---On timeout, cancel or error, returns `(nil, err)` where `err` is a @@ -1441,7 +1436,9 @@ function lsp.start_client(config) end, 10) if not wait_result then - client.cancel_request(request_id) + if request_id then + client.cancel_request(request_id) + end return nil, wait_result_reason[reason] end return request_result @@ -1536,6 +1533,16 @@ function lsp.start_client(config) -- TODO(ashkan) handle errors. pcall(config.on_attach, client, bufnr) end + + -- schedule the initialization of semantic tokens to give the above + -- on_attach and LspAttach callbacks the ability to schedule wrap the + -- opt-out (deleting the semanticTokensProvider from capabilities) + vim.schedule(function() + if vim.tbl_get(client.server_capabilities, 'semanticTokensProvider', 'full') then + semantic_tokens.start(bufnr, client.id) + end + end) + client.attached_buffers[bufnr] = true end @@ -1549,15 +1556,21 @@ end --- Notify all attached clients that a buffer has changed. local text_document_did_change_handler do - text_document_did_change_handler = - function(_, bufnr, changedtick, firstline, lastline, new_lastline) - -- Detach (nvim_buf_attach) via returning True to on_lines if no clients are attached - if tbl_isempty(all_buffer_active_clients[bufnr] or {}) then - return true - end - util.buf_versions[bufnr] = changedtick - changetracking.send_changes(bufnr, firstline, lastline, new_lastline) + text_document_did_change_handler = function( + _, + bufnr, + changedtick, + firstline, + lastline, + new_lastline + ) + -- Detach (nvim_buf_attach) via returning True to on_lines if no clients are attached + if tbl_isempty(all_buffer_active_clients[bufnr] or {}) then + return true end + util.buf_versions[bufnr] = changedtick + changetracking.send_changes(bufnr, firstline, lastline, new_lastline) + end end ---@private @@ -1621,9 +1634,37 @@ function lsp.buf_attach_client(bufnr, client_id) all_buffer_active_clients[bufnr] = buffer_client_ids local uri = vim.uri_from_bufnr(bufnr) - local augroup = ('lsp_c_%d_b_%d_did_save'):format(client_id, bufnr) + local augroup = ('lsp_c_%d_b_%d_save'):format(client_id, bufnr) + local group = api.nvim_create_augroup(augroup, { clear = true }) + api.nvim_create_autocmd('BufWritePre', { + group = group, + buffer = bufnr, + desc = 'vim.lsp: textDocument/willSave', + callback = function(ctx) + for_each_buffer_client(ctx.buf, function(client) + local params = { + textDocument = { + uri = uri, + }, + reason = protocol.TextDocumentSaveReason.Manual, + } + if vim.tbl_get(client.server_capabilities, 'textDocumentSync', 'willSave') then + client.notify('textDocument/willSave', params) + end + if vim.tbl_get(client.server_capabilities, 'textDocumentSync', 'willSaveWaitUntil') then + local result, err = + client.request_sync('textDocument/willSaveWaitUntil', params, 1000, ctx.buf) + if result and result.result then + util.apply_text_edits(result.result, ctx.buf, client.offset_encoding) + elseif err then + log.error(vim.inspect(err)) + end + end + end) + end, + }) api.nvim_create_autocmd('BufWritePost', { - group = api.nvim_create_augroup(augroup, { clear = true }), + group = group, buffer = bufnr, desc = 'vim.lsp: textDocument/didSave handler', callback = function(ctx) @@ -1761,16 +1802,15 @@ end --- --- You can also use the `stop()` function on a |vim.lsp.client| object. --- To stop all clients: ---- ---- <pre> +--- <pre>lua --- vim.lsp.stop_client(vim.lsp.get_active_clients()) --- </pre> --- --- By default asks the server to shutdown, unless stop was requested --- already for this client, then force-shutdown is attempted. --- ----@param client_id client id or |vim.lsp.client| object, or list thereof ----@param force boolean (optional) shutdown forcefully +---@param client_id number|table id or |vim.lsp.client| object, or list thereof +---@param force boolean|nil shutdown forcefully function lsp.stop_client(client_id, force) local ids = type(client_id) == 'table' and client_id or { client_id } for _, id in ipairs(ids) do @@ -1784,10 +1824,16 @@ function lsp.stop_client(client_id, force) end end +---@class vim.lsp.get_active_clients.filter +---@field id number|nil Match clients by id +---@field bufnr number|nil match clients attached to the given buffer +---@field name string|nil match clients by name + --- Get active clients. --- ----@param filter (table|nil) A table with key-value pairs used to filter the ---- returned clients. The available keys are: +---@param filter vim.lsp.get_active_clients.filter|nil (table|nil) A table with +--- key-value pairs used to filter the returned clients. +--- The available keys are: --- - id (number): Only return clients with the given id --- - bufnr (number): Only return clients attached to this buffer --- - name (string): Only return clients with the given name @@ -1804,7 +1850,8 @@ function lsp.get_active_clients(filter) for client_id in pairs(t) do local client = active_clients[client_id] if - (filter.id == nil or client.id == filter.id) + client + and (filter.id == nil or client.id == filter.id) and (filter.name == nil or client.name == filter.name) then clients[#clients + 1] = client @@ -1935,7 +1982,7 @@ end --- ---@param bufnr (number) Buffer handle, or 0 for current. ---@param method (string) LSP method name ----@param params (optional, table) Parameters to send to the server +---@param params (table|nil) Parameters to send to the server ---@param callback (function) The callback to call when all requests are finished. -- Unlike `buf_request`, this will collect all the responses from each server instead of handling them. -- A map of client_id:request_result will be provided to the callback @@ -1977,9 +2024,9 @@ end --- ---@param bufnr (number) Buffer handle, or 0 for current. ---@param method (string) LSP method name ----@param params (optional, table) Parameters to send to the server ----@param timeout_ms (optional, number, default=1000) Maximum time in ---- milliseconds to wait for a result. +---@param params (table|nil) Parameters to send to the server +---@param timeout_ms (number|nil) Maximum time in milliseconds to wait for a +--- result. Defaults to 1000 --- ---@returns Map of client_id:request_result. On timeout, cancel or error, --- returns `(nil, err)` where `err` is a string describing the failure @@ -2004,9 +2051,9 @@ function lsp.buf_request_sync(bufnr, method, params, timeout_ms) end --- Send a notification to a server ----@param bufnr [number] (optional): The number of the buffer ----@param method [string]: Name of the request method ----@param params [string]: Arguments to send to the server +---@param bufnr (number|nil) The number of the buffer +---@param method (string) Name of the request method +---@param params (any) Arguments to send to the server --- ---@returns true if any client returns true; false otherwise function lsp.buf_notify(bufnr, method, params) @@ -2047,8 +2094,8 @@ end ---@see |complete-items| ---@see |CompleteDone| --- ----@param findstart 0 or 1, decides behavior ----@param base If findstart=0, text to match against +---@param findstart number 0 or 1, decides behavior +---@param base number findstart=0, text to match against --- ---@returns (number) Decided by {findstart}: --- - findstart=0: column where the completion starts, or -2 or -3 @@ -2177,8 +2224,8 @@ end --- Otherwise, uses "workspace/symbol". If no results are returned from --- any LSP servers, falls back to using built-in tags. --- ----@param pattern Pattern used to find a workspace symbol ----@param flags See |tag-function| +---@param pattern string Pattern used to find a workspace symbol +---@param flags string See |tag-function| --- ---@returns A list of matching tags function lsp.tagfunc(...) @@ -2187,7 +2234,7 @@ end ---Checks whether a client is stopped. --- ----@param client_id (Number) +---@param client_id (number) ---@returns true if client is stopped, false otherwise. function lsp.client_is_stopped(client_id) return active_clients[client_id] == nil @@ -2196,7 +2243,7 @@ end --- Gets a map of client_id:client pairs for the given buffer, where each value --- is a |vim.lsp.client| object. --- ----@param bufnr (optional, number): Buffer handle, or 0 for current +---@param bufnr (number|nil): Buffer handle, or 0 for current ---@returns (table) Table of (client_id, client) pairs ---@deprecated Use |vim.lsp.get_active_clients()| instead. function lsp.buf_get_clients(bufnr) @@ -2225,7 +2272,7 @@ lsp.log_levels = log.levels --- ---@see |vim.lsp.log_levels| --- ----@param level [number|string] the case insensitive level name or number +---@param level (number|string) the case insensitive level name or number function lsp.set_log_level(level) if type(level) == 'string' or type(level) == 'number' then log.set_level(level) @@ -2246,7 +2293,7 @@ end ---@param fn function Function to run on each client attached to buffer --- {bufnr}. The function takes the client, client ID, and --- buffer number as arguments. Example: ---- <pre> +--- <pre>lua --- vim.lsp.for_each_buffer_client(0, function(client, client_id, bufnr) --- print(vim.inspect(client)) --- end) diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index c593e72d62..6ac885c78f 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -162,11 +162,11 @@ end --- Predicate used to filter clients. Receives a client as argument and must return a --- boolean. Clients matching the predicate are included. Example: --- ---- <pre> ---- -- Never request typescript-language-server for formatting ---- vim.lsp.buf.format { ---- filter = function(client) return client.name ~= "tsserver" end ---- } +--- <pre>lua +--- -- Never request typescript-language-server for formatting +--- vim.lsp.buf.format { +--- filter = function(client) return client.name ~= "tsserver" end +--- } --- </pre> --- --- - async boolean|nil @@ -197,20 +197,21 @@ function M.format(options) clients = vim.tbl_filter(options.filter, clients) end + local mode = api.nvim_get_mode().mode + local range = options.range + if not range and mode == 'v' or mode == 'V' then + range = range_from_selection() + end + local method = range and 'textDocument/rangeFormatting' or 'textDocument/formatting' + clients = vim.tbl_filter(function(client) - return client.supports_method('textDocument/formatting') + return client.supports_method(method) end, clients) if #clients == 0 then vim.notify('[LSP] Format request failed, no matching language servers.') end - local mode = api.nvim_get_mode().mode - local range = options.range - if not range and mode == 'v' or mode == 'V' then - range = range_from_selection() - end - ---@private local function set_range(client, params) if range then @@ -221,7 +222,6 @@ function M.format(options) return params end - local method = range and 'textDocument/rangeFormatting' or 'textDocument/formatting' if options.async then local do_format do_format = function(idx, client) @@ -383,7 +383,7 @@ end --- Lists all the references to the symbol under the cursor in the quickfix window. --- ----@param context (table) Context for the request +---@param context (table|nil) Context for the request ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_references ---@param options table|nil additional options --- - on_list: (function) handler for list results. See |lsp-on-list-handler| @@ -464,7 +464,7 @@ end --- function M.list_workspace_folders() local workspace_folders = {} - for _, client in pairs(vim.lsp.buf_get_clients()) do + for _, client in pairs(vim.lsp.get_active_clients({ bufnr = 0 })) do for _, folder in pairs(client.workspace_folders or {}) do table.insert(workspace_folders, folder.name) end @@ -487,9 +487,9 @@ function M.add_workspace_folder(workspace_folder) end local params = util.make_workspace_params( { { uri = vim.uri_from_fname(workspace_folder), name = workspace_folder } }, - { {} } + {} ) - for _, client in pairs(vim.lsp.buf_get_clients()) do + for _, client in pairs(vim.lsp.get_active_clients({ bufnr = 0 })) do local found = false for _, folder in pairs(client.workspace_folders or {}) do if folder.name == workspace_folder then @@ -522,7 +522,7 @@ function M.remove_workspace_folder(workspace_folder) { {} }, { { uri = vim.uri_from_fname(workspace_folder), name = workspace_folder } } ) - for _, client in pairs(vim.lsp.buf_get_clients()) do + for _, client in pairs(vim.lsp.get_active_clients({ bufnr = 0 })) do for idx, folder in pairs(client.workspace_folders) do if folder.name == workspace_folder then vim.lsp.buf_notify(0, 'workspace/didChangeWorkspaceFolders', params) @@ -555,11 +555,10 @@ end --- Send request to the server to resolve document highlights for the current --- text document position. This request can be triggered by a key mapping or --- by events such as `CursorHold`, e.g.: ---- ---- <pre> ---- autocmd CursorHold <buffer> lua vim.lsp.buf.document_highlight() ---- autocmd CursorHoldI <buffer> lua vim.lsp.buf.document_highlight() ---- autocmd CursorMoved <buffer> lua vim.lsp.buf.clear_references() +--- <pre>vim +--- autocmd CursorHold <buffer> lua vim.lsp.buf.document_highlight() +--- autocmd CursorHoldI <buffer> lua vim.lsp.buf.document_highlight() +--- autocmd CursorMoved <buffer> lua vim.lsp.buf.clear_references() --- </pre> --- --- Note: Usage of |vim.lsp.buf.document_highlight()| requires the following highlight groups @@ -734,6 +733,7 @@ end --- List of LSP `CodeActionKind`s used to filter the code actions. --- Most language servers support values like `refactor` --- or `quickfix`. +--- - triggerKind (number|nil): The reason why code actions were requested. --- - filter: (function|nil) --- Predicate taking an `CodeAction` and returning a boolean. --- - apply: (boolean|nil) @@ -747,6 +747,7 @@ end --- using mark-like indexing. See |api-indexing| --- ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_codeAction +---@see vim.lsp.protocol.constants.CodeActionTriggerKind function M.code_action(options) validate({ options = { options, 't', true } }) options = options or {} @@ -756,6 +757,9 @@ function M.code_action(options) options = { options = options } end local context = options.context or {} + if not context.triggerKind then + context.triggerKind = vim.lsp.protocol.CodeActionTriggerKind.Invoked + end if not context.diagnostics then local bufnr = api.nvim_get_current_buf() context.diagnostics = vim.lsp.diagnostic.get_line_diagnostics(bufnr) diff --git a/runtime/lua/vim/lsp/codelens.lua b/runtime/lua/vim/lsp/codelens.lua index 4fa02c8db2..17489ed84d 100644 --- a/runtime/lua/vim/lsp/codelens.lua +++ b/runtime/lua/vim/lsp/codelens.lua @@ -108,13 +108,36 @@ function M.run() end end +---@private +local function resolve_bufnr(bufnr) + return bufnr == 0 and api.nvim_get_current_buf() or bufnr +end + +--- Clear the lenses +--- +---@param client_id number|nil filter by client_id. All clients if nil +---@param bufnr number|nil filter by buffer. All buffers if nil +function M.clear(client_id, bufnr) + local buffers = bufnr and { resolve_bufnr(bufnr) } or vim.tbl_keys(lens_cache_by_buf) + for _, iter_bufnr in pairs(buffers) do + local client_ids = client_id and { client_id } or vim.tbl_keys(namespaces) + for _, iter_client_id in pairs(client_ids) do + local ns = namespaces[iter_client_id] + lens_cache_by_buf[iter_bufnr][iter_client_id] = {} + api.nvim_buf_clear_namespace(iter_bufnr, ns, 0, -1) + end + end +end + --- Display the lenses using virtual text --- ---@param lenses table of lenses to display (`CodeLens[] | null`) ---@param bufnr number ---@param client_id number function M.display(lenses, bufnr, client_id) + local ns = namespaces[client_id] if not lenses or not next(lenses) then + api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) return end local lenses_by_lnum = {} @@ -126,7 +149,6 @@ function M.display(lenses, bufnr, client_id) end table.insert(line_lenses, lens) end - local ns = namespaces[client_id] local num_lines = api.nvim_buf_line_count(bufnr) for i = 0, num_lines do local line_lenses = lenses_by_lnum[i] or {} @@ -241,7 +263,8 @@ end --- --- It is recommended to trigger this using an autocmd or via keymap. --- ---- <pre> +--- Example: +--- <pre>vim --- autocmd BufEnter,CursorHold,InsertLeave <buffer> lua vim.lsp.codelens.refresh() --- </pre> --- diff --git a/runtime/lua/vim/lsp/diagnostic.lua b/runtime/lua/vim/lsp/diagnostic.lua index 1f9d084e2b..5e2bf75f1b 100644 --- a/runtime/lua/vim/lsp/diagnostic.lua +++ b/runtime/lua/vim/lsp/diagnostic.lua @@ -150,7 +150,7 @@ end --- --- See |vim.diagnostic.config()| for configuration options. Handler-specific --- configuration can be set using |vim.lsp.with()|: ---- <pre> +--- <pre>lua --- vim.lsp.handlers["textDocument/publishDiagnostics"] = vim.lsp.with( --- vim.lsp.diagnostic.on_publish_diagnostics, { --- -- Enable underline, use default values diff --git a/runtime/lua/vim/lsp/handlers.lua b/runtime/lua/vim/lsp/handlers.lua index 93fd621161..5096100a60 100644 --- a/runtime/lua/vim/lsp/handlers.lua +++ b/runtime/lua/vim/lsp/handlers.lua @@ -81,22 +81,38 @@ M['window/workDoneProgress/create'] = function(_, result, ctx) end --see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_showMessageRequest +---@param result lsp.ShowMessageRequestParams M['window/showMessageRequest'] = function(_, result) - local actions = result.actions - print(result.message) - local option_strings = { result.message, '\nRequest Actions:' } - for i, action in ipairs(actions) do - local title = action.title:gsub('\r\n', '\\r\\n') - title = title:gsub('\n', '\\n') - table.insert(option_strings, string.format('%d. %s', i, title)) - end - - -- window/showMessageRequest can return either MessageActionItem[] or null. - local choice = vim.fn.inputlist(option_strings) - if choice < 1 or choice > #actions then - return vim.NIL + local actions = result.actions or {} + local co, is_main = coroutine.running() + if co and not is_main then + local opts = { + prompt = result.message .. ': ', + format_item = function(action) + return (action.title:gsub('\r\n', '\\r\\n')):gsub('\n', '\\n') + end, + } + vim.ui.select(actions, opts, function(choice) + -- schedule to ensure resume doesn't happen _before_ yield with + -- default synchronous vim.ui.select + vim.schedule(function() + coroutine.resume(co, choice or vim.NIL) + end) + end) + return coroutine.yield() else - return actions[choice] + local option_strings = { result.message, '\nRequest Actions:' } + for i, action in ipairs(actions) do + local title = action.title:gsub('\r\n', '\\r\\n') + title = title:gsub('\n', '\\n') + table.insert(option_strings, string.format('%d. %s', i, title)) + end + local choice = vim.fn.inputlist(option_strings) + if choice < 1 or choice > #actions then + return vim.NIL + else + return actions[choice] + end end end @@ -115,9 +131,10 @@ end --see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_applyEdit M['workspace/applyEdit'] = function(_, workspace_edit, ctx) - if not workspace_edit then - return - end + assert( + workspace_edit, + 'workspace/applyEdit must be called with `ApplyWorkspaceEditParams`. Server is violating the specification' + ) -- TODO(ashkan) Do something more with label? local client_id = ctx.client_id local client = vim.lsp.get_client_by_id(client_id) @@ -297,13 +314,15 @@ M['textDocument/completion'] = function(_, result, _, _) end --- |lsp-handler| for the method "textDocument/hover" ---- <pre> ---- vim.lsp.handlers["textDocument/hover"] = vim.lsp.with( ---- vim.lsp.handlers.hover, { ---- -- Use a sharp border with `FloatBorder` highlights ---- border = "single" ---- } ---- ) +--- <pre>lua +--- vim.lsp.handlers["textDocument/hover"] = vim.lsp.with( +--- vim.lsp.handlers.hover, { +--- -- Use a sharp border with `FloatBorder` highlights +--- border = "single", +--- -- add the title in hover float window +--- title = "hover" +--- } +--- ) --- </pre> ---@param config table Configuration table. --- - border: (default=nil) @@ -312,8 +331,14 @@ end function M.hover(_, result, ctx, config) config = config or {} config.focus_id = ctx.method + if api.nvim_get_current_buf() ~= ctx.bufnr then + -- Ignore result since buffer changed. This happens for slow language servers. + return + end if not (result and result.contents) then - vim.notify('No information available') + if config.silent ~= true then + vim.notify('No information available') + end return end local markdown_lines = util.convert_input_to_markdown_lines(result.contents) @@ -377,13 +402,13 @@ M['textDocument/implementation'] = location_handler --- |lsp-handler| for the method "textDocument/signatureHelp". --- The active parameter is highlighted with |hl-LspSignatureActiveParameter|. ---- <pre> ---- vim.lsp.handlers["textDocument/signatureHelp"] = vim.lsp.with( ---- vim.lsp.handlers.signature_help, { ---- -- Use a sharp border with `FloatBorder` highlights ---- border = "single" ---- } ---- ) +--- <pre>lua +--- vim.lsp.handlers["textDocument/signatureHelp"] = vim.lsp.with( +--- vim.lsp.handlers.signature_help, { +--- -- Use a sharp border with `FloatBorder` highlights +--- border = "single" +--- } +--- ) --- </pre> ---@param config table Configuration table. --- - border: (default=nil) @@ -392,6 +417,10 @@ M['textDocument/implementation'] = location_handler function M.signature_help(_, result, ctx, config) config = config or {} config.focus_id = ctx.method + if api.nvim_get_current_buf() ~= ctx.bufnr then + -- Ignore result since buffer changed. This happens for slow language servers. + return + end -- When use `autocmd CompleteDone <silent><buffer> lua vim.lsp.buf.signature_help()` to call signatureHelp handler -- If the completion item doesn't have signatures It will make noise. Change to use `print` that can use `<silent>` to ignore if not (result and result.signatures and result.signatures[1]) then @@ -519,15 +548,15 @@ M['window/showDocument'] = function(_, result, ctx, _) -- TODO(lvimuser): ask the user for confirmation local cmd if vim.fn.has('win32') == 1 then - cmd = { 'cmd.exe', '/c', 'start', '""', vim.fn.shellescape(uri) } + cmd = { 'cmd.exe', '/c', 'start', '""', uri } elseif vim.fn.has('macunix') == 1 then - cmd = { 'open', vim.fn.shellescape(uri) } + cmd = { 'open', uri } else - cmd = { 'xdg-open', vim.fn.shellescape(uri) } + cmd = { 'xdg-open', uri } end local ret = vim.fn.system(cmd) - if vim.v.shellerror ~= 0 then + if vim.v.shell_error ~= 0 then return { success = false, error = { @@ -553,7 +582,10 @@ M['window/showDocument'] = function(_, result, ctx, _) range = result.selection, } - local success = util.show_document(location, client.offset_encoding, true, result.takeFocus) + local success = util.show_document(location, client.offset_encoding, { + reuse_win = true, + focus = result.takeFocus, + }) return { success = success or false } end diff --git a/runtime/lua/vim/lsp/health.lua b/runtime/lua/vim/lsp/health.lua index ba730e3d6d..987707e661 100644 --- a/runtime/lua/vim/lsp/health.lua +++ b/runtime/lua/vim/lsp/health.lua @@ -2,8 +2,8 @@ local M = {} --- Performs a healthcheck for LSP function M.check() - local report_info = vim.fn['health#report_info'] - local report_warn = vim.fn['health#report_warn'] + local report_info = vim.health.report_info + local report_warn = vim.health.report_warn local log = require('vim.lsp.log') local current_log_level = log.get_level() @@ -27,6 +27,18 @@ function M.check() local report_fn = (log_size / 1000000 > 100 and report_warn or report_info) report_fn(string.format('Log size: %d KB', log_size / 1000)) + + local clients = vim.lsp.get_active_clients() + vim.health.report_start('vim.lsp: Active Clients') + if next(clients) then + for _, client in pairs(clients) do + report_info( + string.format('%s (id=%s, root_dir=%s)', client.name, client.id, client.config.root_dir) + ) + end + else + report_info('No active clients') + end end return M diff --git a/runtime/lua/vim/lsp/log.lua b/runtime/lua/vim/lsp/log.lua index 6c6ba0f206..d1a78572aa 100644 --- a/runtime/lua/vim/lsp/log.lua +++ b/runtime/lua/vim/lsp/log.lua @@ -20,6 +20,17 @@ local format_func = function(arg) end do + ---@private + local function notify(msg, level) + if vim.in_fast_event() then + vim.schedule(function() + vim.notify(msg, level) + end) + else + vim.notify(msg, level) + end + end + local path_sep = vim.loop.os_uname().version:match('Windows') and '\\' or '/' ---@private local function path_join(...) @@ -53,7 +64,7 @@ do logfile, openerr = io.open(logfilename, 'a+') if not logfile then local err_msg = string.format('Failed to open LSP client log file: %s', openerr) - vim.notify(err_msg, vim.log.levels.ERROR) + notify(err_msg, vim.log.levels.ERROR) return false end @@ -64,7 +75,7 @@ do log_info.size / (1000 * 1000), logfilename ) - vim.notify(warn_msg) + notify(warn_msg) end -- Start message for logging @@ -130,7 +141,7 @@ end vim.tbl_add_reverse_lookup(log.levels) --- Sets the current log level. ----@param level (string or number) One of `vim.lsp.log.levels` +---@param level (string|number) One of `vim.lsp.log.levels` function log.set_level(level) if type(level) == 'string' then current_log_level = diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index 4034753322..12345b6c8c 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -20,6 +20,14 @@ function transform_schema_to_table() end --]=] +---@class lsp.ShowMessageRequestParams +---@field type lsp.MessageType +---@field message string +---@field actions nil|lsp.MessageActionItem[] + +---@class lsp.MessageActionItem +---@field title string + local constants = { DiagnosticSeverity = { -- Reports an error. @@ -39,6 +47,7 @@ local constants = { Deprecated = 2, }, + ---@enum lsp.MessageType MessageType = { -- An error message. Error = 1, @@ -142,6 +151,7 @@ local constants = { }, -- Represents reasons why a text document is saved. + ---@enum lsp.TextDocumentSaveReason TextDocumentSaveReason = { -- Manually triggered, e.g. by the user pressing save, by starting debugging, -- or by an API call. @@ -294,6 +304,17 @@ local constants = { -- Base kind for an organize imports source action SourceOrganizeImports = 'source.organizeImports', }, + -- The reason why code actions were requested. + ---@enum lsp.CodeActionTriggerKind + CodeActionTriggerKind = { + -- Code actions were explicitly requested by the user or by an extension. + Invoked = 1, + -- Code actions were requested automatically. + -- + -- This typically happens when current selection in a file changes, but can + -- also be triggered when file content changes. + Automatic = 2, + }, } for k, v in pairs(constants) do @@ -619,14 +640,63 @@ export interface WorkspaceClientCapabilities { function protocol.make_client_capabilities() return { textDocument = { - synchronization = { + semanticTokens = { dynamicRegistration = false, + tokenTypes = { + 'namespace', + 'type', + 'class', + 'enum', + 'interface', + 'struct', + 'typeParameter', + 'parameter', + 'variable', + 'property', + 'enumMember', + 'event', + 'function', + 'method', + 'macro', + 'keyword', + 'modifier', + 'comment', + 'string', + 'number', + 'regexp', + 'operator', + 'decorator', + }, + tokenModifiers = { + 'declaration', + 'definition', + 'readonly', + 'static', + 'deprecated', + 'abstract', + 'async', + 'modification', + 'documentation', + 'defaultLibrary', + }, + formats = { 'relative' }, + requests = { + -- TODO(jdrouhard): Add support for this + range = false, + full = { delta = true }, + }, - -- TODO(ashkan) Send textDocument/willSave before saving (BufWritePre) - willSave = false, + overlappingTokenSupport = true, + -- TODO(jdrouhard): Add support for this + multilineTokenSupport = false, + serverCancelSupport = false, + augmentsSyntaxTokens = true, + }, + synchronization = { + dynamicRegistration = false, - -- TODO(ashkan) Implement textDocument/willSaveWaitUntil - willSaveWaitUntil = false, + willSave = true, + willSaveWaitUntil = true, -- Send textDocument/didSave after saving (BufWritePost) didSave = true, @@ -637,7 +707,7 @@ function protocol.make_client_capabilities() codeActionLiteralSupport = { codeActionKind = { valueSet = (function() - local res = vim.tbl_values(protocol.CodeActionKind) + local res = vim.tbl_values(constants.CodeActionKind) table.sort(res) return res end)(), @@ -742,6 +812,9 @@ function protocol.make_client_capabilities() end)(), }, }, + callHierarchy = { + dynamicRegistration = false, + }, }, workspace = { symbol = { @@ -765,9 +838,9 @@ function protocol.make_client_capabilities() workspaceEdit = { resourceOperations = { 'rename', 'create', 'delete' }, }, - }, - callHierarchy = { - dynamicRegistration = false, + semanticTokens = { + refreshSupport = true, + }, }, experimental = nil, window = { @@ -861,8 +934,8 @@ function protocol._resolve_capabilities_compat(server_capabilities) text_document_sync_properties = { text_document_open_close = if_nil(textDocumentSync.openClose, false), text_document_did_change = if_nil(textDocumentSync.change, TextDocumentSyncKind.None), - text_document_will_save = if_nil(textDocumentSync.willSave, false), - text_document_will_save_wait_until = if_nil(textDocumentSync.willSaveWaitUntil, false), + text_document_will_save = if_nil(textDocumentSync.willSave, true), + text_document_will_save_wait_until = if_nil(textDocumentSync.willSaveWaitUntil, true), text_document_save = if_nil(textDocumentSync.save, false), text_document_save_include_text = if_nil( type(textDocumentSync.save) == 'table' and textDocumentSync.save.includeText, diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua index ff62623544..f1492601ff 100644 --- a/runtime/lua/vim/lsp/rpc.lua +++ b/runtime/lua/vim/lsp/rpc.lua @@ -293,7 +293,7 @@ end ---@private --- Sends a notification to the LSP server. ---@param method (string) The invoked LSP method ----@param params (table|nil): Parameters for the invoked LSP method +---@param params (any): Parameters for the invoked LSP method ---@returns (bool) `true` if notification could be sent, `false` if not function Client:notify(method, params) return self:encode_and_send({ @@ -391,44 +391,46 @@ function Client:handle_body(body) -- Schedule here so that the users functions don't trigger an error and -- we can still use the result. schedule(function() - local status, result - status, result, err = self:try_call( - client_errors.SERVER_REQUEST_HANDLER_ERROR, - self.dispatchers.server_request, - decoded.method, - decoded.params - ) - local _ = log.debug() - and log.debug( - 'server_request: callback result', - { status = status, result = result, err = err } + coroutine.wrap(function() + local status, result + status, result, err = self:try_call( + client_errors.SERVER_REQUEST_HANDLER_ERROR, + self.dispatchers.server_request, + decoded.method, + decoded.params ) - if status then - if result == nil and err == nil then - error( - string.format( - 'method %q: either a result or an error must be sent to the server in response', - decoded.method - ) - ) - end - if err then - assert( - type(err) == 'table', - 'err must be a table. Use rpc_response_error to help format errors.' - ) - local code_name = assert( - protocol.ErrorCodes[err.code], - 'Errors must use protocol.ErrorCodes. Use rpc_response_error to help format errors.' + local _ = log.debug() + and log.debug( + 'server_request: callback result', + { status = status, result = result, err = err } ) - err.message = err.message or code_name + if status then + if result == nil and err == nil then + error( + string.format( + 'method %q: either a result or an error must be sent to the server in response', + decoded.method + ) + ) + end + if err then + assert( + type(err) == 'table', + 'err must be a table. Use rpc_response_error to help format errors.' + ) + local code_name = assert( + protocol.ErrorCodes[err.code], + 'Errors must use protocol.ErrorCodes. Use rpc_response_error to help format errors.' + ) + err.message = err.message or code_name + end + else + -- On an exception, result will contain the error message. + err = rpc_response_error(protocol.ErrorCodes.InternalError, result) + result = nil end - else - -- On an exception, result will contain the error message. - err = rpc_response_error(protocol.ErrorCodes.InternalError, result) - result = nil - end - self:send_response(decoded.id, err, result) + self:send_response(decoded.id, err, result) + end)() end) -- This works because we are expecting vim.NIL here elseif decoded.id and (decoded.result ~= vim.NIL or decoded.error ~= vim.NIL) then diff --git a/runtime/lua/vim/lsp/semantic_tokens.lua b/runtime/lua/vim/lsp/semantic_tokens.lua new file mode 100644 index 0000000000..b1bc48dac6 --- /dev/null +++ b/runtime/lua/vim/lsp/semantic_tokens.lua @@ -0,0 +1,702 @@ +local api = vim.api +local handlers = require('vim.lsp.handlers') +local util = require('vim.lsp.util') + +--- @class STTokenRange +--- @field line number line number 0-based +--- @field start_col number start column 0-based +--- @field end_col number end column 0-based +--- @field type string token type as string +--- @field modifiers string[] token modifiers as strings +--- @field extmark_added boolean whether this extmark has been added to the buffer yet +--- +--- @class STCurrentResult +--- @field version number document version associated with this result +--- @field result_id string resultId from the server; used with delta requests +--- @field highlights STTokenRange[] cache of highlight ranges for this document version +--- @field tokens number[] raw token array as received by the server. used for calculating delta responses +--- @field namespace_cleared boolean whether the namespace was cleared for this result yet +--- +--- @class STActiveRequest +--- @field request_id number the LSP request ID of the most recent request sent to the server +--- @field version number the document version associated with the most recent request +--- +--- @class STClientState +--- @field namespace number +--- @field active_request STActiveRequest +--- @field current_result STCurrentResult + +---@class STHighlighter +---@field active table<number, STHighlighter> +---@field bufnr number +---@field augroup number augroup for buffer events +---@field debounce number milliseconds to debounce requests for new tokens +---@field timer table uv_timer for debouncing requests for new tokens +---@field client_state table<number, STClientState> +local STHighlighter = { active = {} } + +---@private +local function binary_search(tokens, line) + local lo = 1 + local hi = #tokens + while lo < hi do + local mid = math.floor((lo + hi) / 2) + if tokens[mid].line < line then + lo = mid + 1 + else + hi = mid + end + end + return lo +end + +--- Extracts modifier strings from the encoded number in the token array +--- +---@private +---@return string[] +local function modifiers_from_number(x, modifiers_table) + local modifiers = {} + local idx = 1 + while x > 0 do + if _G.bit then + if _G.bit.band(x, 1) == 1 then + modifiers[#modifiers + 1] = modifiers_table[idx] + end + x = _G.bit.rshift(x, 1) + else + --TODO(jdrouhard): remove this branch once `bit` module is available for non-LuaJIT (#21222) + if x % 2 == 1 then + modifiers[#modifiers + 1] = modifiers_table[idx] + end + x = math.floor(x / 2) + end + idx = idx + 1 + end + + return modifiers +end + +--- Converts a raw token list to a list of highlight ranges used by the on_win callback +--- +---@private +---@return STTokenRange[] +local function tokens_to_ranges(data, bufnr, client) + local legend = client.server_capabilities.semanticTokensProvider.legend + local token_types = legend.tokenTypes + local token_modifiers = legend.tokenModifiers + local ranges = {} + + local line + local start_char = 0 + for i = 1, #data, 5 do + local delta_line = data[i] + line = line and line + delta_line or delta_line + local delta_start = data[i + 1] + start_char = delta_line == 0 and start_char + delta_start or delta_start + + -- data[i+3] +1 because Lua tables are 1-indexed + local token_type = token_types[data[i + 3] + 1] + local modifiers = modifiers_from_number(data[i + 4], token_modifiers) + + ---@private + local function _get_byte_pos(char_pos) + return util._get_line_byte_from_position(bufnr, { + line = line, + character = char_pos, + }, client.offset_encoding) + end + + local start_col = _get_byte_pos(start_char) + local end_col = _get_byte_pos(start_char + data[i + 2]) + + if token_type then + ranges[#ranges + 1] = { + line = line, + start_col = start_col, + end_col = end_col, + type = token_type, + modifiers = modifiers, + extmark_added = false, + } + end + end + + return ranges +end + +--- Construct a new STHighlighter for the buffer +--- +---@private +---@param bufnr number +function STHighlighter.new(bufnr) + local self = setmetatable({}, { __index = STHighlighter }) + + self.bufnr = bufnr + self.augroup = api.nvim_create_augroup('vim_lsp_semantic_tokens:' .. bufnr, { clear = true }) + self.client_state = {} + + STHighlighter.active[bufnr] = self + + api.nvim_buf_attach(bufnr, false, { + on_lines = function(_, buf) + local highlighter = STHighlighter.active[buf] + if not highlighter then + return true + end + highlighter:on_change() + end, + on_reload = function(_, buf) + local highlighter = STHighlighter.active[buf] + if highlighter then + highlighter:reset() + highlighter:send_request() + end + end, + on_detach = function(_, buf) + local highlighter = STHighlighter.active[buf] + if highlighter then + highlighter:destroy() + end + end, + }) + + api.nvim_create_autocmd({ 'BufWinEnter', 'InsertLeave' }, { + buffer = self.bufnr, + group = self.augroup, + callback = function() + self:send_request() + end, + }) + + api.nvim_create_autocmd('LspDetach', { + buffer = self.bufnr, + group = self.augroup, + callback = function(args) + self:detach(args.data.client_id) + if vim.tbl_isempty(self.client_state) then + self:destroy() + end + end, + }) + + return self +end + +---@private +function STHighlighter:destroy() + for client_id, _ in pairs(self.client_state) do + self:detach(client_id) + end + + api.nvim_del_augroup_by_id(self.augroup) + STHighlighter.active[self.bufnr] = nil +end + +---@private +function STHighlighter:attach(client_id) + local state = self.client_state[client_id] + if not state then + state = { + namespace = api.nvim_create_namespace('vim_lsp_semantic_tokens:' .. client_id), + active_request = {}, + current_result = {}, + } + self.client_state[client_id] = state + end +end + +---@private +function STHighlighter:detach(client_id) + local state = self.client_state[client_id] + if state then + --TODO: delete namespace if/when that becomes possible + api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1) + self.client_state[client_id] = nil + end +end + +--- This is the entry point for getting all the tokens in a buffer. +--- +--- For the given clients (or all attached, if not provided), this sends a request +--- to ask for semantic tokens. If the server supports delta requests, that will +--- be prioritized if we have a previous requestId and token array. +--- +--- This function will skip servers where there is an already an active request in +--- flight for the same version. If there is a stale request in flight, that is +--- cancelled prior to sending a new one. +--- +--- Finally, if the request was successful, the requestId and document version +--- are saved to facilitate document synchronization in the response. +--- +---@private +function STHighlighter:send_request() + local version = util.buf_versions[self.bufnr] + + self:reset_timer() + + for client_id, state in pairs(self.client_state) do + local client = vim.lsp.get_client_by_id(client_id) + + local current_result = state.current_result + local active_request = state.active_request + + -- Only send a request for this client if the current result is out of date and + -- there isn't a current a request in flight for this version + if client and current_result.version ~= version and active_request.version ~= version then + -- cancel stale in-flight request + if active_request.request_id then + client.cancel_request(active_request.request_id) + active_request = {} + state.active_request = active_request + end + + local spec = client.server_capabilities.semanticTokensProvider.full + local hasEditProvider = type(spec) == 'table' and spec.delta + + local params = { textDocument = util.make_text_document_params(self.bufnr) } + local method = 'textDocument/semanticTokens/full' + + if hasEditProvider and current_result.result_id then + method = method .. '/delta' + params.previousResultId = current_result.result_id + end + local success, request_id = client.request(method, params, function(err, response, ctx) + -- look client up again using ctx.client_id instead of using a captured + -- client object + local c = vim.lsp.get_client_by_id(ctx.client_id) + local highlighter = STHighlighter.active[ctx.bufnr] + if not err and c and highlighter then + highlighter:process_response(response, c, version) + end + end, self.bufnr) + + if success then + active_request.request_id = request_id + active_request.version = version + end + end + end +end + +--- This function will parse the semantic token responses and set up the cache +--- (current_result). It also performs document synchronization by checking the +--- version of the document associated with the resulting request_id and only +--- performing work if the response is not out-of-date. +--- +--- Delta edits are applied if necessary, and new highlight ranges are calculated +--- and stored in the buffer state. +--- +--- Finally, a redraw command is issued to force nvim to redraw the screen to +--- pick up changed highlight tokens. +--- +---@private +function STHighlighter:process_response(response, client, version) + local state = self.client_state[client.id] + if not state then + return + end + + -- ignore stale responses + if state.active_request.version and version ~= state.active_request.version then + return + end + + -- reset active request + state.active_request = {} + + -- skip nil responses + if response == nil then + return + end + + -- if we have a response to a delta request, update the state of our tokens + -- appropriately. if it's a full response, just use that + local tokens + local token_edits = response.edits + if token_edits then + table.sort(token_edits, function(a, b) + return a.start < b.start + end) + + tokens = {} + local old_tokens = state.current_result.tokens + local idx = 1 + for _, token_edit in ipairs(token_edits) do + vim.list_extend(tokens, old_tokens, idx, token_edit.start) + if token_edit.data then + vim.list_extend(tokens, token_edit.data) + end + idx = token_edit.start + token_edit.deleteCount + 1 + end + vim.list_extend(tokens, old_tokens, idx) + else + tokens = response.data + end + + -- Update the state with the new results + local current_result = state.current_result + current_result.version = version + current_result.result_id = response.resultId + current_result.tokens = tokens + current_result.highlights = tokens_to_ranges(tokens, self.bufnr, client) + current_result.namespace_cleared = false + + api.nvim_command('redraw!') +end + +--- on_win handler for the decoration provider (see |nvim_set_decoration_provider|) +--- +--- If there is a current result for the buffer and the version matches the +--- current document version, then the tokens are valid and can be applied. As +--- the buffer is drawn, this function will add extmark highlights for every +--- token in the range of visible lines. Once a highlight has been added, it +--- sticks around until the document changes and there's a new set of matching +--- highlight tokens available. +--- +--- If this is the first time a buffer is being drawn with a new set of +--- highlights for the current document version, the namespace is cleared to +--- remove extmarks from the last version. It's done here instead of the response +--- handler to avoid the "blink" that occurs due to the timing between the +--- response handler and the actual redraw. +--- +---@private +function STHighlighter:on_win(topline, botline) + for _, state in pairs(self.client_state) do + local current_result = state.current_result + if current_result.version and current_result.version == util.buf_versions[self.bufnr] then + if not current_result.namespace_cleared then + api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1) + current_result.namespace_cleared = true + end + + -- We can't use ephemeral extmarks because the buffer updates are not in + -- sync with the list of semantic tokens. There's a delay between the + -- buffer changing and when the LSP server can respond with updated + -- tokens, and we don't want to "blink" the token highlights while + -- updates are in flight, and we don't want to use stale tokens because + -- they likely won't line up right with the actual buffer. + -- + -- Instead, we have to use normal extmarks that can attach to locations + -- in the buffer and are persisted between redraws. + local highlights = current_result.highlights + local idx = binary_search(highlights, topline) + + for i = idx, #highlights do + local token = highlights[i] + + if token.line > botline then + break + end + + if not token.extmark_added then + -- `strict = false` is necessary here for the 1% of cases where the + -- current result doesn't actually match the buffer contents. Some + -- LSP servers can respond with stale tokens on requests if they are + -- still processing changes from a didChange notification. + -- + -- LSP servers that do this _should_ follow up known stale responses + -- with a refresh notification once they've finished processing the + -- didChange notification, which would re-synchronize the tokens from + -- our end. + -- + -- The server I know of that does this is clangd when the preamble of + -- a file changes and the token request is processed with a stale + -- preamble while the new one is still being built. Once the preamble + -- finishes, clangd sends a refresh request which lets the client + -- re-synchronize the tokens. + api.nvim_buf_set_extmark(self.bufnr, state.namespace, token.line, token.start_col, { + hl_group = '@' .. token.type, + end_col = token.end_col, + priority = vim.highlight.priorities.semantic_tokens, + strict = false, + }) + + -- TODO(bfredl) use single extmark when hl_group supports table + if #token.modifiers > 0 then + for _, modifier in pairs(token.modifiers) do + api.nvim_buf_set_extmark(self.bufnr, state.namespace, token.line, token.start_col, { + hl_group = '@' .. modifier, + end_col = token.end_col, + priority = vim.highlight.priorities.semantic_tokens + 1, + strict = false, + }) + end + end + + token.extmark_added = true + end + end + end + end +end + +--- Reset the buffer's highlighting state and clears the extmark highlights. +--- +---@private +function STHighlighter:reset() + for client_id, state in pairs(self.client_state) do + api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1) + state.current_result = {} + if state.active_request.request_id then + local client = vim.lsp.get_client_by_id(client_id) + assert(client) + client.cancel_request(state.active_request.request_id) + state.active_request = {} + end + end +end + +--- Mark a client's results as dirty. This method will cancel any active +--- requests to the server and pause new highlights from being added +--- in the on_win callback. The rest of the current results are saved +--- in case the server supports delta requests. +--- +---@private +---@param client_id number +function STHighlighter:mark_dirty(client_id) + local state = self.client_state[client_id] + assert(state) + + -- if we clear the version from current_result, it'll cause the + -- next request to be sent and will also pause new highlights + -- from being added in on_win until a new result comes from + -- the server + if state.current_result then + state.current_result.version = nil + end + + if state.active_request.request_id then + local client = vim.lsp.get_client_by_id(client_id) + assert(client) + client.cancel_request(state.active_request.request_id) + state.active_request = {} + end +end + +---@private +function STHighlighter:on_change() + self:reset_timer() + if self.debounce > 0 then + self.timer = vim.defer_fn(function() + self:send_request() + end, self.debounce) + else + self:send_request() + end +end + +---@private +function STHighlighter:reset_timer() + local timer = self.timer + if timer then + self.timer = nil + if not timer:is_closing() then + timer:stop() + timer:close() + end + end +end + +local M = {} + +--- Start the semantic token highlighting engine for the given buffer with the +--- given client. The client must already be attached to the buffer. +--- +--- NOTE: This is currently called automatically by |vim.lsp.buf_attach_client()|. To +--- opt-out of semantic highlighting with a server that supports it, you can +--- delete the semanticTokensProvider table from the {server_capabilities} of +--- your client in your |LspAttach| callback or your configuration's +--- `on_attach` callback: +--- <pre>lua +--- client.server_capabilities.semanticTokensProvider = nil +--- </pre> +--- +---@param bufnr number +---@param client_id number +---@param opts (nil|table) Optional keyword arguments +--- - debounce (number, default: 200): Debounce token requests +--- to the server by the given number in milliseconds +function M.start(bufnr, client_id, opts) + vim.validate({ + bufnr = { bufnr, 'n', false }, + client_id = { client_id, 'n', false }, + }) + + opts = opts or {} + assert( + (not opts.debounce or type(opts.debounce) == 'number'), + 'opts.debounce must be a number with the debounce time in milliseconds' + ) + + local client = vim.lsp.get_client_by_id(client_id) + if not client then + vim.notify('[LSP] No client with id ' .. client_id, vim.log.levels.ERROR) + return + end + + if not vim.lsp.buf_is_attached(bufnr, client_id) then + vim.notify( + '[LSP] Client with id ' .. client_id .. ' not attached to buffer ' .. bufnr, + vim.log.levels.WARN + ) + return + end + + if not vim.tbl_get(client.server_capabilities, 'semanticTokensProvider', 'full') then + vim.notify('[LSP] Server does not support semantic tokens', vim.log.levels.WARN) + return + end + + local highlighter = STHighlighter.active[bufnr] + + if not highlighter then + highlighter = STHighlighter.new(bufnr) + highlighter.debounce = opts.debounce or 200 + else + highlighter.debounce = math.max(highlighter.debounce, opts.debounce or 200) + end + + highlighter:attach(client_id) + highlighter:send_request() +end + +--- Stop the semantic token highlighting engine for the given buffer with the +--- given client. +--- +--- NOTE: This is automatically called by a |LspDetach| autocmd that is set up as part +--- of `start()`, so you should only need this function to manually disengage the semantic +--- token engine without fully detaching the LSP client from the buffer. +--- +---@param bufnr number +---@param client_id number +function M.stop(bufnr, client_id) + vim.validate({ + bufnr = { bufnr, 'n', false }, + client_id = { client_id, 'n', false }, + }) + + local highlighter = STHighlighter.active[bufnr] + if not highlighter then + return + end + + highlighter:detach(client_id) + + if vim.tbl_isempty(highlighter.client_state) then + highlighter:destroy() + end +end + +--- Return the semantic token(s) at the given position. +--- If called without arguments, returns the token under the cursor. +--- +---@param bufnr number|nil Buffer number (0 for current buffer, default) +---@param row number|nil Position row (default cursor position) +---@param col number|nil Position column (default cursor position) +--- +---@return table|nil (table|nil) List of tokens at position +function M.get_at_pos(bufnr, row, col) + if bufnr == nil or bufnr == 0 then + bufnr = api.nvim_get_current_buf() + end + + local highlighter = STHighlighter.active[bufnr] + if not highlighter then + return + end + + if row == nil or col == nil then + local cursor = api.nvim_win_get_cursor(0) + row, col = cursor[1] - 1, cursor[2] + end + + local tokens = {} + for client_id, client in pairs(highlighter.client_state) do + local highlights = client.current_result.highlights + if highlights then + local idx = binary_search(highlights, row) + for i = idx, #highlights do + local token = highlights[i] + + if token.line > row then + break + end + + if token.start_col <= col and token.end_col > col then + token.client_id = client_id + tokens[#tokens + 1] = token + end + end + end + end + return tokens +end + +--- Force a refresh of all semantic tokens +--- +--- Only has an effect if the buffer is currently active for semantic token +--- highlighting (|vim.lsp.semantic_tokens.start()| has been called for it) +--- +---@param bufnr (nil|number) default: current buffer +function M.force_refresh(bufnr) + vim.validate({ + bufnr = { bufnr, 'n', true }, + }) + + if bufnr == nil or bufnr == 0 then + bufnr = api.nvim_get_current_buf() + end + + local highlighter = STHighlighter.active[bufnr] + if not highlighter then + return + end + + highlighter:reset() + highlighter:send_request() +end + +--- |lsp-handler| for the method `workspace/semanticTokens/refresh` +--- +--- Refresh requests are sent by the server to indicate a project-wide change +--- that requires all tokens to be re-requested by the client. This handler will +--- invalidate the current results of all buffers and automatically kick off a +--- new request for buffers that are displayed in a window. For those that aren't, a +--- the BufWinEnter event should take care of it next time it's displayed. +--- +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#semanticTokens_refreshRequest +handlers['workspace/semanticTokens/refresh'] = function(err, _, ctx) + if err then + return vim.NIL + end + + for _, bufnr in ipairs(vim.lsp.get_buffers_by_client_id(ctx.client_id)) do + local highlighter = STHighlighter.active[bufnr] + if highlighter and highlighter.client_state[ctx.client_id] then + highlighter:mark_dirty(ctx.client_id) + + if not vim.tbl_isempty(vim.fn.win_findbuf(bufnr)) then + highlighter:send_request() + end + end + end + + return vim.NIL +end + +local namespace = api.nvim_create_namespace('vim_lsp_semantic_tokens') +api.nvim_set_decoration_provider(namespace, { + on_win = function(_, _, bufnr, topline, botline) + local highlighter = STHighlighter.active[bufnr] + if highlighter then + highlighter:on_win(topline, botline) + end + end, +}) + +--- for testing only! there is no guarantee of API stability with this! +--- +---@private +M.__STHighlighter = STHighlighter + +return M diff --git a/runtime/lua/vim/lsp/sync.lua b/runtime/lua/vim/lsp/sync.lua index 0d65e86b55..826352f036 100644 --- a/runtime/lua/vim/lsp/sync.lua +++ b/runtime/lua/vim/lsp/sync.lua @@ -392,7 +392,7 @@ end ---@param lastline number line to begin search in old_lines for last difference ---@param new_lastline number line to begin search in new_lines for last difference ---@param offset_encoding string encoding requested by language server ----@returns table TextDocumentContentChangeEvent see https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#textDocumentContentChangeEvent +---@returns table TextDocumentContentChangeEvent see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentContentChangeEvent function M.compute_diff( prev_lines, curr_lines, diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index b0f9c1660e..38051e6410 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -856,7 +856,7 @@ end --- `textDocument/signatureHelp`, and potentially others. --- ---@param input (`MarkedString` | `MarkedString[]` | `MarkupContent`) ----@param contents (table, optional, default `{}`) List of strings to extend with converted lines +---@param contents (table|nil) List of strings to extend with converted lines. Defaults to {}. ---@returns {contents}, extended with lines of converted markdown. ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_hover function M.convert_input_to_markdown_lines(input, contents) @@ -1015,6 +1015,7 @@ end --- - border (string or table) override `border` --- - focusable (string or table) override `focusable` --- - zindex (string or table) override `zindex`, defaults to 50 +--- - relative ("mouse"|"cursor") defaults to "cursor" ---@returns (table) Options function M.make_floating_popup_options(width, height, opts) validate({ @@ -1029,7 +1030,8 @@ function M.make_floating_popup_options(width, height, opts) local anchor = '' local row, col - local lines_above = vim.fn.winline() - 1 + local lines_above = opts.relative == 'mouse' and vim.fn.getmousepos().line - 1 + or vim.fn.winline() - 1 local lines_below = vim.fn.winheight(0) - lines_above if lines_above < lines_below then @@ -1042,7 +1044,9 @@ function M.make_floating_popup_options(width, height, opts) row = 0 end - if vim.fn.wincol() + width + (opts.offset_x or 0) <= api.nvim_get_option('columns') then + local wincol = opts.relative == 'mouse' and vim.fn.getmousepos().column or vim.fn.wincol() + + if wincol + width + (opts.offset_x or 0) <= api.nvim_get_option('columns') then anchor = anchor .. 'W' col = 0 else @@ -1050,17 +1054,26 @@ function M.make_floating_popup_options(width, height, opts) col = 1 end + local title = (opts.border and opts.title) and opts.title or nil + local title_pos + + if title then + title_pos = opts.title_pos or 'center' + end + return { anchor = anchor, col = col + (opts.offset_x or 0), height = height, focusable = opts.focusable, - relative = 'cursor', + relative = opts.relative == 'mouse' and 'mouse' or 'cursor', row = row + (opts.offset_y or 0), style = 'minimal', width = width, border = opts.border or default_border, zindex = opts.zindex or 50, + title = title, + title_pos = title_pos, } end @@ -1068,7 +1081,7 @@ end --- ---@param location table (`Location`|`LocationLink`) ---@param offset_encoding "utf-8" | "utf-16" | "utf-32" ----@param opts table options +---@param opts table|nil options --- - reuse_win (boolean) Jump to existing window if buffer is already open. --- - focus (boolean) Whether to focus/jump to location if possible. Defaults to true. ---@return boolean `true` if succeeded @@ -1125,7 +1138,7 @@ end --- ---@param location table (`Location`|`LocationLink`) ---@param offset_encoding "utf-8" | "utf-16" | "utf-32" ----@param reuse_win boolean Jump to existing window if buffer is already open. +---@param reuse_win boolean|nil Jump to existing window if buffer is already open. ---@return boolean `true` if the jump succeeded function M.jump_to_location(location, offset_encoding, reuse_win) if offset_encoding == nil then @@ -1252,7 +1265,7 @@ function M.stylize_markdown(bufnr, contents, opts) -- when ft is nil, we get the ft from the regex match local matchers = { block = { nil, '```+([a-zA-Z0-9_]*)', '```+' }, - pre = { '', '<pre>', '</pre>' }, + pre = { nil, '<pre>([a-z0-9]*)', '</pre>' }, code = { '', '<code>', '</code>' }, text = { 'text', '<text>', '</text>' }, } @@ -1277,8 +1290,6 @@ function M.stylize_markdown(bufnr, contents, opts) -- Clean up contents = M._trim(contents, opts) - -- Insert blank line separator after code block? - local add_sep = opts.separator == nil and true or opts.separator local stripped = {} local highlights = {} -- keep track of lnums that contain markdown @@ -1306,7 +1317,7 @@ function M.stylize_markdown(bufnr, contents, opts) finish = #stripped, }) -- add a separator, but not on the last line - if add_sep and i < #contents then + if opts.separator and i < #contents then table.insert(stripped, '---') markdown_lines[#stripped] = true end @@ -1670,7 +1681,7 @@ do --[[ References ]] ---@param bufnr number Buffer id ---@param references table List of `DocumentHighlight` objects to highlight ---@param offset_encoding string One of "utf-8", "utf-16", "utf-32". - ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#documentHighlight + ---@see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentContentChangeEvent function M.buf_highlight_references(bufnr, references, offset_encoding) validate({ bufnr = { bufnr, 'n', true }, @@ -1901,7 +1912,7 @@ end --- Creates a `TextDocumentPositionParams` object for the current buffer and cursor position. --- ---@param window number|nil: window handle or 0 for current, defaults to current ----@param offset_encoding string utf-8|utf-16|utf-32|nil defaults to `offset_encoding` of first client of buffer of `window` +---@param offset_encoding string|nil utf-8|utf-16|utf-32|nil defaults to `offset_encoding` of first client of buffer of `window` ---@returns `TextDocumentPositionParams` object ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentPositionParams function M.make_position_params(window, offset_encoding) @@ -1924,7 +1935,7 @@ function M._get_offset_encoding(bufnr) local offset_encoding - for _, client in pairs(vim.lsp.buf_get_clients(bufnr)) do + for _, client in pairs(vim.lsp.get_active_clients({ bufnr = bufnr })) do if client.offset_encoding == nil then vim.notify_once( string.format( diff --git a/runtime/lua/vim/secure.lua b/runtime/lua/vim/secure.lua new file mode 100644 index 0000000000..443b152273 --- /dev/null +++ b/runtime/lua/vim/secure.lua @@ -0,0 +1,188 @@ +local M = {} + +---@private +--- Reads trust database from $XDG_STATE_HOME/nvim/trust. +--- +---@return (table) Contents of trust database, if it exists. Empty table otherwise. +local function read_trust() + local trust = {} + local f = io.open(vim.fn.stdpath('state') .. '/trust', 'r') + if f then + local contents = f:read('*a') + if contents then + for line in vim.gsplit(contents, '\n') do + local hash, file = string.match(line, '^(%S+) (.+)$') + if hash and file then + trust[file] = hash + end + end + end + f:close() + end + return trust +end + +---@private +--- Writes provided {trust} table to trust database at +--- $XDG_STATE_HOME/nvim/trust. +--- +---@param trust (table) Trust table to write +local function write_trust(trust) + vim.validate({ trust = { trust, 't' } }) + local f = assert(io.open(vim.fn.stdpath('state') .. '/trust', 'w')) + + local t = {} + for p, h in pairs(trust) do + t[#t + 1] = string.format('%s %s\n', h, p) + end + f:write(table.concat(t)) + f:close() +end + +--- Attempt to read the file at {path} prompting the user if the file should be +--- trusted. The user's choice is persisted in a trust database at +--- $XDG_STATE_HOME/nvim/trust. +--- +---@see |:trust| +--- +---@param path (string) Path to a file to read. +--- +---@return (string|nil) The contents of the given file if it exists and is +--- trusted, or nil otherwise. +function M.read(path) + vim.validate({ path = { path, 's' } }) + local fullpath = vim.loop.fs_realpath(vim.fs.normalize(path)) + if not fullpath then + return nil + end + + local trust = read_trust() + + if trust[fullpath] == '!' then + -- File is denied + return nil + end + + local contents + do + local f = io.open(fullpath, 'r') + if not f then + return nil + end + contents = f:read('*a') + f:close() + end + + local hash = vim.fn.sha256(contents) + if trust[fullpath] == hash then + -- File already exists in trust database + return contents + end + + -- File either does not exist in trust database or the hash does not match + local ok, result = pcall( + vim.fn.confirm, + string.format('%s is not trusted.', fullpath), + '&ignore\n&view\n&deny\n&allow', + 1 + ) + + if not ok and result ~= 'Keyboard interrupt' then + error(result) + elseif not ok or result == 0 or result == 1 then + -- Cancelled or ignored + return nil + elseif result == 2 then + -- View + vim.cmd('sview ' .. fullpath) + return nil + elseif result == 3 then + -- Deny + trust[fullpath] = '!' + contents = nil + elseif result == 4 then + -- Allow + trust[fullpath] = hash + end + + write_trust(trust) + + return contents +end + +--- Manage the trust database. +--- +--- The trust database is located at |$XDG_STATE_HOME|/nvim/trust. +--- +---@param opts (table): +--- - action (string): "allow" to add a file to the trust database and trust it, +--- "deny" to add a file to the trust database and deny it, +--- "remove" to remove file from the trust database +--- - path (string|nil): Path to a file to update. Mutually exclusive with {bufnr}. +--- Cannot be used when {action} is "allow". +--- - bufnr (number|nil): Buffer number to update. Mutually exclusive with {path}. +---@return (boolean, string) success, msg: +--- - true and full path of target file if operation was successful +--- - false and error message on failure +function M.trust(opts) + vim.validate({ + path = { opts.path, 's', true }, + bufnr = { opts.bufnr, 'n', true }, + action = { + opts.action, + function(m) + return m == 'allow' or m == 'deny' or m == 'remove' + end, + [["allow" or "deny" or "remove"]], + }, + }) + + local path = opts.path + local bufnr = opts.bufnr + local action = opts.action + + assert(not path or not bufnr, '"path" and "bufnr" are mutually exclusive') + + if action == 'allow' then + assert(not path, '"path" is not valid when action is "allow"') + end + + local fullpath + if path then + fullpath = vim.loop.fs_realpath(vim.fs.normalize(path)) + elseif bufnr then + local bufname = vim.api.nvim_buf_get_name(bufnr) + if bufname == '' then + return false, 'buffer is not associated with a file' + end + fullpath = vim.loop.fs_realpath(vim.fs.normalize(bufname)) + else + error('one of "path" or "bufnr" is required') + end + + if not fullpath then + return false, string.format('invalid path: %s', path) + end + + local trust = read_trust() + + if action == 'allow' then + local newline = vim.bo[bufnr].fileformat == 'unix' and '\n' or '\r\n' + local contents = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), newline) + if vim.bo[bufnr].endofline then + contents = contents .. newline + end + local hash = vim.fn.sha256(contents) + + trust[fullpath] = hash + elseif action == 'deny' then + trust[fullpath] = '!' + elseif action == 'remove' then + trust[fullpath] = nil + end + + write_trust(trust) + return true, fullpath +end + +return M diff --git a/runtime/lua/vim/shared.lua b/runtime/lua/vim/shared.lua index f03d608e56..cc48e3f193 100644 --- a/runtime/lua/vim/shared.lua +++ b/runtime/lua/vim/shared.lua @@ -49,6 +49,9 @@ vim.deepcopy = (function() if f then return f(orig, cache or {}) else + if type(orig) == 'userdata' and orig == vim.NIL then + return vim.NIL + end error('Cannot deepcopy object of type ' .. type(orig)) end end @@ -57,12 +60,13 @@ end)() --- Splits a string at each instance of a separator. --- ---@see |vim.split()| +---@see |luaref-patterns| ---@see https://www.lua.org/pil/20.2.html ---@see http://lua-users.org/wiki/StringLibraryTutorial --- ---@param s string String to split ---@param sep string Separator or pattern ----@param plain boolean If `true` use `sep` literally (passed to string.find) +---@param plain (boolean|nil) If `true` use `sep` literally (passed to string.find) ---@return fun():string (function) Iterator over the split components function vim.gsplit(s, sep, plain) vim.validate({ s = { s, 's' }, sep = { sep, 's' }, plain = { plain, 'b', true } }) @@ -99,20 +103,18 @@ end --- Splits a string at each instance of a separator. --- --- Examples: ---- <pre> ---- split(":aa::b:", ":") => {'','aa','','b',''} ---- split("axaby", "ab?") => {'','x','y'} ---- split("x*yz*o", "*", {plain=true}) => {'x','yz','o'} ---- split("|x|y|z|", "|", {trimempty=true}) => {'x', 'y', 'z'} +--- <pre>lua +--- split(":aa::b:", ":") --> {'','aa','','b',''} +--- split("axaby", "ab?") --> {'','x','y'} +--- split("x*yz*o", "*", {plain=true}) --> {'x','yz','o'} +--- split("|x|y|z|", "|", {trimempty=true}) --> {'x', 'y', 'z'} --- </pre> --- ---@see |vim.gsplit()| --- ----@alias split_kwargs {plain: boolean, trimempty: boolean} | boolean | nil ---- ---@param s string String to split ---@param sep string Separator or pattern ----@param kwargs? {plain: boolean, trimempty: boolean} (table|nil) Keyword arguments: +---@param kwargs (table|nil) Keyword arguments: --- - plain: (boolean) If `true` use `sep` literally (passed to string.find) --- - trimempty: (boolean) If `true` remove empty items from the front --- and back of the list @@ -159,8 +161,8 @@ end --- ---@see From https://github.com/premake/premake-core/blob/master/src/base/table.lua --- ----@param t table<T, any> (table) Table ---@generic T: table +---@param t table<T, any> (table) Table ---@return T[] (list) List of keys function vim.tbl_keys(t) assert(type(t) == 'table', string.format('Expected table, got %s', type(t))) @@ -382,7 +384,7 @@ end --- Return `nil` if the key does not exist. --- --- Examples: ---- <pre> +--- <pre>lua --- vim.tbl_get({ key = { nested_key = true }}, 'key', 'nested_key') == true --- vim.tbl_get({ key = {}}, 'key', 'nested_key') == nil --- </pre> @@ -394,15 +396,14 @@ end function vim.tbl_get(o, ...) local keys = { ... } if #keys == 0 then - return + return nil end for i, k in ipairs(keys) do - if type(o[k]) ~= 'table' and next(keys, i) then - return nil - end o = o[k] if o == nil then - return + return nil + elseif type(o) ~= 'table' and next(keys, i) then + return nil end end return o @@ -417,8 +418,8 @@ end ---@generic T: table ---@param dst T List which will be modified and appended to ---@param src table List from which values will be inserted ----@param start? number Start index on src. Defaults to 1 ----@param finish? number Final index on src. Defaults to `#src` +---@param start (number|nil) Start index on src. Defaults to 1 +---@param finish (number|nil) Final index on src. Defaults to `#src` ---@return T dst function vim.list_extend(dst, src, start, finish) vim.validate({ @@ -457,6 +458,33 @@ function vim.tbl_flatten(t) return result end +--- Enumerate a table sorted by its keys. +--- +---@see Based on https://github.com/premake/premake-core/blob/master/src/base/table.lua +--- +---@param t table List-like table +---@return iterator over sorted keys and their values +function vim.spairs(t) + assert(type(t) == 'table', string.format('Expected table, got %s', type(t))) + + -- collect the keys + local keys = {} + for k in pairs(t) do + table.insert(keys, k) + end + table.sort(keys) + + -- Return the iterator function. + -- TODO(justinmk): Return "iterator function, table {t}, and nil", like pairs()? + local i = 0 + return function() + i = i + 1 + if keys[i] then + return keys[i], t[keys[i]] + end + end +end + --- Tests if a Lua table can be treated as an array. --- --- Empty table `{}` is assumed to be an array, unless it was created by @@ -486,7 +514,7 @@ function vim.tbl_islist(t) -- TODO(bfredl): in the future, we will always be inside nvim -- then this check can be deleted. if vim._empty_dict_mt == nil then - return nil + return false end return getmetatable(t) ~= vim._empty_dict_mt end @@ -494,9 +522,9 @@ end --- Counts the number of non-nil values in table `t`. --- ---- <pre> ---- vim.tbl_count({ a=1, b=2 }) => 2 ---- vim.tbl_count({ 1, 2 }) => 2 +--- <pre>lua +--- vim.tbl_count({ a=1, b=2 }) --> 2 +--- vim.tbl_count({ 1, 2 }) --> 2 --- </pre> --- ---@see https://github.com/Tieske/Penlight/blob/master/lua/pl/tablex.lua @@ -516,8 +544,8 @@ end --- ---@generic T ---@param list T[] (list) Table ----@param start number Start range of slice ----@param finish number End range of slice +---@param start number|nil Start range of slice +---@param finish number|nil End range of slice ---@return T[] (list) Copy of table sliced from start to finish (inclusive) function vim.list_slice(list, start, finish) local new_list = {} @@ -529,6 +557,7 @@ end --- Trim whitespace (Lua pattern "%s") from both sides of a string. --- +---@see |luaref-patterns| ---@see https://www.lua.org/pil/20.2.html ---@param s string String to trim ---@return string String with whitespace removed from its beginning and end @@ -544,7 +573,7 @@ end ---@return string %-escaped pattern string function vim.pesc(s) vim.validate({ s = { s, 's' } }) - return s:gsub('[%(%)%.%%%+%-%*%?%[%]%^%$]', '%%%1') + return (s:gsub('[%(%)%.%%%+%-%*%?%[%]%^%$]', '%%%1')) end --- Tests if `s` starts with `prefix`. @@ -570,7 +599,7 @@ end --- Validates a parameter specification (types and values). --- --- Usage example: ---- <pre> +--- <pre>lua --- function user.new(name, age, hobbies) --- vim.validate{ --- name={name, 'string'}, @@ -582,24 +611,24 @@ end --- </pre> --- --- Examples with explicit argument values (can be run directly): ---- <pre> +--- <pre>lua --- vim.validate{arg1={{'foo'}, 'table'}, arg2={'foo', 'string'}} ---- => NOP (success) +--- --> NOP (success) --- --- vim.validate{arg1={1, 'table'}} ---- => error('arg1: expected table, got number') +--- --> error('arg1: expected table, got number') --- --- vim.validate{arg1={3, function(a) return (a % 2) == 0 end, 'even number'}} ---- => error('arg1: expected even number, got 3') +--- --> error('arg1: expected even number, got 3') --- </pre> --- --- If multiple types are valid they can be given as a list. ---- <pre> +--- <pre>lua --- vim.validate{arg1={{'foo'}, {'table', 'string'}}, arg2={'foo', {'table', 'string'}}} ---- => NOP (success) +--- --> NOP (success) --- --- vim.validate{arg1={1, {'string', table'}}} ---- => error('arg1: expected string|table, got number') +--- --> error('arg1: expected string|table, got number') --- --- </pre> --- @@ -734,7 +763,7 @@ end --- If {create} is `nil`, this will create a defaulttable whose constructor function is --- this function, effectively allowing to create nested tables on the fly: --- ---- <pre> +--- <pre>lua --- local a = vim.defaulttable() --- a.b.c = 1 --- </pre> diff --git a/runtime/lua/vim/treesitter.lua b/runtime/lua/vim/treesitter.lua index 6cd00516bf..582922ecb6 100644 --- a/runtime/lua/vim/treesitter.lua +++ b/runtime/lua/vim/treesitter.lua @@ -240,7 +240,7 @@ function M.get_captures_at_pos(bufnr, row, col) if M.is_in_node_range(node, row, col) then local c = q._query.captures[capture] -- name of the capture in the query if c ~= nil then - table.insert(matches, { capture = c, metadata = metadata }) + table.insert(matches, { capture = c, metadata = metadata, lang = tree:lang() }) end end end @@ -275,16 +275,17 @@ end ---@param row number Position row ---@param col number Position column ---@param opts table Optional keyword arguments: +--- - lang string|nil Parser language --- - ignore_injections boolean Ignore injected languages (default true) --- ----@return userdata |tsnode| under the cursor +---@return userdata|nil |tsnode| under the cursor function M.get_node_at_pos(bufnr, row, col, opts) if bufnr == 0 then bufnr = a.nvim_get_current_buf() end local ts_range = { row, col, row, col } - local root_lang_tree = M.get_parser(bufnr) + local root_lang_tree = M.get_parser(bufnr, opts.lang) if not root_lang_tree then return end @@ -313,7 +314,7 @@ end --- In this case, add ``vim.bo.syntax = 'on'`` after the call to `start`. --- --- Example: ---- <pre> +--- <pre>lua --- vim.api.nvim_create_autocmd( 'FileType', { pattern = 'tex', --- callback = function(args) --- vim.treesitter.start(args.buf, 'latex') @@ -326,12 +327,8 @@ end ---@param lang (string|nil) Language of the parser (default: buffer filetype) function M.start(bufnr, lang) bufnr = bufnr or a.nvim_get_current_buf() - local parser = M.get_parser(bufnr, lang) - M.highlighter.new(parser) - - vim.b[bufnr].ts_highlight = true end --- Stops treesitter highlighting for a buffer @@ -343,8 +340,203 @@ function M.stop(bufnr) if M.highlighter.active[bufnr] then M.highlighter.active[bufnr]:destroy() end +end + +--- Open a window that displays a textual representation of the nodes in the language tree. +--- +--- While in the window, press "a" to toggle display of anonymous nodes, "I" to toggle the +--- display of the source language of each node, and press <Enter> to jump to the node under the +--- cursor in the source buffer. +--- +---@param opts table|nil Optional options table with the following possible keys: +--- - lang (string|nil): The language of the source buffer. If omitted, the +--- filetype of the source buffer is used. +--- - bufnr (number|nil): Buffer to draw the tree into. If omitted, a new +--- buffer is created. +--- - winid (number|nil): Window id to display the tree buffer in. If omitted, +--- a new window is created with {command}. +--- - command (string|nil): Vimscript command to create the window. Default +--- value is "topleft 60vnew". Only used when {winid} is nil. +--- - title (string|fun(bufnr:number):string|nil): Title of the window. If a +--- function, it accepts the buffer number of the source buffer as its only +--- argument and should return a string. +function M.show_tree(opts) + vim.validate({ + opts = { opts, 't', true }, + }) + + opts = opts or {} + + local Playground = require('vim.treesitter.playground') + local buf = a.nvim_get_current_buf() + local win = a.nvim_get_current_win() + local pg = assert(Playground:new(buf, opts.lang)) + + -- Close any existing playground window + if vim.b[buf].playground then + local w = vim.b[buf].playground + if a.nvim_win_is_valid(w) then + a.nvim_win_close(w, true) + end + end + + local w = opts.winid + if not w then + vim.cmd(opts.command or 'topleft 60vnew') + w = a.nvim_get_current_win() + end + + local b = opts.bufnr + if b then + a.nvim_win_set_buf(w, b) + else + b = a.nvim_win_get_buf(w) + end + + vim.b[buf].playground = w + + vim.wo[w].scrolloff = 5 + vim.wo[w].wrap = false + vim.bo[b].buflisted = false + vim.bo[b].buftype = 'nofile' + vim.bo[b].bufhidden = 'wipe' + + local title = opts.title + if not title then + local bufname = a.nvim_buf_get_name(buf) + title = string.format('Syntax tree for %s', vim.fn.fnamemodify(bufname, ':.')) + elseif type(title) == 'function' then + title = title(buf) + end + + assert(type(title) == 'string', 'Window title must be a string') + a.nvim_buf_set_name(b, title) + + pg:draw(b) + + vim.fn.matchadd('Comment', '\\[[0-9:-]\\+\\]') + vim.fn.matchadd('String', '".*"') - vim.bo[bufnr].syntax = 'on' + a.nvim_buf_clear_namespace(buf, pg.ns, 0, -1) + a.nvim_buf_set_keymap(b, 'n', '<CR>', '', { + desc = 'Jump to the node under the cursor in the source buffer', + callback = function() + local row = a.nvim_win_get_cursor(w)[1] + local pos = pg:get(row) + a.nvim_set_current_win(win) + a.nvim_win_set_cursor(win, { pos.lnum + 1, pos.col }) + end, + }) + a.nvim_buf_set_keymap(b, 'n', 'a', '', { + desc = 'Toggle anonymous nodes', + callback = function() + pg.opts.anon = not pg.opts.anon + pg:draw(b) + end, + }) + a.nvim_buf_set_keymap(b, 'n', 'I', '', { + desc = 'Toggle language display', + callback = function() + pg.opts.lang = not pg.opts.lang + pg:draw(b) + end, + }) + + local group = a.nvim_create_augroup('treesitter/playground', {}) + + a.nvim_create_autocmd('CursorMoved', { + group = group, + buffer = b, + callback = function() + a.nvim_buf_clear_namespace(buf, pg.ns, 0, -1) + local row = a.nvim_win_get_cursor(w)[1] + local pos = pg:get(row) + a.nvim_buf_set_extmark(buf, pg.ns, pos.lnum, pos.col, { + end_row = pos.end_lnum, + end_col = math.max(0, pos.end_col), + hl_group = 'Visual', + }) + end, + }) + + a.nvim_create_autocmd('CursorMoved', { + group = group, + buffer = buf, + callback = function() + if not a.nvim_buf_is_loaded(b) then + return true + end + + a.nvim_buf_clear_namespace(b, pg.ns, 0, -1) + + local cursor = a.nvim_win_get_cursor(win) + local cursor_node = M.get_node_at_pos(buf, cursor[1] - 1, cursor[2], { + lang = opts.lang, + ignore_injections = false, + }) + if not cursor_node then + return + end + + local cursor_node_id = cursor_node:id() + for i, v in pg:iter() do + if v.id == cursor_node_id then + local start = v.depth + local end_col = start + #v.text + a.nvim_buf_set_extmark(b, pg.ns, i - 1, start, { + end_col = end_col, + hl_group = 'Visual', + }) + a.nvim_win_set_cursor(w, { i, 0 }) + break + end + end + end, + }) + + a.nvim_create_autocmd({ 'TextChanged', 'InsertLeave' }, { + group = group, + buffer = buf, + callback = function() + if not a.nvim_buf_is_loaded(b) then + return true + end + + pg = assert(Playground:new(buf, opts.lang)) + pg:draw(b) + end, + }) + + a.nvim_create_autocmd('BufLeave', { + group = group, + buffer = b, + callback = function() + a.nvim_buf_clear_namespace(buf, pg.ns, 0, -1) + end, + }) + + a.nvim_create_autocmd('BufLeave', { + group = group, + buffer = buf, + callback = function() + if not a.nvim_buf_is_loaded(b) then + return true + end + + a.nvim_buf_clear_namespace(b, pg.ns, 0, -1) + end, + }) + + a.nvim_create_autocmd('BufHidden', { + group = group, + buffer = buf, + once = true, + callback = function() + if a.nvim_win_is_valid(w) then + a.nvim_win_close(w, true) + end + end, + }) end return M diff --git a/runtime/lua/vim/treesitter/health.lua b/runtime/lua/vim/treesitter/health.lua index 4995c80a02..c0a1eca0ce 100644 --- a/runtime/lua/vim/treesitter/health.lua +++ b/runtime/lua/vim/treesitter/health.lua @@ -1,5 +1,6 @@ local M = {} local ts = vim.treesitter +local health = require('vim.health') --- Lists the parsers currently installed --- @@ -10,27 +11,23 @@ end --- Performs a healthcheck for treesitter integration function M.check() - local report_info = vim.fn['health#report_info'] - local report_ok = vim.fn['health#report_ok'] - local report_error = vim.fn['health#report_error'] local parsers = M.list_parsers() - report_info(string.format('Runtime ABI version : %d', ts.language_version)) + health.report_info(string.format('Nvim runtime ABI version: %d', ts.language_version)) for _, parser in pairs(parsers) do local parsername = vim.fn.fnamemodify(parser, ':t:r') - local is_loadable, ret = pcall(ts.language.require_language, parsername) - if not is_loadable then - report_error(string.format('Impossible to load parser for %s: %s', parsername, ret)) + if not is_loadable or not ret then + health.report_error( + string.format('Parser "%s" failed to load (path: %s): %s', parsername, parser, ret or '?') + ) elseif ret then local lang = ts.language.inspect_language(parsername) - report_ok( - string.format('Loaded parser for %s: ABI version %d', parsername, lang._abi_version) + health.report_ok( + string.format('Parser: %-10s ABI: %d, path: %s', parsername, lang._abi_version, parser) ) - else - report_error(string.format('Unable to load parser for %s', parsername)) end end end diff --git a/runtime/lua/vim/treesitter/highlighter.lua b/runtime/lua/vim/treesitter/highlighter.lua index 83a26aff13..d77a0d0d03 100644 --- a/runtime/lua/vim/treesitter/highlighter.lua +++ b/runtime/lua/vim/treesitter/highlighter.lua @@ -88,7 +88,10 @@ function TSHighlighter.new(tree, opts) end end + self.orig_spelloptions = vim.bo[self.bufnr].spelloptions + vim.bo[self.bufnr].syntax = '' + vim.b[self.bufnr].ts_highlight = true TSHighlighter.active[self.bufnr] = self @@ -114,6 +117,14 @@ function TSHighlighter:destroy() if TSHighlighter.active[self.bufnr] then TSHighlighter.active[self.bufnr] = nil end + + if vim.api.nvim_buf_is_loaded(self.bufnr) then + vim.bo[self.bufnr].spelloptions = self.orig_spelloptions + vim.b[self.bufnr].ts_highlight = nil + if vim.g.syntax_on == 1 then + a.nvim_exec_autocmds('FileType', { group = 'syntaxset', buffer = self.bufnr }) + end + end end ---@private @@ -164,7 +175,7 @@ function TSHighlighter:get_query(lang) end ---@private -local function on_line_impl(self, buf, line, spell) +local function on_line_impl(self, buf, line, is_spell_nav) self.tree:for_each_tree(function(tstree, tree) if not tstree then return @@ -201,17 +212,26 @@ local function on_line_impl(self, buf, line, spell) local start_row, start_col, end_row, end_col = node:range() local hl = highlighter_query.hl_cache[capture] - local is_spell = highlighter_query:query().captures[capture] == 'spell' + local capture_name = highlighter_query:query().captures[capture] + local spell = nil + if capture_name == 'spell' then + spell = true + elseif capture_name == 'nospell' then + spell = false + end + + -- Give nospell a higher priority so it always overrides spell captures. + local spell_pri_offset = capture_name == 'nospell' and 1 or 0 - if hl and end_row >= line and (not spell or is_spell) then + if hl and end_row >= line and (not is_spell_nav or spell ~= nil) then a.nvim_buf_set_extmark(buf, ns, start_row, start_col, { end_line = end_row, end_col = end_col, hl_group = hl, ephemeral = true, - priority = tonumber(metadata.priority) or 100, -- Low but leaves room below + priority = (tonumber(metadata.priority) or 100) + spell_pri_offset, -- Low but leaves room below conceal = metadata.conceal, - spell = is_spell, + spell = spell, }) end if start_row > line then diff --git a/runtime/lua/vim/treesitter/languagetree.lua b/runtime/lua/vim/treesitter/languagetree.lua index e9d70c4204..a1e96f8ef2 100644 --- a/runtime/lua/vim/treesitter/languagetree.lua +++ b/runtime/lua/vim/treesitter/languagetree.lua @@ -608,7 +608,9 @@ end ---@return userdata|nil Found |tsnode| function LanguageTree:named_node_for_range(range, opts) local tree = self:tree_for_range(range, opts) - return tree:root():named_descendant_for_range(unpack(range)) + if tree then + return tree:root():named_descendant_for_range(unpack(range)) + end end --- Gets the appropriate language that contains {range}. diff --git a/runtime/lua/vim/treesitter/playground.lua b/runtime/lua/vim/treesitter/playground.lua new file mode 100644 index 0000000000..bb073290c6 --- /dev/null +++ b/runtime/lua/vim/treesitter/playground.lua @@ -0,0 +1,186 @@ +local api = vim.api + +local M = {} + +---@class Playground +---@field ns number API namespace +---@field opts table Options table with the following keys: +--- - anon (boolean): If true, display anonymous nodes +--- - lang (boolean): If true, display the language alongside each node +--- +---@class Node +---@field id number Node id +---@field text string Node text +---@field named boolean True if this is a named (non-anonymous) node +---@field depth number Depth of the node within the tree +---@field lnum number Beginning line number of this node in the source buffer +---@field col number Beginning column number of this node in the source buffer +---@field end_lnum number Final line number of this node in the source buffer +---@field end_col number Final column number of this node in the source buffer +---@field lang string Source language of this node + +--- Traverse all child nodes starting at {node}. +--- +--- This is a recursive function. The {depth} parameter indicates the current recursion level. +--- {lang} is a string indicating the language of the tree currently being traversed. Each traversed +--- node is added to {tree}. When recursion completes, {tree} is an array of all nodes in the order +--- they were visited. +--- +--- {injections} is a table mapping node ids from the primary tree to language tree injections. Each +--- injected language has a series of trees nested within the primary language's tree, and the root +--- node of each of these trees is contained within a node in the primary tree. The {injections} +--- table maps nodes in the primary tree to root nodes of injected trees. +--- +---@param node userdata Starting node to begin traversal |tsnode| +---@param depth number Current recursion depth +---@param lang string Language of the tree currently being traversed +---@param injections table Mapping of node ids to root nodes of injected language trees (see +--- explanation above) +---@param tree Node[] Output table containing a list of tables each representing a node in the tree +---@private +local function traverse(node, depth, lang, injections, tree) + local injection = injections[node:id()] + if injection then + traverse(injection.root, depth, injection.lang, injections, tree) + end + + for child, field in node:iter_children() do + local type = child:type() + local lnum, col, end_lnum, end_col = child:range() + local named = child:named() + local text + if named then + if field then + text = string.format('%s: (%s)', field, type) + else + text = string.format('(%s)', type) + end + else + text = string.format('"%s"', type:gsub('\n', '\\n')) + end + + table.insert(tree, { + id = child:id(), + text = text, + named = named, + depth = depth, + lnum = lnum, + col = col, + end_lnum = end_lnum, + end_col = end_col, + lang = lang, + }) + + traverse(child, depth + 1, lang, injections, tree) + end + + return tree +end + +--- Create a new Playground object. +--- +---@param bufnr number Source buffer number +---@param lang string|nil Language of source buffer +--- +---@return Playground|nil +---@return string|nil Error message, if any +--- +---@private +function M.new(self, bufnr, lang) + local ok, parser = pcall(vim.treesitter.get_parser, bufnr or 0, lang) + if not ok then + return nil, 'No parser available for the given buffer' + end + + -- For each child tree (injected language), find the root of the tree and locate the node within + -- the primary tree that contains that root. Add a mapping from the node in the primary tree to + -- the root in the child tree to the {injections} table. + local root = parser:parse()[1]:root() + local injections = {} + parser:for_each_child(function(child, lang_) + child:for_each_tree(function(tree) + local r = tree:root() + local node = root:named_descendant_for_range(r:range()) + if node then + injections[node:id()] = { + lang = lang_, + root = r, + } + end + end) + end) + + local nodes = traverse(root, 0, parser:lang(), injections, {}) + + local named = {} + for _, v in ipairs(nodes) do + if v.named then + named[#named + 1] = v + end + end + + local t = { + ns = api.nvim_create_namespace(''), + nodes = nodes, + named = named, + opts = { + anon = false, + lang = false, + }, + } + + setmetatable(t, self) + self.__index = self + return t +end + +--- Write the contents of this Playground into {bufnr}. +--- +---@param bufnr number Buffer number to write into. +---@private +function M.draw(self, bufnr) + vim.bo[bufnr].modifiable = true + local lines = {} + for _, item in self:iter() do + lines[#lines + 1] = table.concat({ + string.rep(' ', item.depth), + item.text, + item.lnum == item.end_lnum + and string.format(' [%d:%d-%d]', item.lnum + 1, item.col + 1, item.end_col) + or string.format( + ' [%d:%d-%d:%d]', + item.lnum + 1, + item.col + 1, + item.end_lnum + 1, + item.end_col + ), + self.opts.lang and string.format(' %s', item.lang) or '', + }) + end + api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.bo[bufnr].modifiable = false +end + +--- Get node {i} from this Playground object. +--- +--- The node number is dependent on whether or not anonymous nodes are displayed. +--- +---@param i number Node number to get +---@return Node +---@private +function M.get(self, i) + local t = self.opts.anon and self.nodes or self.named + return t[i] +end + +--- Iterate over all of the nodes in this Playground object. +--- +---@return function Iterator over all nodes in this Playground +---@return table +---@return number +---@private +function M.iter(self) + return ipairs(self.opts.anon and self.nodes or self.named) +end + +return M diff --git a/runtime/lua/vim/treesitter/query.lua b/runtime/lua/vim/treesitter/query.lua index 7ca7384a88..dbf134573d 100644 --- a/runtime/lua/vim/treesitter/query.lua +++ b/runtime/lua/vim/treesitter/query.lua @@ -419,7 +419,8 @@ local directive_handlers = { --- Adds a new predicate to be used in queries --- ---@param name string Name of the predicate, without leading # ----@param handler function(match:string, pattern:string, bufnr:number, predicate:function) +---@param handler function(match:table, pattern:string, bufnr:number, predicate:string[]) +--- - see |vim.treesitter.query.add_directive()| for argument meanings function M.add_predicate(name, handler, force) if predicate_handlers[name] and not force then error(string.format('Overriding %s', name)) @@ -436,7 +437,12 @@ end --- metadata table `metadata[capture_id].key = value` --- ---@param name string Name of the directive, without leading # ----@param handler function(match:string, pattern:string, bufnr:number, predicate:function, metadata:table) +---@param handler function(match:table, pattern:string, bufnr:number, predicate:string[], metadata:table) +--- - match: see |treesitter-query| +--- - node-level data are accessible via `match[capture_id]` +--- - pattern: see |treesitter-query| +--- - predicate: list of strings containing the full directive being called, e.g. +--- `(node (#set! conceal "-"))` would get the predicate `{ "#set!", "conceal", "-" }` function M.add_directive(name, handler, force) if directive_handlers[name] and not force then error(string.format('Overriding %s', name)) @@ -549,7 +555,7 @@ end --- The iterator returns three values: a numeric id identifying the capture, --- the captured node, and metadata from any directives processing the match. --- The following example shows how to get captures by name: ---- <pre> +--- <pre>lua --- for id, node, metadata in query:iter_captures(tree:root(), bufnr, first, last) do --- local name = query.captures[id] -- name of the capture in the query --- -- typically useful info about the node: @@ -603,7 +609,7 @@ end --- If the query has more than one pattern, the capture table might be sparse --- and e.g. `pairs()` method should be used over `ipairs`. --- Here is an example iterating over all captures in every match: ---- <pre> +--- <pre>lua --- for pattern, match, metadata in cquery:iter_matches(tree:root(), bufnr, first, last) do --- for id, node in pairs(match) do --- local name = query.captures[id] diff --git a/runtime/lua/vim/ui.lua b/runtime/lua/vim/ui.lua index 6f1ce3089d..8f5be15221 100644 --- a/runtime/lua/vim/ui.lua +++ b/runtime/lua/vim/ui.lua @@ -21,7 +21,7 @@ local M = {} --- --- --- Example: ---- <pre> +--- <pre>lua --- vim.ui.select({ 'tabs', 'spaces' }, { --- prompt = 'Select tabs or spaces:', --- format_item = function(item) @@ -73,11 +73,12 @@ end --- user inputs. ---@param on_confirm function ((input|nil) -> ()) --- Called once the user confirms or abort the input. ---- `input` is what the user typed. +--- `input` is what the user typed (it might be +--- an empty string if nothing was entered), or --- `nil` if the user aborted the dialog. --- --- Example: ---- <pre> +--- <pre>lua --- vim.ui.input({ prompt = 'Enter value for shiftwidth: ' }, function(input) --- vim.o.shiftwidth = tonumber(input) --- end) @@ -88,11 +89,17 @@ function M.input(opts, on_confirm) }) opts = (opts and not vim.tbl_isempty(opts)) and opts or vim.empty_dict() - local input = vim.fn.input(opts) - if #input > 0 then - on_confirm(input) - else + + -- Note that vim.fn.input({}) returns an empty string when cancelled. + -- vim.ui.input() should distinguish aborting from entering an empty string. + local _canceled = vim.NIL + opts = vim.tbl_extend('keep', opts, { cancelreturn = _canceled }) + + local ok, input = pcall(vim.fn.input, opts) + if not ok or input == _canceled then on_confirm(nil) + else + on_confirm(input) end end |