aboutsummaryrefslogtreecommitdiff
path: root/runtime/lua
diff options
context:
space:
mode:
Diffstat (limited to 'runtime/lua')
-rw-r--r--runtime/lua/man.lua601
-rw-r--r--runtime/lua/vim/_meta.lua3
-rw-r--r--runtime/lua/vim/diagnostic.lua30
-rw-r--r--runtime/lua/vim/filetype.lua36
-rw-r--r--runtime/lua/vim/filetype/detect.lua13
-rw-r--r--runtime/lua/vim/inspect.lua32
-rw-r--r--runtime/lua/vim/lsp.lua93
-rw-r--r--runtime/lua/vim/lsp/buf.lua82
-rw-r--r--runtime/lua/vim/lsp/rpc.lua781
-rw-r--r--runtime/lua/vim/shared.lua25
-rw-r--r--runtime/lua/vim/treesitter.lua251
-rw-r--r--runtime/lua/vim/treesitter/highlighter.lua141
-rw-r--r--runtime/lua/vim/treesitter/language.lua14
-rw-r--r--runtime/lua/vim/treesitter/languagetree.lua44
-rw-r--r--runtime/lua/vim/treesitter/query.lua79
-rw-r--r--runtime/lua/vim/usermark.lua68
-rw-r--r--runtime/lua/vim/userreg.lua51
17 files changed, 1782 insertions, 562 deletions
diff --git a/runtime/lua/man.lua b/runtime/lua/man.lua
index 5da3d2a92f..82ed0305f7 100644
--- a/runtime/lua/man.lua
+++ b/runtime/lua/man.lua
@@ -1,7 +1,75 @@
require('vim.compat')
+local api, fn = vim.api, vim.fn
+
+local find_arg = '-w'
+local localfile_arg = true -- Always use -l if possible. #6683
local buf_hls = {}
+local M = {}
+
+local function man_error(msg)
+ M.errormsg = 'man.lua: ' .. vim.inspect(msg)
+ error(M.errormsg)
+end
+
+-- Run a system command and timeout after 30 seconds.
+local function man_system(cmd, silent)
+ local stdout_data = {}
+ local stderr_data = {}
+ local stdout = vim.loop.new_pipe(false)
+ local stderr = vim.loop.new_pipe(false)
+
+ local done = false
+ local exit_code
+
+ local handle = vim.loop.spawn(cmd[1], {
+ args = vim.list_slice(cmd, 2),
+ stdio = { nil, stdout, stderr },
+ }, function(code)
+ exit_code = code
+ stdout:close()
+ stderr:close()
+ done = true
+ end)
+
+ if handle then
+ stdout:read_start(function(_, data)
+ stdout_data[#stdout_data + 1] = data
+ end)
+ stderr:read_start(function(_, data)
+ stderr_data[#stderr_data + 1] = data
+ end)
+ else
+ stdout:close()
+ stderr:close()
+ if not silent then
+ man_error(string.format('command error: %s', table.concat(cmd)))
+ end
+ end
+
+ vim.wait(30000, function()
+ return done
+ end)
+
+ if not done then
+ if handle then
+ vim.loop.shutdown(handle)
+ stdout:close()
+ stderr:close()
+ end
+ man_error(string.format('command timed out: %s', table.concat(cmd, ' ')))
+ end
+
+ if exit_code ~= 0 and not silent then
+ man_error(
+ string.format("command error '%s': %s", table.concat(cmd, ' '), table.concat(stderr_data))
+ )
+ end
+
+ return table.concat(stdout_data)
+end
+
local function highlight_line(line, linenr)
local chars = {}
local prev_char = ''
@@ -152,21 +220,540 @@ local function highlight_line(line, linenr)
end
local function highlight_man_page()
- local mod = vim.api.nvim_buf_get_option(0, 'modifiable')
- vim.api.nvim_buf_set_option(0, 'modifiable', true)
+ local mod = vim.bo.modifiable
+ vim.bo.modifiable = true
- local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
+ local lines = api.nvim_buf_get_lines(0, 0, -1, false)
for i, line in ipairs(lines) do
lines[i] = highlight_line(line, i)
end
- vim.api.nvim_buf_set_lines(0, 0, -1, false, lines)
+ api.nvim_buf_set_lines(0, 0, -1, false, lines)
for _, args in ipairs(buf_hls) do
- vim.api.nvim_buf_add_highlight(unpack(args))
+ api.nvim_buf_add_highlight(unpack(args))
end
buf_hls = {}
- vim.api.nvim_buf_set_option(0, 'modifiable', mod)
+ vim.bo.modifiable = mod
+end
+
+-- replace spaces in a man page name with underscores
+-- intended for PostgreSQL, which has man pages like 'CREATE_TABLE(7)';
+-- while editing SQL source code, it's nice to visually select 'CREATE TABLE'
+-- and hit 'K', which requires this transformation
+local function spaces_to_underscores(str)
+ local res = str:gsub('%s', '_')
+ return res
+end
+
+local function get_path(sect, name, silent)
+ name = name or ''
+ sect = sect or ''
+ -- Some man implementations (OpenBSD) return all available paths from the
+ -- search command. Previously, this function would simply select the first one.
+ --
+ -- However, some searches will report matches that are incorrect:
+ -- man -w strlen may return string.3 followed by strlen.3, and therefore
+ -- selecting the first would get us the wrong page. Thus, we must find the
+ -- first matching one.
+ --
+ -- There's yet another special case here. Consider the following:
+ -- If you run man -w strlen and string.3 comes up first, this is a problem. We
+ -- should search for a matching named one in the results list.
+ -- However, if you search for man -w clock_gettime, you will *only* get
+ -- clock_getres.2, which is the right page. Searching the resuls for
+ -- clock_gettime will no longer work. In this case, we should just use the
+ -- first one that was found in the correct section.
+ --
+ -- Finally, we can avoid relying on -S or -s here since they are very
+ -- inconsistently supported. Instead, call -w with a section and a name.
+ local cmd
+ if sect == '' then
+ cmd = { 'man', find_arg, name }
+ else
+ cmd = { 'man', find_arg, sect, name }
+ end
+
+ local lines = man_system(cmd, silent)
+ if lines == nil then
+ return nil
+ end
+
+ local results = vim.split(lines, '\n', { trimempty = true })
+
+ if #results == 0 then
+ return
+ end
+
+ -- find any that match the specified name
+ local namematches = vim.tbl_filter(function(v)
+ return fn.fnamemodify(v, ':t'):match(name)
+ end, results) or {}
+ local sectmatches = {}
+
+ if #namematches > 0 and sect ~= '' then
+ sectmatches = vim.tbl_filter(function(v)
+ return fn.fnamemodify(v, ':e') == sect
+ end, namematches)
+ end
+
+ return fn.substitute(sectmatches[1] or namematches[1] or results[1], [[\n\+$]], '', '')
+end
+
+local function matchstr(text, pat_or_re)
+ local re = type(pat_or_re) == 'string' and vim.regex(pat_or_re) or pat_or_re
+
+ local s, e = re:match_str(text)
+
+ if s == nil then
+ return
+ end
+
+ return text:sub(vim.str_utfindex(text, s) + 1, vim.str_utfindex(text, e))
+end
+
+-- attempt to extract the name and sect out of 'name(sect)'
+-- otherwise just return the largest string of valid characters in ref
+local function extract_sect_and_name_ref(ref)
+ ref = ref or ''
+ if ref:sub(1, 1) == '-' then -- try ':Man -pandoc' with this disabled.
+ man_error("manpage name cannot start with '-'")
+ end
+ local ref1 = ref:match('[^()]+%([^()]+%)')
+ if not ref1 then
+ local name = ref:match('[^()]+')
+ if not name then
+ man_error('manpage reference cannot contain only parentheses: ' .. ref)
+ end
+ return '', spaces_to_underscores(name)
+ end
+ local parts = vim.split(ref1, '(', { plain = true })
+ -- see ':Man 3X curses' on why tolower.
+ -- TODO(nhooyr) Not sure if this is portable across OSs
+ -- but I have not seen a single uppercase section.
+ local sect = vim.split(parts[2] or '', ')', { plain = true })[1]:lower()
+ local name = spaces_to_underscores(parts[1])
+ return sect, name
+end
+
+-- verify_exists attempts to find the path to a manpage
+-- based on the passed section and name.
+--
+-- 1. If manpage could not be found with the given sect and name,
+-- then try all the sections in b:man_default_sects.
+-- 2. If it still could not be found, then we try again without a section.
+-- 3. If still not found but $MANSECT is set, then we try again with $MANSECT
+-- unset.
+local function verify_exists(sect, name)
+ if sect and sect ~= '' then
+ local ret = get_path(sect, name, true)
+ if ret then
+ return ret
+ end
+ end
+
+ if vim.b.man_default_sects ~= nil then
+ local sects = vim.split(vim.b.man_default_sects, ',', { plain = true, trimempty = true })
+ for _, sec in ipairs(sects) do
+ local ret = get_path(sec, name, true)
+ if ret then
+ return ret
+ end
+ end
+ end
+
+ -- if none of the above worked, we will try with no section
+ local res_empty_sect = get_path('', name, true)
+ if res_empty_sect then
+ return res_empty_sect
+ end
+
+ -- if that still didn't work, we will check for $MANSECT and try again with it
+ -- unset
+ if vim.env.MANSECT then
+ local mansect = vim.env.MANSECT
+ vim.env.MANSECT = nil
+ local res = get_path('', name, true)
+ vim.env.MANSECT = mansect
+ if res then
+ return res
+ end
+ end
+
+ -- finally, if that didn't work, there is no hope
+ man_error('no manual entry for ' .. name)
+end
+
+local EXT_RE = vim.regex([[\.\%([glx]z\|bz2\|lzma\|Z\)$]])
+
+-- Extracts the name/section from the 'path/name.sect', because sometimes the actual section is
+-- more specific than what we provided to `man` (try `:Man 3 App::CLI`).
+-- Also on linux, name seems to be case-insensitive. So for `:Man PRIntf`, we
+-- still want the name of the buffer to be 'printf'.
+local function extract_sect_and_name_path(path)
+ local tail = fn.fnamemodify(path, ':t')
+ if EXT_RE:match_str(path) then -- valid extensions
+ tail = fn.fnamemodify(tail, ':r')
+ end
+ local name, sect = tail:match('^(.+)%.([^.]+)$')
+ return sect, name
+end
+
+local function find_man()
+ local win = 1
+ while win <= fn.winnr('$') do
+ local buf = fn.winbufnr(win)
+ if vim.bo[buf].filetype == 'man' then
+ vim.cmd(win .. 'wincmd w')
+ return true
+ end
+ win = win + 1
+ end
+ return false
+end
+
+local function set_options(pager)
+ vim.bo.swapfile = false
+ vim.bo.buftype = 'nofile'
+ vim.bo.bufhidden = 'hide'
+ vim.bo.modified = false
+ vim.bo.readonly = true
+ vim.bo.modifiable = false
+ vim.b.pager = pager
+ vim.bo.filetype = 'man'
+end
+
+local function get_page(path, silent)
+ -- Disable hard-wrap by using a big $MANWIDTH (max 1000 on some systems #9065).
+ -- Soft-wrap: ftplugin/man.lua sets wrap/breakindent/….
+ -- Hard-wrap: driven by `man`.
+ local manwidth
+ if (vim.g.man_hardwrap or 1) ~= 1 then
+ manwidth = 999
+ elseif vim.env.MANWIDTH then
+ manwidth = vim.env.MANWIDTH
+ else
+ manwidth = api.nvim_win_get_width(0)
+ end
+ -- Force MANPAGER=cat to ensure Vim is not recursively invoked (by man-db).
+ -- http://comments.gmane.org/gmane.editors.vim.devel/29085
+ -- Set MAN_KEEP_FORMATTING so Debian man doesn't discard backspaces.
+ local cmd = { 'env', 'MANPAGER=cat', 'MANWIDTH=' .. manwidth, 'MAN_KEEP_FORMATTING=1', 'man' }
+ if localfile_arg then
+ cmd[#cmd + 1] = '-l'
+ end
+ cmd[#cmd + 1] = path
+ return man_system(cmd, silent)
+end
+
+local function put_page(page)
+ vim.bo.modified = true
+ vim.bo.readonly = false
+ vim.bo.swapfile = false
+
+ api.nvim_buf_set_lines(0, 0, -1, false, vim.split(page, '\n'))
+
+ while fn.getline(1):match('^%s*$') do
+ api.nvim_buf_set_lines(0, 0, 1, false, {})
+ end
+ -- XXX: nroff justifies text by filling it with whitespace. That interacts
+ -- badly with our use of $MANWIDTH=999. Hack around this by using a fixed
+ -- size for those whitespace regions.
+ vim.cmd([[silent! keeppatterns keepjumps %s/\s\{199,}/\=repeat(' ', 10)/g]])
+ vim.cmd('1') -- Move cursor to first line
+ highlight_man_page()
+ set_options(false)
+end
+
+local function format_candidate(path, psect)
+ if matchstr(path, [[\.\%(pdf\|in\)$]]) then -- invalid extensions
+ return ''
+ end
+ local sect, name = extract_sect_and_name_path(path)
+ if sect == psect then
+ return name
+ elseif sect and name and matchstr(sect, psect .. '.\\+$') then -- invalid extensions
+ -- We include the section if the user provided section is a prefix
+ -- of the actual section.
+ return ('%s(%s)'):format(name, sect)
+ end
+ return ''
end
-return { highlight_man_page = highlight_man_page }
+local function get_paths(sect, name, do_fallback)
+ -- callers must try-catch this, as some `man` implementations don't support `s:find_arg`
+ local ok, ret = pcall(function()
+ local mandirs =
+ table.concat(vim.split(man_system({ 'man', find_arg }), '[:\n]', { trimempty = true }), ',')
+ local paths = fn.globpath(mandirs, 'man?/' .. name .. '*.' .. sect .. '*', false, true)
+ pcall(function()
+ -- Prioritize the result from verify_exists as it obeys b:man_default_sects.
+ local first = verify_exists(sect, name)
+ paths = vim.tbl_filter(function(v)
+ return v ~= first
+ end, paths)
+ paths = { first, unpack(paths) }
+ end)
+ return paths
+ end)
+
+ if not ok then
+ if not do_fallback then
+ error(ret)
+ end
+
+ -- Fallback to a single path, with the page we're trying to find.
+ ok, ret = pcall(verify_exists, sect, name)
+
+ return { ok and ret or nil }
+ end
+ return ret or {}
+end
+
+local function complete(sect, psect, name)
+ local pages = get_paths(sect, name, false)
+ -- We remove duplicates in case the same manpage in different languages was found.
+ return fn.uniq(fn.sort(vim.tbl_map(function(v)
+ return format_candidate(v, psect)
+ end, pages) or {}, 'i'))
+end
+
+-- see extract_sect_and_name_ref on why tolower(sect)
+function M.man_complete(arg_lead, cmd_line, _)
+ local args = vim.split(cmd_line, '%s+', { trimempty = true })
+ local cmd_offset = fn.index(args, 'Man')
+ if cmd_offset > 0 then
+ -- Prune all arguments up to :Man itself. Otherwise modifier commands like
+ -- :tab, :vertical, etc. would lead to a wrong length.
+ args = vim.list_slice(args, cmd_offset + 1)
+ end
+
+ if #args > 3 then
+ return {}
+ end
+
+ if #args == 1 then
+ -- returning full completion is laggy. Require some arg_lead to complete
+ -- return complete('', '', '')
+ return {}
+ end
+
+ if arg_lead:match('^[^()]+%([^()]*$') then
+ -- cursor (|) is at ':Man printf(|' or ':Man 1 printf(|'
+ -- The later is is allowed because of ':Man pri<TAB>'.
+ -- It will offer 'priclass.d(1m)' even though section is specified as 1.
+ local tmp = vim.split(arg_lead, '(', { plain = true })
+ local name = tmp[1]
+ local sect = (tmp[2] or ''):lower()
+ return complete(sect, '', name)
+ end
+
+ if not args[2]:match('^[^()]+$') then
+ -- cursor (|) is at ':Man 3() |' or ':Man (3|' or ':Man 3() pri|'
+ -- or ':Man 3() pri |'
+ return {}
+ end
+
+ if #args == 2 then
+ local name, sect
+ if arg_lead == '' then
+ -- cursor (|) is at ':Man 1 |'
+ name = ''
+ sect = args[1]:lower()
+ else
+ -- cursor (|) is at ':Man pri|'
+ if arg_lead:match('/') then
+ -- if the name is a path, complete files
+ -- TODO(nhooyr) why does this complete the last one automatically
+ return fn.glob(arg_lead .. '*', false, true)
+ end
+ name = arg_lead
+ sect = ''
+ end
+ return complete(sect, sect, name)
+ end
+
+ if not arg_lead:match('[^()]+$') then
+ -- cursor (|) is at ':Man 3 printf |' or ':Man 3 (pr)i|'
+ return {}
+ end
+
+ -- cursor (|) is at ':Man 3 pri|'
+ local name = arg_lead
+ local sect = args[2]:lower()
+ return complete(sect, sect, name)
+end
+
+function M.goto_tag(pattern, _, _)
+ local sect, name = extract_sect_and_name_ref(pattern)
+
+ local paths = get_paths(sect, name, true)
+ local structured = {}
+
+ for _, path in ipairs(paths) do
+ sect, name = extract_sect_and_name_path(path)
+ if sect and name then
+ structured[#structured + 1] = {
+ name = name,
+ title = name .. '(' .. sect .. ')',
+ }
+ 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,
+ filename = 'man://' .. entry.title,
+ cmd = '1',
+ }
+ end, structured)
+end
+
+-- Called when Nvim is invoked as $MANPAGER.
+function M.init_pager()
+ if fn.getline(1):match('^%s*$') then
+ api.nvim_buf_set_lines(0, 0, 1, false, {})
+ else
+ vim.cmd('keepjumps 1')
+ end
+ highlight_man_page()
+ -- Guess the ref from the heading (which is usually uppercase, so we cannot
+ -- know the correct casing, cf. `man glDrawArraysInstanced`).
+ local ref = fn.substitute(matchstr(fn.getline(1), [[^[^)]\+)]]) or '', ' ', '_', 'g')
+ local ok, res = pcall(extract_sect_and_name_ref, ref)
+ vim.b.man_sect = ok and res or ''
+
+ if not fn.bufname('%'):match('man://') then -- Avoid duplicate buffers, E95.
+ vim.cmd.file({ 'man://' .. fn.fnameescape(ref):lower(), mods = { silent = true } })
+ end
+
+ set_options(true)
+end
+
+function M.open_page(count, smods, args)
+ if #args > 2 then
+ man_error('too many arguments')
+ end
+
+ local ref
+ if #args == 0 then
+ ref = vim.bo.filetype == 'man' and fn.expand('<cWORD>') or fn.expand('<cword>')
+ if ref == '' then
+ man_error('no identifier under cursor')
+ end
+ elseif #args == 1 then
+ ref = args[1]
+ else
+ -- Combine the name and sect into a manpage reference so that all
+ -- verification/extraction can be kept in a single function.
+ -- If args[2] is a reference as well, that is fine because it is the only
+ -- reference that will match.
+ ref = ('%s(%s)'):format(args[2], args[1])
+ end
+
+ local sect, name = extract_sect_and_name_ref(ref)
+ if count >= 0 then
+ sect = tostring(count)
+ end
+
+ local path = verify_exists(sect, name)
+ sect, name = extract_sect_and_name_path(path)
+
+ local buf = fn.bufnr()
+ local save_tfu = vim.bo[buf].tagfunc
+ vim.bo[buf].tagfunc = "v:lua.require'man'.goto_tag"
+
+ local target = ('%s(%s)'):format(name, sect)
+
+ local ok, ret = pcall(function()
+ if smods.tab == -1 and find_man() then
+ vim.cmd.tag({ target, mods = { silent = true, keepalt = true } })
+ else
+ smods.silent = true
+ smods.keepalt = true
+ vim.cmd.stag({ target, mods = smods })
+ end
+ end)
+
+ vim.bo[buf].tagfunc = save_tfu
+
+ if not ok then
+ error(ret)
+ else
+ set_options(false)
+ end
+
+ vim.b.man_sect = sect
+end
+
+-- Called when a man:// buffer is opened.
+function M.read_page(ref)
+ local sect, name = extract_sect_and_name_ref(ref)
+ local path = verify_exists(sect, name)
+ sect = extract_sect_and_name_path(path)
+ local page = get_page(path)
+ vim.b.man_sect = sect
+ put_page(page)
+end
+
+function M.show_toc()
+ local bufname = fn.bufname('%')
+ local info = fn.getloclist(0, { winid = 1 })
+ if info ~= '' and vim.w[info.winid].qf_toc == bufname then
+ vim.cmd.lopen()
+ return
+ end
+
+ local toc = {}
+ local lnum = 2
+ local last_line = fn.line('$') - 1
+ local section_title_re = vim.regex([[^\%( \{3\}\)\=\S.*$]])
+ local flag_title_re = vim.regex([[^\s\+\%(+\|-\)\S\+]])
+ while lnum and lnum < last_line do
+ local text = fn.getline(lnum)
+ if section_title_re:match_str(text) then
+ -- if text is a section title
+ toc[#toc + 1] = {
+ bufnr = fn.bufnr('%'),
+ lnum = lnum,
+ text = text,
+ }
+ elseif flag_title_re:match_str(text) then
+ -- if text is a flag title. we strip whitespaces and prepend two
+ -- spaces to have a consistent format in the loclist.
+ toc[#toc + 1] = {
+ bufnr = fn.bufnr('%'),
+ lnum = lnum,
+ text = ' ' .. fn.substitute(text, [[^\s*\(.\{-}\)\s*$]], [[\1]], ''),
+ }
+ end
+ lnum = fn.nextnonblank(lnum + 1)
+ end
+
+ fn.setloclist(0, toc, ' ')
+ fn.setloclist(0, {}, 'a', { title = 'Man TOC' })
+ vim.cmd.lopen()
+ vim.w.qf_toc = bufname
+end
+
+local function init()
+ local path = get_path('', 'man', true)
+ local page
+ if path ~= nil then
+ -- Check for -l support.
+ page = get_page(path, true)
+ end
+
+ if page == '' or page == nil then
+ localfile_arg = false
+ end
+end
+
+init()
+
+return M
diff --git a/runtime/lua/vim/_meta.lua b/runtime/lua/vim/_meta.lua
index f1652718ee..0f45c916dc 100644
--- a/runtime/lua/vim/_meta.lua
+++ b/runtime/lua/vim/_meta.lua
@@ -198,7 +198,10 @@ end
-- Can be done in a separate PR.
local key_value_options = {
fillchars = true,
+ fcs = true,
listchars = true,
+ lcs = true,
+ winhighlight = true,
winhl = true,
}
diff --git a/runtime/lua/vim/diagnostic.lua b/runtime/lua/vim/diagnostic.lua
index 3f71d4f70d..4f7d8cccd5 100644
--- a/runtime/lua/vim/diagnostic.lua
+++ b/runtime/lua/vim/diagnostic.lua
@@ -45,18 +45,24 @@ local bufnr_and_namespace_cacher_mt = {
end,
}
-local diagnostic_cache = setmetatable({}, {
- __index = function(t, bufnr)
- assert(bufnr > 0, 'Invalid buffer number')
- vim.api.nvim_buf_attach(bufnr, false, {
- on_detach = function()
- rawset(t, bufnr, nil) -- clear cache
- end,
- })
- t[bufnr] = {}
- return t[bufnr]
- end,
-})
+local diagnostic_cache
+do
+ local group = vim.api.nvim_create_augroup('DiagnosticBufWipeout', {})
+ diagnostic_cache = setmetatable({}, {
+ __index = function(t, bufnr)
+ assert(bufnr > 0, 'Invalid buffer number')
+ vim.api.nvim_create_autocmd('BufWipeout', {
+ group = group,
+ buffer = bufnr,
+ callback = function()
+ rawset(t, bufnr, nil)
+ end,
+ })
+ t[bufnr] = {}
+ return t[bufnr]
+ end,
+ })
+end
local diagnostic_cache_extmarks = setmetatable({}, bufnr_and_namespace_cacher_mt)
local diagnostic_attached_buffers = {}
diff --git a/runtime/lua/vim/filetype.lua b/runtime/lua/vim/filetype.lua
index 99c98764dd..39985c948e 100644
--- a/runtime/lua/vim/filetype.lua
+++ b/runtime/lua/vim/filetype.lua
@@ -177,6 +177,7 @@ local extension = {
bbappend = 'bitbake',
bbclass = 'bitbake',
bl = 'blank',
+ blp = 'blueprint',
bsd = 'bsdl',
bsdl = 'bsdl',
bst = 'bst',
@@ -422,9 +423,11 @@ local extension = {
gdb = 'gdb',
gdmo = 'gdmo',
mo = 'gdmo',
- tres = 'gdresource',
tscn = 'gdresource',
+ tres = 'gdresource',
gd = 'gdscript',
+ gdshader = 'gdshader',
+ shader = 'gdshader',
ged = 'gedcom',
gmi = 'gemtext',
gemini = 'gemtext',
@@ -538,6 +541,7 @@ local extension = {
mjs = 'javascript',
javascript = 'javascript',
js = 'javascript',
+ jsm = 'javascript',
cjs = 'javascript',
jsx = 'javascriptreact',
clp = 'jess',
@@ -554,6 +558,8 @@ local extension = {
['json-patch'] = 'json',
json5 = 'json5',
jsonc = 'jsonc',
+ jsonnet = 'jsonnet',
+ libjsonnet = 'jsonnet',
jsp = 'jsp',
jl = 'julia',
kv = 'kivy',
@@ -741,6 +747,7 @@ local extension = {
php = 'php',
phpt = 'php',
phtml = 'php',
+ theme = 'php',
pike = 'pike',
pmod = 'pike',
rcp = 'pilrc',
@@ -900,7 +907,9 @@ local extension = {
sig = function(path, bufnr)
return require('vim.filetype.detect').sig(bufnr)
end,
- sil = 'sil',
+ sil = function(path, bufnr)
+ return require('vim.filetype.detect').sil(bufnr)
+ end,
sim = 'simula',
['s85'] = 'sinda',
sin = 'sinda',
@@ -950,6 +959,7 @@ local extension = {
srec = 'srec',
mot = 'srec',
['s19'] = 'srec',
+ srt = 'srt',
st = 'st',
imata = 'stata',
['do'] = 'stata',
@@ -1011,6 +1021,11 @@ local extension = {
dsm = 'vb',
ctl = 'vb',
vbs = 'vb',
+ vdmpp = 'vdmpp',
+ vpp = 'vdmpp',
+ vdmrt = 'vdmrt',
+ vdmsl = 'vdmsl',
+ vdm = 'vdmsl',
vr = 'vera',
vri = 'vera',
vrh = 'vera',
@@ -1371,6 +1386,8 @@ local filename = {
['EDIT_DESCRIPTION'] = 'gitcommit',
['.gitconfig'] = 'gitconfig',
['.gitmodules'] = 'gitconfig',
+ ['.gitattributes'] = 'gitattributes',
+ ['.gitignore'] = 'gitignore',
['gitolite.conf'] = 'gitolite',
['git-rebase-todo'] = 'gitrebase',
gkrellmrc = 'gkrellmrc',
@@ -1813,6 +1830,21 @@ local pattern = {
return 'gitconfig'
end
end,
+ ['.*%.git/info/attributes'] = 'gitattributes',
+ ['.*/etc/gitattributes'] = 'gitattributes',
+ ['.*/%.config/git/attributes'] = 'gitattributes',
+ ['.*/git/attributes'] = function(path, bufnr)
+ if vim.env.XDG_CONFIG_HOME and path:find(vim.env.XDG_CONFIG_HOME .. '/git/attributes') then
+ return 'gitattributes'
+ end
+ end,
+ ['.*%.git/info/exclude'] = 'gitignore',
+ ['.*/%.config/git/ignore'] = 'gitignore',
+ ['.*/git/ignore'] = function(path, bufnr)
+ if vim.env.XDG_CONFIG_HOME and path:find(vim.env.XDG_CONFIG_HOME .. '/git/ignore') then
+ return 'gitignore'
+ end
+ end,
['%.gitsendemail%.msg%.......'] = 'gitsendemail',
['gkrellmrc_.'] = 'gkrellmrc',
['.*/usr/.*/gnupg/options%.skel'] = 'gpg',
diff --git a/runtime/lua/vim/filetype/detect.lua b/runtime/lua/vim/filetype/detect.lua
index 2be9dcff88..7fc7f1b7ca 100644
--- a/runtime/lua/vim/filetype/detect.lua
+++ b/runtime/lua/vim/filetype/detect.lua
@@ -1194,6 +1194,19 @@ function M.shell(path, contents, name)
return name
end
+-- Swift Intermediate Language or SILE
+function M.sil(bufnr)
+ for _, line in ipairs(getlines(bufnr, 1, 100)) do
+ if line:find('^%s*[\\%%]') then
+ return 'sile'
+ elseif line:find('^%s*%S') then
+ return 'sil'
+ end
+ end
+ -- No clue, default to "sil"
+ return 'sil'
+end
+
-- SMIL or SNMP MIB file
function M.smi(bufnr)
local line = getlines(bufnr, 1)
diff --git a/runtime/lua/vim/inspect.lua b/runtime/lua/vim/inspect.lua
index 0a53fb203b..c232f69590 100644
--- a/runtime/lua/vim/inspect.lua
+++ b/runtime/lua/vim/inspect.lua
@@ -89,8 +89,38 @@ local function escape(str)
)
end
+-- List of lua keywords
+local luaKeywords = {
+ ['and'] = true,
+ ['break'] = true,
+ ['do'] = true,
+ ['else'] = true,
+ ['elseif'] = true,
+ ['end'] = true,
+ ['false'] = true,
+ ['for'] = true,
+ ['function'] = true,
+ ['goto'] = true,
+ ['if'] = true,
+ ['in'] = true,
+ ['local'] = true,
+ ['nil'] = true,
+ ['not'] = true,
+ ['or'] = true,
+ ['repeat'] = true,
+ ['return'] = true,
+ ['then'] = true,
+ ['true'] = true,
+ ['until'] = true,
+ ['while'] = true,
+}
+
local function isIdentifier(str)
- return type(str) == 'string' and not not str:match('^[_%a][_%a%d]*$')
+ return type(str) == 'string'
+ -- identifier must start with a letter and underscore, and be followed by letters, numbers, and underscores
+ and not not str:match('^[_%a][_%a%d]*$')
+ -- lua keywords are not valid identifiers
+ and not luaKeywords[str]
end
local flr = math.floor
diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua
index fd64c1a495..22933d8143 100644
--- a/runtime/lua/vim/lsp.lua
+++ b/runtime/lua/vim/lsp.lua
@@ -289,7 +289,12 @@ local function validate_client_config(config)
'flags.debounce_text_changes must be a number with the debounce time in milliseconds'
)
- local cmd, cmd_args = lsp._cmd_parts(config.cmd)
+ local cmd, cmd_args
+ if type(config.cmd) == 'function' then
+ cmd = config.cmd
+ else
+ cmd, cmd_args = lsp._cmd_parts(config.cmd)
+ end
local offset_encoding = valid_encodings.UTF16
if config.offset_encoding then
offset_encoding = validate_encoding(config.offset_encoding)
@@ -855,14 +860,17 @@ end
--- Used on all running clients.
--- The default implementation re-uses a client if name
--- and root_dir matches.
----@return number client_id
+---@return number|nil client_id
function lsp.start(config, opts)
opts = opts or {}
local reuse_client = opts.reuse_client
or function(client, conf)
return client.config.root_dir == conf.root_dir and client.name == conf.name
end
- config.name = config.name or (config.cmd[1] and vim.fs.basename(config.cmd[1])) or nil
+ config.name = config.name
+ if not config.name and type(config.cmd) == 'table' then
+ config.name = config.cmd[1] and vim.fs.basename(config.cmd[1]) or nil
+ end
local bufnr = api.nvim_get_current_buf()
for _, clients in ipairs({ uninitialized_clients, lsp.get_active_clients() }) do
for _, client in pairs(clients) do
@@ -893,8 +901,13 @@ end
--- The following parameters describe fields in the {config} table.
---
---
----@param cmd: (required, string or list treated like |jobstart()|) Base command
---- that initiates the LSP client.
+---@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`.
@@ -1134,41 +1147,47 @@ function lsp.start_client(config)
local namespace = vim.lsp.diagnostic.get_namespace(client_id)
vim.diagnostic.reset(namespace, bufnr)
- end)
- client_ids[client_id] = nil
- end
- if vim.tbl_isempty(client_ids) then
- vim.schedule(function()
- unset_defaults(bufnr)
+ client_ids[client_id] = nil
+ if vim.tbl_isempty(client_ids) then
+ unset_defaults(bufnr)
+ end
end)
end
end
- local client = active_clients[client_id] and active_clients[client_id]
- or uninitialized_clients[client_id]
- active_clients[client_id] = nil
- uninitialized_clients[client_id] = nil
- -- Client can be absent if executable starts, but initialize fails
- -- init/attach won't have happened
- if client then
- changetracking.reset(client)
- end
- if code ~= 0 or (signal ~= 0 and signal ~= 15) then
- local msg =
- string.format('Client %s quit with exit code %s and signal %s', client_id, code, signal)
- vim.schedule(function()
+ -- Schedule the deletion of the client object so that it exists in the execution of LspDetach
+ -- autocommands
+ vim.schedule(function()
+ local client = active_clients[client_id] and active_clients[client_id]
+ or uninitialized_clients[client_id]
+ active_clients[client_id] = nil
+ uninitialized_clients[client_id] = nil
+
+ -- Client can be absent if executable starts, but initialize fails
+ -- init/attach won't have happened
+ if client then
+ changetracking.reset(client)
+ end
+ if code ~= 0 or (signal ~= 0 and signal ~= 15) then
+ local msg =
+ string.format('Client %s quit with exit code %s and signal %s', client_id, code, signal)
vim.notify(msg, vim.log.levels.WARN)
- end)
- end
+ end
+ end)
end
-- Start the RPC client.
- local rpc = lsp_rpc.start(cmd, cmd_args, dispatch, {
- cwd = config.cmd_cwd,
- env = config.cmd_env,
- detached = config.detached,
- })
+ local rpc
+ if type(cmd) == 'function' then
+ rpc = cmd(dispatch)
+ else
+ rpc = lsp_rpc.start(cmd, cmd_args, dispatch, {
+ cwd = config.cmd_cwd,
+ env = config.cmd_env,
+ detached = config.detached,
+ })
+ end
-- Return nil if client fails to start
if not rpc then
@@ -1464,14 +1483,13 @@ function lsp.start_client(config)
--- you request to stop a client which has previously been requested to
--- shutdown, it will automatically escalate and force shutdown.
---
- ---@param force (bool, optional)
+ ---@param force boolean|nil
function client.stop(force)
- local handle = rpc.handle
- if handle:is_closing() then
+ if rpc.is_closing() then
return
end
if force or not client.initialized or graceful_shutdown_failed then
- handle:kill(15)
+ rpc.terminate()
return
end
-- Sending a signal after a process has exited is acceptable.
@@ -1480,7 +1498,7 @@ function lsp.start_client(config)
rpc.notify('exit')
else
-- If there was an error in the shutdown request, then term to be safe.
- handle:kill(15)
+ rpc.terminate()
graceful_shutdown_failed = true
end
end)
@@ -1492,7 +1510,7 @@ function lsp.start_client(config)
---@returns (bool) true if client is stopped or in the process of being
---stopped; false otherwise
function client.is_stopped()
- return rpc.handle:is_closing()
+ return rpc.is_closing()
end
---@private
@@ -1627,6 +1645,7 @@ function lsp.buf_attach_client(bufnr, client_id)
if vim.tbl_get(client.server_capabilities, 'textDocumentSync', 'openClose') then
client.notify('textDocument/didClose', params)
end
+ client.attached_buffers[bufnr] = nil
end)
util.buf_versions[bufnr] = nil
all_buffer_active_clients[bufnr] = nil
diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua
index 6a070928d9..2ce565e1d9 100644
--- a/runtime/lua/vim/lsp/buf.lua
+++ b/runtime/lua/vim/lsp/buf.lua
@@ -150,6 +150,33 @@ local function select_client(method, on_choice)
end
end
+---@private
+---@return table {start={row, col}, end={row, col}} using (1, 0) indexing
+local function range_from_selection()
+ -- TODO: Use `vim.region()` instead https://github.com/neovim/neovim/pull/13896
+
+ -- [bufnum, lnum, col, off]; both row and column 1-indexed
+ local start = vim.fn.getpos('v')
+ local end_ = vim.fn.getpos('.')
+ local start_row = start[2]
+ local start_col = start[3]
+ local end_row = end_[2]
+ local end_col = end_[3]
+
+ -- A user can start visual selection at the end and move backwards
+ -- Normalize the range to start < end
+ if start_row == end_row and end_col < start_col then
+ end_col, start_col = start_col, end_col
+ elseif end_row < start_row then
+ start_row, end_row = end_row, start_row
+ start_col, end_col = end_col, start_col
+ end
+ return {
+ ['start'] = { start_row, start_col - 1 },
+ ['end'] = { end_row, end_col - 1 },
+ }
+end
+
--- Formats a buffer using the attached (and optionally filtered) language
--- server clients.
---
@@ -184,7 +211,12 @@ end
--- Restrict formatting to the client with ID (client.id) matching this field.
--- - name (string|nil):
--- Restrict formatting to the client with name (client.name) matching this field.
-
+---
+--- - range (table|nil) Range to format.
+--- Table must contain `start` and `end` keys with {row, col} tuples using
+--- (1,0) indexing.
+--- Defaults to current selection in visual mode
+--- Defaults to `nil` in other modes, formatting the full buffer
function M.format(options)
options = options or {}
local bufnr = options.bufnr or api.nvim_get_current_buf()
@@ -206,16 +238,32 @@ function M.format(options)
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
+ local range_params =
+ util.make_given_range_params(range.start, range['end'], bufnr, client.offset_encoding)
+ params.range = range_params.range
+ end
+ return params
+ end
+
+ local method = range and 'textDocument/rangeFormatting' or 'textDocument/formatting'
if options.async then
local do_format
do_format = function(idx, client)
if not client then
return
end
- local params = util.make_formatting_params(options.formatting_options)
- client.request('textDocument/formatting', params, function(...)
- local handler = client.handlers['textDocument/formatting']
- or vim.lsp.handlers['textDocument/formatting']
+ local params = set_range(client, util.make_formatting_params(options.formatting_options))
+ client.request(method, params, function(...)
+ local handler = client.handlers[method] or vim.lsp.handlers[method]
handler(...)
do_format(next(clients, idx))
end, bufnr)
@@ -224,8 +272,8 @@ function M.format(options)
else
local timeout_ms = options.timeout_ms or 1000
for _, client in pairs(clients) do
- local params = util.make_formatting_params(options.formatting_options)
- local result, err = client.request_sync('textDocument/formatting', params, timeout_ms, bufnr)
+ local params = set_range(client, util.make_formatting_params(options.formatting_options))
+ local result, err = client.request_sync(method, params, timeout_ms, bufnr)
if result and result.result then
util.apply_text_edits(result.result, bufnr, client.offset_encoding)
elseif err then
@@ -356,6 +404,7 @@ end
---@param end_pos ({number, number}, optional) mark-indexed position.
---Defaults to the end of the last visual selection.
function M.range_formatting(options, start_pos, end_pos)
+ vim.deprecate('vim.lsp.buf.range_formatting', 'vim.lsp.formatexpr or vim.lsp.format', '0.9.0')
local params = util.make_given_range_params(start_pos, end_pos)
params.options = util.make_formatting_params(options).options
select_client('textDocument/rangeFormatting', function(client)
@@ -885,23 +934,8 @@ function M.code_action(options)
local end_ = assert(options.range['end'], 'range must have a `end` property')
params = util.make_given_range_params(start, end_)
elseif mode == 'v' or mode == 'V' then
- -- [bufnum, lnum, col, off]; both row and column 1-indexed
- local start = vim.fn.getpos('v')
- local end_ = vim.fn.getpos('.')
- local start_row = start[2]
- local start_col = start[3]
- local end_row = end_[2]
- local end_col = end_[3]
-
- -- A user can start visual selection at the end and move backwards
- -- Normalize the range to start < end
- if start_row == end_row and end_col < start_col then
- end_col, start_col = start_col, end_col
- elseif end_row < start_row then
- start_row, end_row = end_row, start_row
- start_col, end_col = end_col, start_col
- end
- params = util.make_given_range_params({ start_row, start_col - 1 }, { end_row, end_col - 1 })
+ local range = range_from_selection()
+ params = util.make_given_range_params(range.start, range['end'])
else
params = util.make_range_params()
end
diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua
index 0926912066..7d047f8958 100644
--- a/runtime/lua/vim/lsp/rpc.lua
+++ b/runtime/lua/vim/lsp/rpc.lua
@@ -241,8 +241,402 @@ function default_dispatchers.on_error(code, err)
local _ = log.error() and log.error('client_error:', client_errors[code], err)
end
+---@private
+local function create_read_loop(handle_body, on_no_chunk, on_error)
+ local parse_chunk = coroutine.wrap(request_parser_loop)
+ parse_chunk()
+ return function(err, chunk)
+ if err then
+ on_error(err)
+ return
+ end
+
+ if not chunk then
+ if on_no_chunk then
+ on_no_chunk()
+ end
+ return
+ end
+
+ while true do
+ local headers, body = parse_chunk(chunk)
+ if headers then
+ handle_body(body)
+ chunk = ''
+ else
+ break
+ end
+ end
+ end
+end
+
+---@class RpcClient
+---@field message_index number
+---@field message_callbacks table
+---@field notify_reply_callbacks table
+---@field transport table
+---@field dispatchers table
+
+---@class RpcClient
+local Client = {}
+
+---@private
+function Client:encode_and_send(payload)
+ local _ = log.debug() and log.debug('rpc.send', payload)
+ if self.transport.is_closing() then
+ return false
+ end
+ local encoded = vim.json.encode(payload)
+ self.transport.write(format_message_with_content_length(encoded))
+ return true
+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
+---@returns (bool) `true` if notification could be sent, `false` if not
+function Client:notify(method, params)
+ return self:encode_and_send({
+ jsonrpc = '2.0',
+ method = method,
+ params = params,
+ })
+end
+
+---@private
+--- sends an error object to the remote LSP process.
+function Client:send_response(request_id, err, result)
+ return self:encode_and_send({
+ id = request_id,
+ jsonrpc = '2.0',
+ error = err,
+ result = result,
+ })
+end
+
+---@private
+--- Sends a request to the LSP server and runs {callback} upon response.
+---
+---@param method (string) The invoked LSP method
+---@param params (table|nil) Parameters for the invoked LSP method
+---@param callback (function) Callback to invoke
+---@param notify_reply_callback (function|nil) Callback to invoke as soon as a request is no longer pending
+---@returns (bool, number) `(true, message_id)` if request could be sent, `false` if not
+function Client:request(method, params, callback, notify_reply_callback)
+ validate({
+ callback = { callback, 'f' },
+ notify_reply_callback = { notify_reply_callback, 'f', true },
+ })
+ self.message_index = self.message_index + 1
+ local message_id = self.message_index
+ local result = self:encode_and_send({
+ id = message_id,
+ jsonrpc = '2.0',
+ method = method,
+ params = params,
+ })
+ local message_callbacks = self.message_callbacks
+ local notify_reply_callbacks = self.notify_reply_callbacks
+ if result then
+ if message_callbacks then
+ message_callbacks[message_id] = schedule_wrap(callback)
+ else
+ return false
+ end
+ if notify_reply_callback and notify_reply_callbacks then
+ notify_reply_callbacks[message_id] = schedule_wrap(notify_reply_callback)
+ end
+ return result, message_id
+ else
+ return false
+ end
+end
+
+---@private
+function Client:on_error(errkind, ...)
+ assert(client_errors[errkind])
+ -- TODO what to do if this fails?
+ pcall(self.dispatchers.on_error, errkind, ...)
+end
+
+---@private
+function Client:pcall_handler(errkind, status, head, ...)
+ if not status then
+ self:on_error(errkind, head, ...)
+ return status, head
+ end
+ return status, head, ...
+end
+
+---@private
+function Client:try_call(errkind, fn, ...)
+ return self:pcall_handler(errkind, pcall(fn, ...))
+end
+
+-- TODO periodically check message_callbacks for old requests past a certain
+-- time and log them. This would require storing the timestamp. I could call
+-- them with an error then, perhaps.
+
+---@private
+function Client:handle_body(body)
+ local ok, decoded = pcall(vim.json.decode, body, { luanil = { object = true } })
+ if not ok then
+ self:on_error(client_errors.INVALID_SERVER_JSON, decoded)
+ return
+ end
+ local _ = log.debug() and log.debug('rpc.receive', decoded)
+
+ if type(decoded.method) == 'string' and decoded.id then
+ local err
+ -- 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 }
+ )
+ if status then
+ if not (result or err) then
+ -- TODO this can be a problem if `null` is sent for result. needs vim.NIL
+ 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
+ self:send_response(decoded.id, err, result)
+ end)
+ -- This works because we are expecting vim.NIL here
+ elseif decoded.id and (decoded.result ~= vim.NIL or decoded.error ~= vim.NIL) then
+ -- We sent a number, so we expect a number.
+ local result_id = assert(tonumber(decoded.id), 'response id must be a number')
+
+ -- Notify the user that a response was received for the request
+ local notify_reply_callbacks = self.notify_reply_callbacks
+ local notify_reply_callback = notify_reply_callbacks and notify_reply_callbacks[result_id]
+ if notify_reply_callback then
+ validate({
+ notify_reply_callback = { notify_reply_callback, 'f' },
+ })
+ notify_reply_callback(result_id)
+ notify_reply_callbacks[result_id] = nil
+ end
+
+ local message_callbacks = self.message_callbacks
+
+ -- Do not surface RequestCancelled to users, it is RPC-internal.
+ if decoded.error then
+ local mute_error = false
+ if decoded.error.code == protocol.ErrorCodes.RequestCancelled then
+ local _ = log.debug() and log.debug('Received cancellation ack', decoded)
+ mute_error = true
+ end
+
+ if mute_error then
+ -- Clear any callback since this is cancelled now.
+ -- This is safe to do assuming that these conditions hold:
+ -- - The server will not send a result callback after this cancellation.
+ -- - If the server sent this cancellation ACK after sending the result, the user of this RPC
+ -- client will ignore the result themselves.
+ if result_id and message_callbacks then
+ message_callbacks[result_id] = nil
+ end
+ return
+ end
+ end
+
+ local callback = message_callbacks and message_callbacks[result_id]
+ if callback then
+ message_callbacks[result_id] = nil
+ validate({
+ callback = { callback, 'f' },
+ })
+ if decoded.error then
+ decoded.error = setmetatable(decoded.error, {
+ __tostring = format_rpc_error,
+ })
+ end
+ self:try_call(
+ client_errors.SERVER_RESULT_CALLBACK_ERROR,
+ callback,
+ decoded.error,
+ decoded.result
+ )
+ else
+ self:on_error(client_errors.NO_RESULT_CALLBACK_FOUND, decoded)
+ local _ = log.error() and log.error('No callback found for server response id ' .. result_id)
+ end
+ elseif type(decoded.method) == 'string' then
+ -- Notification
+ self:try_call(
+ client_errors.NOTIFICATION_HANDLER_ERROR,
+ self.dispatchers.notification,
+ decoded.method,
+ decoded.params
+ )
+ else
+ -- Invalid server message
+ self:on_error(client_errors.INVALID_SERVER_MESSAGE, decoded)
+ end
+end
+
+---@private
+---@return RpcClient
+local function new_client(dispatchers, transport)
+ local state = {
+ message_index = 0,
+ message_callbacks = {},
+ notify_reply_callbacks = {},
+ transport = transport,
+ dispatchers = dispatchers,
+ }
+ return setmetatable(state, { __index = Client })
+end
+
+---@private
+---@param client RpcClient
+local function public_client(client)
+ local result = {}
+
+ ---@private
+ function result.is_closing()
+ return client.transport.is_closing()
+ end
+
+ ---@private
+ function result.terminate()
+ client.transport.terminate()
+ end
+
+ --- Sends a request to the LSP server and runs {callback} upon response.
+ ---
+ ---@param method (string) The invoked LSP method
+ ---@param params (table|nil) Parameters for the invoked LSP method
+ ---@param callback (function) Callback to invoke
+ ---@param notify_reply_callback (function|nil) Callback to invoke as soon as a request is no longer pending
+ ---@returns (bool, number) `(true, message_id)` if request could be sent, `false` if not
+ function result.request(method, params, callback, notify_reply_callback)
+ return client:request(method, params, callback, notify_reply_callback)
+ end
+
+ --- Sends a notification to the LSP server.
+ ---@param method (string) The invoked LSP method
+ ---@param params (table|nil): Parameters for the invoked LSP method
+ ---@returns (bool) `true` if notification could be sent, `false` if not
+ function result.notify(method, params)
+ return client:notify(method, params)
+ end
+
+ return result
+end
+
+---@private
+local function merge_dispatchers(dispatchers)
+ if dispatchers then
+ local user_dispatchers = dispatchers
+ dispatchers = {}
+ for dispatch_name, default_dispatch in pairs(default_dispatchers) do
+ local user_dispatcher = user_dispatchers[dispatch_name]
+ if user_dispatcher then
+ if type(user_dispatcher) ~= 'function' then
+ error(string.format('dispatcher.%s must be a function', dispatch_name))
+ end
+ -- server_request is wrapped elsewhere.
+ if
+ not (dispatch_name == 'server_request' or dispatch_name == 'on_exit') -- TODO this blocks the loop exiting for some reason.
+ then
+ user_dispatcher = schedule_wrap(user_dispatcher)
+ end
+ dispatchers[dispatch_name] = user_dispatcher
+ else
+ dispatchers[dispatch_name] = default_dispatch
+ end
+ end
+ else
+ dispatchers = default_dispatchers
+ end
+ return dispatchers
+end
+
+--- Create a LSP RPC client factory that connects via TCP to the given host
+--- and port
+---
+---@param host string
+---@param port number
+---@return function
+local function connect(host, port)
+ return function(dispatchers)
+ dispatchers = merge_dispatchers(dispatchers)
+ local tcp = uv.new_tcp()
+ local closing = false
+ local transport = {
+ write = function(msg)
+ tcp:write(msg)
+ end,
+ is_closing = function()
+ return closing
+ end,
+ terminate = function()
+ if not closing then
+ closing = true
+ tcp:shutdown()
+ tcp:close()
+ dispatchers.on_exit(0, 0)
+ end
+ end,
+ }
+ local client = new_client(dispatchers, transport)
+ tcp:connect(host, port, function(err)
+ if err then
+ vim.schedule(function()
+ vim.notify(
+ string.format('Could not connect to %s:%s, reason: %s', host, port, vim.inspect(err)),
+ vim.log.levels.WARN
+ )
+ end)
+ return
+ end
+ local handle_body = function(body)
+ client:handle_body(body)
+ end
+ tcp:read_start(create_read_loop(handle_body, transport.terminate, function(read_err)
+ client:on_error(client_errors.READ_ERROR, read_err)
+ end))
+ end)
+
+ return public_client(client)
+ end
+end
+
--- Starts an LSP server process and create an LSP RPC client object to
---- interact with it. Communication with the server is currently limited to stdio.
+--- interact with it. Communication with the spawned process happens via stdio. For
+--- communication via TCP, spawn a process manually and use |vim.lsp.rpc.connect|
---
---@param cmd (string) Command to start the LSP server.
---@param cmd_args (table) List of additional string arguments to pass to {cmd}.
@@ -261,11 +655,8 @@ end
---@returns Methods:
--- - `notify()` |vim.lsp.rpc.notify()|
--- - `request()` |vim.lsp.rpc.request()|
----
----@returns Members:
---- - {pid} (number) The LSP server's PID.
---- - {handle} A handle for low-level interaction with the LSP server process
---- |vim.loop|.
+--- - `is_closing()` returns a boolean indicating if the RPC is closing.
+--- - `terminate()` terminates the RPC client.
local function start(cmd, cmd_args, dispatchers, extra_spawn_params)
local _ = log.info()
and log.info('Starting RPC client', { cmd = cmd, args = cmd_args, extra = extra_spawn_params })
@@ -278,161 +669,64 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params)
if extra_spawn_params and extra_spawn_params.cwd then
assert(is_dir(extra_spawn_params.cwd), 'cwd must be a directory')
end
- if dispatchers then
- local user_dispatchers = dispatchers
- dispatchers = {}
- for dispatch_name, default_dispatch in pairs(default_dispatchers) do
- local user_dispatcher = user_dispatchers[dispatch_name]
- if user_dispatcher then
- if type(user_dispatcher) ~= 'function' then
- error(string.format('dispatcher.%s must be a function', dispatch_name))
- end
- -- server_request is wrapped elsewhere.
- if
- not (dispatch_name == 'server_request' or dispatch_name == 'on_exit') -- TODO this blocks the loop exiting for some reason.
- then
- user_dispatcher = schedule_wrap(user_dispatcher)
- end
- dispatchers[dispatch_name] = user_dispatcher
- else
- dispatchers[dispatch_name] = default_dispatch
- end
- end
- else
- dispatchers = default_dispatchers
- end
+ dispatchers = merge_dispatchers(dispatchers)
local stdin = uv.new_pipe(false)
local stdout = uv.new_pipe(false)
local stderr = uv.new_pipe(false)
-
- local message_index = 0
- local message_callbacks = {}
- local notify_reply_callbacks = {}
-
local handle, pid
- do
- ---@private
- --- Callback for |vim.loop.spawn()| Closes all streams and runs the `on_exit` dispatcher.
- ---@param code (number) Exit code
- ---@param signal (number) Signal that was used to terminate (if any)
- local function onexit(code, signal)
- stdin:close()
- stdout:close()
- stderr:close()
- handle:close()
- -- Make sure that message_callbacks/notify_reply_callbacks can be gc'd.
- message_callbacks = nil
- notify_reply_callbacks = nil
- dispatchers.on_exit(code, signal)
- end
- local spawn_params = {
- args = cmd_args,
- stdio = { stdin, stdout, stderr },
- detached = not is_win,
- }
- if extra_spawn_params then
- spawn_params.cwd = extra_spawn_params.cwd
- spawn_params.env = env_merge(extra_spawn_params.env)
- if extra_spawn_params.detached ~= nil then
- spawn_params.detached = extra_spawn_params.detached
- end
- end
- handle, pid = uv.spawn(cmd, spawn_params, onexit)
- if handle == nil then
- stdin:close()
- stdout:close()
- stderr:close()
- local msg = string.format('Spawning language server with cmd: `%s` failed', cmd)
- if string.match(pid, 'ENOENT') then
- msg = msg
- .. '. The language server is either not installed, missing from PATH, or not executable.'
- else
- msg = msg .. string.format(' with error message: %s', pid)
+
+ local client = new_client(dispatchers, {
+ write = function(msg)
+ stdin:write(msg)
+ end,
+ is_closing = function()
+ return handle == nil or handle:is_closing()
+ end,
+ terminate = function()
+ if handle then
+ handle:kill(15)
end
- vim.notify(msg, vim.log.levels.WARN)
- return
- end
- end
+ end,
+ })
---@private
- --- Encodes {payload} into a JSON-RPC message and sends it to the remote
- --- process.
- ---
- ---@param payload table
- ---@returns true if the payload could be scheduled, false if the main event-loop is in the process of closing.
- local function encode_and_send(payload)
- local _ = log.debug() and log.debug('rpc.send', payload)
- if handle == nil or handle:is_closing() then
- return false
- end
- local encoded = vim.json.encode(payload)
- stdin:write(format_message_with_content_length(encoded))
- return true
- end
-
- -- FIXME: DOC: Should be placed on the RPC client object returned by
- -- `start()`
- --
- --- Sends a notification to the LSP server.
- ---@param method (string) The invoked LSP method
- ---@param params (table): Parameters for the invoked LSP method
- ---@returns (bool) `true` if notification could be sent, `false` if not
- local function notify(method, params)
- return encode_and_send({
- jsonrpc = '2.0',
- method = method,
- params = params,
- })
+ --- Callback for |vim.loop.spawn()| Closes all streams and runs the `on_exit` dispatcher.
+ ---@param code (number) Exit code
+ ---@param signal (number) Signal that was used to terminate (if any)
+ local function onexit(code, signal)
+ stdin:close()
+ stdout:close()
+ stderr:close()
+ handle:close()
+ dispatchers.on_exit(code, signal)
end
-
- ---@private
- --- sends an error object to the remote LSP process.
- local function send_response(request_id, err, result)
- return encode_and_send({
- id = request_id,
- jsonrpc = '2.0',
- error = err,
- result = result,
- })
+ local spawn_params = {
+ args = cmd_args,
+ stdio = { stdin, stdout, stderr },
+ detached = not is_win,
+ }
+ if extra_spawn_params then
+ spawn_params.cwd = extra_spawn_params.cwd
+ spawn_params.env = env_merge(extra_spawn_params.env)
+ if extra_spawn_params.detached ~= nil then
+ spawn_params.detached = extra_spawn_params.detached
+ end
end
-
- -- FIXME: DOC: Should be placed on the RPC client object returned by
- -- `start()`
- --
- --- Sends a request to the LSP server and runs {callback} upon response.
- ---
- ---@param method (string) The invoked LSP method
- ---@param params (table) Parameters for the invoked LSP method
- ---@param callback (function) Callback to invoke
- ---@param notify_reply_callback (function|nil) Callback to invoke as soon as a request is no longer pending
- ---@returns (bool, number) `(true, message_id)` if request could be sent, `false` if not
- local function request(method, params, callback, notify_reply_callback)
- validate({
- callback = { callback, 'f' },
- notify_reply_callback = { notify_reply_callback, 'f', true },
- })
- message_index = message_index + 1
- local message_id = message_index
- local result = encode_and_send({
- id = message_id,
- jsonrpc = '2.0',
- method = method,
- params = params,
- })
- if result then
- if message_callbacks then
- message_callbacks[message_id] = schedule_wrap(callback)
- else
- return false
- end
- if notify_reply_callback and notify_reply_callbacks then
- notify_reply_callbacks[message_id] = schedule_wrap(notify_reply_callback)
- end
- return result, message_id
+ handle, pid = uv.spawn(cmd, spawn_params, onexit)
+ if handle == nil then
+ stdin:close()
+ stdout:close()
+ stderr:close()
+ local msg = string.format('Spawning language server with cmd: `%s` failed', cmd)
+ if string.match(pid, 'ENOENT') then
+ msg = msg
+ .. '. The language server is either not installed, missing from PATH, or not executable.'
else
- return false
+ msg = msg .. string.format(' with error message: %s', pid)
end
+ vim.notify(msg, vim.log.levels.WARN)
+ return
end
stderr:read_start(function(_, chunk)
@@ -441,195 +735,22 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params)
end
end)
- ---@private
- local function on_error(errkind, ...)
- assert(client_errors[errkind])
- -- TODO what to do if this fails?
- pcall(dispatchers.on_error, errkind, ...)
- end
- ---@private
- local function pcall_handler(errkind, status, head, ...)
- if not status then
- on_error(errkind, head, ...)
- return status, head
- end
- return status, head, ...
+ local handle_body = function(body)
+ client:handle_body(body)
end
- ---@private
- local function try_call(errkind, fn, ...)
- return pcall_handler(errkind, pcall(fn, ...))
- end
-
- -- TODO periodically check message_callbacks for old requests past a certain
- -- time and log them. This would require storing the timestamp. I could call
- -- them with an error then, perhaps.
+ stdout:read_start(create_read_loop(handle_body, nil, function(err)
+ client:on_error(client_errors.READ_ERROR, err)
+ end))
- ---@private
- local function handle_body(body)
- local ok, decoded = pcall(vim.json.decode, body, { luanil = { object = true } })
- if not ok then
- on_error(client_errors.INVALID_SERVER_JSON, decoded)
- return
- end
- local _ = log.debug() and log.debug('rpc.receive', decoded)
-
- if type(decoded.method) == 'string' and decoded.id then
- local err
- -- 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 = try_call(
- client_errors.SERVER_REQUEST_HANDLER_ERROR,
- dispatchers.server_request,
- decoded.method,
- decoded.params
- )
- local _ = log.debug()
- and log.debug(
- 'server_request: callback result',
- { status = status, result = result, err = err }
- )
- if status then
- if not (result or err) then
- -- TODO this can be a problem if `null` is sent for result. needs vim.NIL
- 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
- send_response(decoded.id, err, result)
- end)
- -- This works because we are expecting vim.NIL here
- elseif decoded.id and (decoded.result ~= vim.NIL or decoded.error ~= vim.NIL) then
- -- We sent a number, so we expect a number.
- local result_id = assert(tonumber(decoded.id), 'response id must be a number')
-
- -- Notify the user that a response was received for the request
- local notify_reply_callback = notify_reply_callbacks and notify_reply_callbacks[result_id]
- if notify_reply_callback then
- validate({
- notify_reply_callback = { notify_reply_callback, 'f' },
- })
- notify_reply_callback(result_id)
- notify_reply_callbacks[result_id] = nil
- end
-
- -- Do not surface RequestCancelled to users, it is RPC-internal.
- if decoded.error then
- local mute_error = false
- if decoded.error.code == protocol.ErrorCodes.RequestCancelled then
- local _ = log.debug() and log.debug('Received cancellation ack', decoded)
- mute_error = true
- end
-
- if mute_error then
- -- Clear any callback since this is cancelled now.
- -- This is safe to do assuming that these conditions hold:
- -- - The server will not send a result callback after this cancellation.
- -- - If the server sent this cancellation ACK after sending the result, the user of this RPC
- -- client will ignore the result themselves.
- if result_id and message_callbacks then
- message_callbacks[result_id] = nil
- end
- return
- end
- end
-
- local callback = message_callbacks and message_callbacks[result_id]
- if callback then
- message_callbacks[result_id] = nil
- validate({
- callback = { callback, 'f' },
- })
- if decoded.error then
- decoded.error = setmetatable(decoded.error, {
- __tostring = format_rpc_error,
- })
- end
- try_call(
- client_errors.SERVER_RESULT_CALLBACK_ERROR,
- callback,
- decoded.error,
- decoded.result
- )
- else
- on_error(client_errors.NO_RESULT_CALLBACK_FOUND, decoded)
- local _ = log.error()
- and log.error('No callback found for server response id ' .. result_id)
- end
- elseif type(decoded.method) == 'string' then
- -- Notification
- try_call(
- client_errors.NOTIFICATION_HANDLER_ERROR,
- dispatchers.notification,
- decoded.method,
- decoded.params
- )
- else
- -- Invalid server message
- on_error(client_errors.INVALID_SERVER_MESSAGE, decoded)
- end
- end
-
- local request_parser = coroutine.wrap(request_parser_loop)
- request_parser()
- stdout:read_start(function(err, chunk)
- if err then
- -- TODO better handling. Can these be intermittent errors?
- on_error(client_errors.READ_ERROR, err)
- return
- end
- -- This should signal that we are done reading from the client.
- if not chunk then
- return
- end
- -- Flush anything in the parser by looping until we don't get a result
- -- anymore.
- while true do
- local headers, body = request_parser(chunk)
- -- If we successfully parsed, then handle the response.
- if headers then
- handle_body(body)
- -- Set chunk to empty so that we can call request_parser to get
- -- anything existing in the parser to flush.
- chunk = ''
- else
- break
- end
- end
- end)
-
- return {
- pid = pid,
- handle = handle,
- request = request,
- notify = notify,
- }
+ return public_client(client)
end
return {
start = start,
+ connect = connect,
rpc_response_error = rpc_response_error,
format_rpc_error = format_rpc_error,
client_errors = client_errors,
+ create_read_loop = create_read_loop,
}
-- vim:sw=2 ts=2 et
diff --git a/runtime/lua/vim/shared.lua b/runtime/lua/vim/shared.lua
index e1b4ed4ea9..59cb669609 100644
--- a/runtime/lua/vim/shared.lua
+++ b/runtime/lua/vim/shared.lua
@@ -715,5 +715,30 @@ function vim.is_callable(f)
return type(m.__call) == 'function'
end
+--- Creates a table whose members are automatically created when accessed, if they don't already
+--- exist.
+---
+--- They mimic defaultdict in python.
+---
+--- If @p create is @c nil, this will create a defaulttable whose constructor function is
+--- this function, effectively allowing to create nested tables on the fly:
+---
+--- <pre>
+--- local a = vim.defaulttable()
+--- a.b.c = 1
+--- </pre>
+---
+---@param create function|nil The function called to create a missing value.
+---@return table Empty table with metamethod
+function vim.defaulttable(create)
+ create = create or vim.defaulttable
+ return setmetatable({}, {
+ __index = function(tbl, key)
+ rawset(tbl, key, create())
+ return rawget(tbl, key)
+ end,
+ })
+end
+
return vim
-- vim:sw=2 ts=2 et
diff --git a/runtime/lua/vim/treesitter.lua b/runtime/lua/vim/treesitter.lua
index 70f2c425ed..d93c485dfe 100644
--- a/runtime/lua/vim/treesitter.lua
+++ b/runtime/lua/vim/treesitter.lua
@@ -3,10 +3,7 @@ local query = require('vim.treesitter.query')
local language = require('vim.treesitter.language')
local LanguageTree = require('vim.treesitter.languagetree')
--- TODO(bfredl): currently we retain parsers for the lifetime of the buffer.
--- Consider use weak references to release parser if all plugins are done with
--- it.
-local parsers = {}
+local parsers = setmetatable({}, { __mode = 'v' })
local M = vim.tbl_extend('error', query, language)
@@ -32,9 +29,11 @@ setmetatable(M, {
---
--- It is not recommended to use this, use vim.treesitter.get_parser() instead.
---
----@param bufnr The buffer the parser will be tied to
----@param lang The language of the parser
----@param opts Options to pass to the created language tree
+---@param bufnr string Buffer the parser will be tied to (0 for current buffer)
+---@param lang string Language of the parser
+---@param opts table|nil Options to pass to the created language tree
+---
+---@returns table Created parser object
function M._create_parser(bufnr, lang, opts)
language.require_language(lang)
if bufnr == 0 then
@@ -79,11 +78,11 @@ end
--- If needed this will create the parser.
--- Unconditionally attach the provided callback
---
----@param bufnr The buffer the parser should be tied to
----@param lang The filetype of this parser
----@param opts Options object to pass to the created language tree
+---@param bufnr number|nil Buffer the parser should be tied to (default: current buffer)
+---@param lang string |nil Filetype of this parser (default: buffer filetype)
+---@param opts table|nil Options to pass to the created language tree
---
----@returns The parser
+---@returns table Parser object
function M.get_parser(bufnr, lang, opts)
opts = opts or {}
@@ -118,4 +117,234 @@ function M.get_string_parser(str, lang, opts)
return LanguageTree.new(str, lang, opts)
end
+--- Determines whether a node is the ancestor of another
+---
+---@param dest table Possible ancestor
+---@param source table Possible descendant node
+---
+---@returns (boolean) True if dest is an ancestor of source
+function M.is_ancestor(dest, source)
+ if not (dest and source) then
+ return false
+ end
+
+ local current = source
+ while current ~= nil do
+ if current == dest then
+ return true
+ end
+
+ current = current:parent()
+ end
+
+ return false
+end
+
+--- Get the node's range or unpack a range table
+---
+---@param node_or_range table
+---
+---@returns table start_row, start_col, end_row, end_col
+function M.get_node_range(node_or_range)
+ if type(node_or_range) == 'table' then
+ return unpack(node_or_range)
+ else
+ return node_or_range:range()
+ end
+end
+
+---Determines whether (line, col) position is in node range
+---
+---@param node table Node defining the range
+---@param line number Line (0-based)
+---@param col number Column (0-based)
+---
+---@returns (boolean) True if the position is in node range
+function M.is_in_node_range(node, line, col)
+ local start_line, start_col, end_line, end_col = M.get_node_range(node)
+ if line >= start_line and line <= end_line then
+ if line == start_line and line == end_line then
+ return col >= start_col and col < end_col
+ elseif line == start_line then
+ return col >= start_col
+ elseif line == end_line then
+ return col < end_col
+ else
+ return true
+ end
+ else
+ return false
+ end
+end
+
+---Determines if a node contains a range
+---@param node table
+---@param range table
+---
+---@returns (boolean) True if the node contains the range
+function M.node_contains(node, range)
+ local start_row, start_col, end_row, end_col = node:range()
+ local start_fits = start_row < range[1] or (start_row == range[1] and start_col <= range[2])
+ local end_fits = end_row > range[3] or (end_row == range[3] and end_col >= range[4])
+
+ return start_fits and end_fits
+end
+
+---Gets a list of captures for a given cursor position
+---@param bufnr number Buffer number (0 for current buffer)
+---@param row number Position row
+---@param col number Position column
+---
+---@param bufnr number Buffer number (0 for current buffer)
+---@param row number Position row
+---@param col number Position column
+---
+---@returns (table) Table of captures
+function M.get_captures_at_position(bufnr, row, col)
+ if bufnr == 0 then
+ bufnr = a.nvim_get_current_buf()
+ end
+ local buf_highlighter = M.highlighter.active[bufnr]
+
+ if not buf_highlighter then
+ return {}
+ end
+
+ local matches = {}
+
+ buf_highlighter.tree:for_each_tree(function(tstree, tree)
+ if not tstree then
+ return
+ end
+
+ local root = tstree:root()
+ local root_start_row, _, root_end_row, _ = root:range()
+
+ -- Only worry about trees within the line range
+ if root_start_row > row or root_end_row < row then
+ return
+ end
+
+ local q = buf_highlighter:get_query(tree:lang())
+
+ -- Some injected languages may not have highlight queries.
+ if not q:query() then
+ return
+ end
+
+ local iter = q:query():iter_captures(root, buf_highlighter.bufnr, row, row + 1)
+
+ for capture, node, metadata in iter do
+ 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, priority = metadata.priority })
+ end
+ end
+ end
+ end, true)
+ return matches
+end
+
+---Gets a list of captures under the cursor
+---
+---@param winnr number|nil Window handle or 0 for current window (default)
+---
+---@returns (table) Named node under the cursor
+function M.get_captures_at_cursor(winnr)
+ winnr = winnr or 0
+ local bufnr = a.nvim_win_get_buf(winnr)
+ local cursor = a.nvim_win_get_cursor(winnr)
+
+ local data = M.get_captures_at_position(bufnr, cursor[1] - 1, cursor[2])
+
+ local captures = {}
+
+ for _, capture in ipairs(data) do
+ table.insert(captures, capture.capture)
+ end
+
+ return captures
+end
+
+--- Gets the smallest named node at position
+---
+---@param bufnr number Buffer number (0 for current buffer)
+---@param row number Position row
+---@param col number Position column
+---@param opts table Optional keyword arguments:
+--- - ignore_injections boolean Ignore injected languages (default true)
+---
+---@returns (table) Named node under the cursor
+function M.get_node_at_position(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)
+ if not root_lang_tree then
+ return
+ end
+
+ return root_lang_tree:named_node_for_range(ts_range, opts)
+end
+
+--- Gets the smallest named node under the cursor
+---
+---@param winnr number|nil Window handle or 0 for current window (default)
+---
+---@returns (string) Named node under the cursor
+function M.get_node_at_cursor(winnr)
+ winnr = winnr or 0
+ local bufnr = a.nvim_win_get_buf(winnr)
+ local cursor = a.nvim_win_get_cursor(winnr)
+
+ return M.get_node_at_position(bufnr, cursor[1] - 1, cursor[2], { ignore_injections = false })
+ :type()
+end
+
+--- Start treesitter highlighting for a buffer
+---
+--- Can be used in an ftplugin or FileType autocommand
+---
+--- Note: By default, disables regex syntax highlighting, which may be required for some plugins.
+--- In this case, add ``vim.bo.syntax = 'on'`` after the call to `start`.
+---
+--- Example:
+---
+--- <pre>
+--- vim.api.nvim_create_autocmd( 'FileType', { pattern = 'tex',
+--- callback = function(args)
+--- vim.treesitter.start(args.buf, 'latex')
+--- vim.bo[args.buf].syntax = 'on' -- only if additional legacy syntax is needed
+--- end
+--- })
+--- </pre>
+---
+---@param bufnr number|nil Buffer to be highlighted (default: current buffer)
+---@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
+
+---Stop treesitter highlighting for a buffer
+---
+---@param bufnr number|nil Buffer to stop highlighting (default: current buffer)
+function M.stop(bufnr)
+ bufnr = bufnr or a.nvim_get_current_buf()
+
+ if M.highlighter.active[bufnr] then
+ M.highlighter.active[bufnr]:destroy()
+ end
+
+ vim.bo[bufnr].syntax = 'on'
+end
+
return M
diff --git a/runtime/lua/vim/treesitter/highlighter.lua b/runtime/lua/vim/treesitter/highlighter.lua
index e27a5fa9c3..1e625eddb8 100644
--- a/runtime/lua/vim/treesitter/highlighter.lua
+++ b/runtime/lua/vim/treesitter/highlighter.lua
@@ -12,105 +12,18 @@ TSHighlighterQuery.__index = TSHighlighterQuery
local ns = a.nvim_create_namespace('treesitter/highlighter')
-local _default_highlights = {}
-local _link_default_highlight_once = function(from, to)
- if not _default_highlights[from] then
- _default_highlights[from] = true
- a.nvim_set_hl(0, from, { link = to, default = true })
- end
-
- return from
-end
-
--- If @definition.special does not exist use @definition instead
-local subcapture_fallback = {
- __index = function(self, capture)
- local rtn
- local shortened = capture
- while not rtn and shortened do
- shortened = shortened:match('(.*)%.')
- rtn = shortened and rawget(self, shortened)
- end
- rawset(self, capture, rtn or '__notfound')
- return rtn
- end,
-}
-
-TSHighlighter.hl_map = setmetatable({
- ['error'] = 'Error',
- ['text.underline'] = 'Underlined',
- ['todo'] = 'Todo',
- ['debug'] = 'Debug',
-
- -- Miscs
- ['comment'] = 'Comment',
- ['punctuation.delimiter'] = 'Delimiter',
- ['punctuation.bracket'] = 'Delimiter',
- ['punctuation.special'] = 'Delimiter',
-
- -- Constants
- ['constant'] = 'Constant',
- ['constant.builtin'] = 'Special',
- ['constant.macro'] = 'Define',
- ['define'] = 'Define',
- ['macro'] = 'Macro',
- ['string'] = 'String',
- ['string.regex'] = 'String',
- ['string.escape'] = 'SpecialChar',
- ['character'] = 'Character',
- ['character.special'] = 'SpecialChar',
- ['number'] = 'Number',
- ['boolean'] = 'Boolean',
- ['float'] = 'Float',
-
- -- Functions
- ['function'] = 'Function',
- ['function.special'] = 'Function',
- ['function.builtin'] = 'Special',
- ['function.macro'] = 'Macro',
- ['parameter'] = 'Identifier',
- ['method'] = 'Function',
- ['field'] = 'Identifier',
- ['property'] = 'Identifier',
- ['constructor'] = 'Special',
-
- -- Keywords
- ['conditional'] = 'Conditional',
- ['repeat'] = 'Repeat',
- ['label'] = 'Label',
- ['operator'] = 'Operator',
- ['keyword'] = 'Keyword',
- ['exception'] = 'Exception',
-
- ['type'] = 'Type',
- ['type.builtin'] = 'Type',
- ['type.qualifier'] = 'Type',
- ['type.definition'] = 'Typedef',
- ['storageclass'] = 'StorageClass',
- ['structure'] = 'Structure',
- ['include'] = 'Include',
- ['preproc'] = 'PreProc',
-}, subcapture_fallback)
-
----@private
-local function is_highlight_name(capture_name)
- local firstc = string.sub(capture_name, 1, 1)
- return firstc ~= string.lower(firstc)
-end
-
---@private
function TSHighlighterQuery.new(lang, query_string)
local self = setmetatable({}, { __index = TSHighlighterQuery })
self.hl_cache = setmetatable({}, {
__index = function(table, capture)
- local hl, is_vim_highlight = self:_get_hl_from_capture(capture)
- if not is_vim_highlight then
- hl = _link_default_highlight_once(lang .. hl, hl)
+ local name = self._query.captures[capture]
+ local id = 0
+ if not vim.startswith(name, '_') then
+ id = a.nvim_get_hl_id_by_name('@' .. name .. '.' .. lang)
end
- local id = a.nvim_get_hl_id_by_name(hl)
-
rawset(table, capture, id)
return id
end,
@@ -130,20 +43,6 @@ function TSHighlighterQuery:query()
return self._query
end
----@private
---- Get the hl from capture.
---- Returns a tuple { highlight_name: string, is_builtin: bool }
-function TSHighlighterQuery:_get_hl_from_capture(capture)
- local name = self._query.captures[capture]
-
- if is_highlight_name(name) then
- -- From "Normal.left" only keep "Normal"
- return vim.split(name, '.', true)[1], true
- else
- return TSHighlighter.hl_map[name] or 0, false
- end
-end
-
--- Creates a new highlighter using @param tree
---
---@param tree The language tree to use for highlighting
@@ -187,7 +86,7 @@ function TSHighlighter.new(tree, opts)
end
end
- a.nvim_buf_set_option(self.bufnr, 'syntax', '')
+ vim.bo[self.bufnr].syntax = ''
TSHighlighter.active[self.bufnr] = self
@@ -196,9 +95,13 @@ function TSHighlighter.new(tree, opts)
-- syntax FileType autocmds. Later on we should integrate with the
-- `:syntax` and `set syntax=...` machinery properly.
if vim.g.syntax_on ~= 1 then
- vim.api.nvim_command('runtime! syntax/synload.vim')
+ vim.cmd.runtime({ 'syntax/synload.vim', bang = true })
end
+ a.nvim_buf_call(self.bufnr, function()
+ vim.opt_local.spelloptions:append('noplainbuffer')
+ end)
+
self.tree:parse()
return self
@@ -257,7 +160,7 @@ function TSHighlighter:get_query(lang)
end
---@private
-local function on_line_impl(self, buf, line)
+local function on_line_impl(self, buf, line, spell)
self.tree:for_each_tree(function(tstree, tree)
if not tstree then
return
@@ -294,7 +197,9 @@ local function on_line_impl(self, buf, line)
local start_row, start_col, end_row, end_col = node:range()
local hl = highlighter_query.hl_cache[capture]
- if hl and end_row >= line then
+ local is_spell = highlighter_query:query().captures[capture] == 'spell'
+
+ if hl and end_row >= line and (not spell or is_spell) then
a.nvim_buf_set_extmark(buf, ns, start_row, start_col, {
end_line = end_row,
end_col = end_col,
@@ -302,6 +207,7 @@ local function on_line_impl(self, buf, line)
ephemeral = true,
priority = tonumber(metadata.priority) or 100, -- Low but leaves room below
conceal = metadata.conceal,
+ spell = is_spell,
})
end
if start_row > line then
@@ -318,7 +224,21 @@ function TSHighlighter._on_line(_, _win, buf, line, _)
return
end
- on_line_impl(self, buf, line)
+ on_line_impl(self, buf, line, false)
+end
+
+---@private
+function TSHighlighter._on_spell_nav(_, _, buf, srow, _, erow, _)
+ local self = TSHighlighter.active[buf]
+ if not self then
+ return
+ end
+
+ self:reset_highlight_state()
+
+ for row = srow, erow do
+ on_line_impl(self, buf, row, true)
+ end
end
---@private
@@ -345,6 +265,7 @@ a.nvim_set_decoration_provider(ns, {
on_buf = TSHighlighter._on_buf,
on_win = TSHighlighter._on_win,
on_line = TSHighlighter._on_line,
+ _on_spell_nav = TSHighlighter._on_spell_nav,
})
return TSHighlighter
diff --git a/runtime/lua/vim/treesitter/language.lua b/runtime/lua/vim/treesitter/language.lua
index dfb6f5be84..d14b825603 100644
--- a/runtime/lua/vim/treesitter/language.lua
+++ b/runtime/lua/vim/treesitter/language.lua
@@ -6,10 +6,11 @@ local M = {}
---
--- Parsers are searched in the `parser` runtime directory.
---
----@param lang The language the parser should parse
----@param path Optional path the parser is located at
----@param silent Don't throw an error if language not found
-function M.require_language(lang, path, silent)
+---@param lang string The language the parser should parse
+---@param path string|nil Optional path the parser is located at
+---@param silent boolean|nil Don't throw an error if language not found
+---@param symbol_name string|nil Internal symbol name for the language to load
+function M.require_language(lang, path, silent, symbol_name)
if vim._ts_has_language(lang) then
return true
end
@@ -21,7 +22,6 @@ function M.require_language(lang, path, silent)
return false
end
- -- TODO(bfredl): help tag?
error("no parser for '" .. lang .. "' language, see :help treesitter-parsers")
end
path = paths[1]
@@ -29,10 +29,10 @@ function M.require_language(lang, path, silent)
if silent then
return pcall(function()
- vim._ts_add_language(path, lang)
+ vim._ts_add_language(path, lang, symbol_name)
end)
else
- vim._ts_add_language(path, lang)
+ vim._ts_add_language(path, lang, symbol_name)
end
return true
diff --git a/runtime/lua/vim/treesitter/languagetree.lua b/runtime/lua/vim/treesitter/languagetree.lua
index 4d3b0631a2..70317a9f94 100644
--- a/runtime/lua/vim/treesitter/languagetree.lua
+++ b/runtime/lua/vim/treesitter/languagetree.lua
@@ -299,7 +299,7 @@ function LanguageTree:included_regions()
end
---@private
-local function get_node_range(node, id, metadata)
+local function get_range_from_metadata(node, id, metadata)
if metadata[id] and metadata[id].range then
return metadata[id].range
end
@@ -362,7 +362,7 @@ function LanguageTree:_get_injections()
elseif name == 'combined' then
combined = true
elseif name == 'content' and #ranges == 0 then
- table.insert(ranges, get_node_range(node, id, metadata))
+ table.insert(ranges, get_range_from_metadata(node, id, metadata))
-- Ignore any tags that start with "_"
-- Allows for other tags to be used in matches
elseif string.sub(name, 1, 1) ~= '_' then
@@ -371,7 +371,7 @@ function LanguageTree:_get_injections()
end
if #ranges == 0 then
- table.insert(ranges, get_node_range(node, id, metadata))
+ table.insert(ranges, get_range_from_metadata(node, id, metadata))
end
end
end
@@ -549,6 +549,44 @@ function LanguageTree:contains(range)
return false
end
+--- Gets the tree that contains {range}
+---
+---@param range table A text range
+---@param opts table Options table
+---@param opts.ignore_injections boolean (default true) Ignore injected languages.
+function LanguageTree:tree_for_range(range, opts)
+ opts = opts or {}
+ local ignore = vim.F.if_nil(opts.ignore_injections, true)
+
+ if not ignore then
+ for _, child in pairs(self._children) do
+ for _, tree in pairs(child:trees()) do
+ if tree_contains(tree, range) then
+ return tree
+ end
+ end
+ end
+ end
+
+ for _, tree in pairs(self._trees) do
+ if tree_contains(tree, range) then
+ return tree
+ end
+ end
+
+ return nil
+end
+
+--- Gets the smallest named node that contains {range}
+---
+---@param range table A text range
+---@param opts table Options table
+---@param opts.ignore_injections boolean (default true) Ignore injected languages.
+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))
+end
+
--- Gets the appropriate language that contains {range}
---
---@param range A text range, see |LanguageTree:contains|
diff --git a/runtime/lua/vim/treesitter/query.lua b/runtime/lua/vim/treesitter/query.lua
index 103e85abfd..90ed2a357c 100644
--- a/runtime/lua/vim/treesitter/query.lua
+++ b/runtime/lua/vim/treesitter/query.lua
@@ -34,6 +34,18 @@ local function safe_read(filename, read_quantifier)
return content
end
+---@private
+--- Adds @p ilang to @p base_langs, only if @p ilang is different than @lang
+---
+---@return boolean true it lang == ilang
+local function add_included_lang(base_langs, lang, ilang)
+ if lang == ilang then
+ return true
+ end
+ table.insert(base_langs, ilang)
+ return false
+end
+
--- Gets the list of files used to make up a query
---
---@param lang The language
@@ -47,6 +59,9 @@ function M.get_query_files(lang, query_name, is_included)
return {}
end
+ local base_query = nil
+ local extensions = {}
+
local base_langs = {}
-- Now get the base languages by looking at the first line of every file
@@ -55,35 +70,61 @@ function M.get_query_files(lang, query_name, is_included)
--
-- {language} ::= {lang} | ({lang})
local MODELINE_FORMAT = '^;+%s*inherits%s*:?%s*([a-z_,()]+)%s*$'
+ local EXTENDS_FORMAT = '^;+%s*extends%s*$'
- for _, file in ipairs(lang_files) do
- local modeline = safe_read(file, '*l')
+ for _, filename in ipairs(lang_files) do
+ local file, err = io.open(filename, 'r')
+ if not file then
+ error(err)
+ end
- if modeline then
- local langlist = modeline:match(MODELINE_FORMAT)
+ local extension = false
+ for modeline in
+ function()
+ return file:read('*l')
+ end
+ do
+ if not vim.startswith(modeline, ';') then
+ break
+ end
+
+ local langlist = modeline:match(MODELINE_FORMAT)
if langlist then
for _, incllang in ipairs(vim.split(langlist, ',', true)) do
local is_optional = incllang:match('%(.*%)')
if is_optional then
if not is_included then
- table.insert(base_langs, incllang:sub(2, #incllang - 1))
+ if add_included_lang(base_langs, lang, incllang:sub(2, #incllang - 1)) then
+ extension = true
+ end
end
else
- table.insert(base_langs, incllang)
+ if add_included_lang(base_langs, lang, incllang) then
+ extension = true
+ end
end
end
+ elseif modeline:match(EXTENDS_FORMAT) then
+ extension = true
end
end
+
+ if extension then
+ table.insert(extensions, filename)
+ elseif base_query == nil then
+ base_query = filename
+ end
+ io.close(file)
end
- local query_files = {}
+ local query_files = { base_query }
for _, base_lang in ipairs(base_langs) do
local base_files = M.get_query_files(base_lang, query_name, true)
vim.list_extend(query_files, base_files)
end
- vim.list_extend(query_files, lang_files)
+ vim.list_extend(query_files, extensions)
return query_files
end
@@ -140,12 +181,9 @@ function M.get_query(lang, query_name)
end
end
-local query_cache = setmetatable({}, {
- __index = function(tbl, key)
- rawset(tbl, key, {})
- return rawget(tbl, key)
- end,
-})
+local query_cache = vim.defaulttable(function()
+ return setmetatable({}, { __mode = 'v' })
+end)
--- Parse {query} as a string. (If the query is in a file, the caller
--- should read the contents into a string before calling).
@@ -181,9 +219,14 @@ end
--- Gets the text corresponding to a given node
---
----@param node the node
----@param source The buffer or string from which the node is extracted
-function M.get_node_text(node, source)
+---@param node table The node
+---@param source table The buffer or string from which the node is extracted
+---@param opts table Optional parameters.
+--- - concat: (boolean default true) Concatenate result in a string
+function M.get_node_text(node, source, opts)
+ opts = opts or {}
+ local concat = vim.F.if_nil(opts.concat, true)
+
local start_row, start_col, start_byte = node:start()
local end_row, end_col, end_byte = node:end_()
@@ -210,7 +253,7 @@ function M.get_node_text(node, source)
end
end
- return table.concat(lines, '\n')
+ return concat and table.concat(lines, '\n') or lines
elseif type(source) == 'string' then
return source:sub(start_byte + 1, end_byte)
end
diff --git a/runtime/lua/vim/usermark.lua b/runtime/lua/vim/usermark.lua
new file mode 100644
index 0000000000..0d1ec0ae0f
--- /dev/null
+++ b/runtime/lua/vim/usermark.lua
@@ -0,0 +1,68 @@
+-- Defualt implementation of the usermarkfunc. This default implementation is
+-- extensible and allows other plugins to register handlers for different
+-- registers.
+--
+-- The default handler behaves just as a normal register would.
+
+local vim = assert(vim)
+local usermark = {}
+
+-- Returns a "default handler" which behaves like normal global marks. When a
+-- call to set() is made, it stores the current line and col of the cursor and
+-- the filename of the current file.
+function usermark._default_handler()
+ local d = {}
+
+ -- Called when a mark is recalled using the "'" command. Just returns what was
+ -- stored before or nothing if it was never set before.
+ function d.get(self, mark)
+ return self.content or {}
+ end
+
+ -- Called when a mark is set using the "m" command. Stores the current cursor
+ -- position to be recalled at a later time.
+ function d.set(self, mark)
+ local r,c = unpack(vim.api.nvim_win_get_cursor(0))
+ local file = vim.fn.expand("%:p")
+
+ self.content = {
+ line = r;
+ col = c;
+ }
+
+ if file ~= '' then
+ self.content.file = file
+ end
+ end
+
+ return d
+end
+
+-- The store for register default handler
+usermark._marktable = {}
+
+-- Function for the 'usermarkfunc'. Will defer to the handler associated with
+-- the provided mark.
+--
+-- If not handler is registered to a given mark, the default handler is used,
+-- which is a re-implementation of standard mark behavior.
+function usermark.fn(action, mark)
+ if not usermark._marktable[mark] then
+ usermark._marktable[mark] = usermark._default_handler()
+ end
+
+ if action == "get" then
+ return usermark._marktable[mark]:get(mark)
+ else
+ usermark._marktable[mark]:set(mark)
+ return nil
+ end
+end
+
+-- Registers a handler with a mark. Gets and sets will then defer to this
+-- handler when determining the mark's behavior.
+function usermark.register_handler(mark, handler)
+ usermark._marktable[mark] = handler
+end
+
+return usermark
diff --git a/runtime/lua/vim/userreg.lua b/runtime/lua/vim/userreg.lua
new file mode 100644
index 0000000000..5abcff0407
--- /dev/null
+++ b/runtime/lua/vim/userreg.lua
@@ -0,0 +1,51 @@
+-- Defualt implementation of the userregfunc. This default implementation is
+-- extensible and allows other plugins to register handlers for different
+-- registers.
+--
+-- The default handler behaves just as a normal register would.
+
+local userreg = {}
+
+-- Returns a "default handler" which behaves exactly like the builtin registers
+-- in Vim. Simply stores whatever was yanked and returns the last thing that was
+-- yanked.
+function userreg._default_handler()
+ local d = {}
+
+ function d.do_yank(self, content)
+ self.content = content
+ end
+
+ function d.do_put(self)
+ return self.content or {}
+ end
+
+ return d
+end
+
+-- The store for registers default handler
+userreg._regtable = {}
+
+-- Function for the userreg. This function will defer to the handler registered
+-- to the given register. If no handler is registered to the given register, the
+-- default handler is used.
+function userreg.fn(action, register, content)
+ if not userreg._regtable[register] then
+ userreg._regtable[register] = userreg._default_handler()
+ end
+
+ if action == "yank" then
+ userreg._regtable[register]:do_yank(content)
+ return nil
+ else
+ return userreg._regtable[register]:do_put()
+ end
+end
+
+-- Registers a handler with a register. Future yanks and puts will defer to the
+-- handler when determining the content to put/yank.
+function userreg.register_handler(register, handler)
+ userreg._regtable[register] = handler
+end
+
+return userreg