mirror of
https://github.com/neovim/neovim.git
synced 2026-05-24 13:50:06 +00:00
Problem: `nvim_create_autocmd` is too verbose and its `callback` requires extra "nesting". Solution: Introduce `nvim_on`. Start using it internally. Then we can get a feel for how it should look before making it public.
319 lines
9.2 KiB
Lua
319 lines
9.2 KiB
Lua
local api = vim.api
|
|
local fn = vim.fn
|
|
local nvim_on = require('vim._core.util').nvim_on
|
|
|
|
local M = {}
|
|
|
|
local skip_names = { 'string', 'character', 'singlequote', 'escape', 'symbol', 'comment' }
|
|
|
|
---@param text string
|
|
---@param byte_col integer
|
|
---@return string
|
|
local function char_at(text, byte_col)
|
|
if byte_col > #text then
|
|
return ''
|
|
end
|
|
return text:sub(byte_col, byte_col + vim.str_utf_end(text, byte_col))
|
|
end
|
|
|
|
---@param text string
|
|
---@param byte_col integer
|
|
---@return string
|
|
local function char_before(text, byte_col)
|
|
local end_col = math.min(byte_col - 1, #text)
|
|
if end_col < 1 then
|
|
return ''
|
|
end
|
|
return text:sub(end_col + vim.str_utf_start(text, end_col), end_col)
|
|
end
|
|
|
|
function M.enable()
|
|
vim.g.loaded_matchparen = 1
|
|
|
|
if vim.g.matchparen_timeout == nil then
|
|
vim.g.matchparen_timeout = 300
|
|
end
|
|
if vim.g.matchparen_insert_timeout == nil then
|
|
vim.g.matchparen_insert_timeout = 60
|
|
end
|
|
if vim.g.matchparen_disable_cursor_hl == nil then
|
|
vim.g.matchparen_disable_cursor_hl = 0
|
|
end
|
|
|
|
local group = api.nvim_create_augroup('matchparen', { clear = true })
|
|
|
|
-- Replace all matchparen autocommands
|
|
nvim_on(
|
|
{ 'CursorMoved', 'CursorMovedI', 'WinEnter', 'WinScrolled', 'TextChanged', 'TextChangedI' },
|
|
group,
|
|
function()
|
|
M.highlight_matching_pair()
|
|
end
|
|
)
|
|
nvim_on('BufWinEnter', group, function()
|
|
nvim_on('SafeState', group, { once = true }, function()
|
|
M.highlight_matching_pair()
|
|
end)
|
|
end)
|
|
nvim_on({ 'WinLeave', 'BufLeave', 'TextChangedP' }, group, function()
|
|
M.remove_matches()
|
|
end)
|
|
|
|
-- Define commands that will disable and enable the plugin.
|
|
api.nvim_create_user_command('DoMatchParen', function()
|
|
M.do_matchparen()
|
|
end, { force = true })
|
|
api.nvim_create_user_command('NoMatchParen', function()
|
|
M.no_matchparen()
|
|
end, { force = true })
|
|
end
|
|
|
|
--- The function that is invoked (very often) to define a ":match" highlighting
|
|
--- for any matching paren.
|
|
---@param win? integer
|
|
function M.highlight_matching_pair(win)
|
|
win = win or api.nvim_get_current_win()
|
|
local buf = api.nvim_win_get_buf(win)
|
|
|
|
if vim.w[win].matchparen_ids == nil then
|
|
vim.w[win].matchparen_ids = {}
|
|
end
|
|
-- Remove any previous match.
|
|
M.remove_matches(win)
|
|
|
|
-- Avoid that we remove the popup menu.
|
|
if fn.pumvisible() ~= 0 then
|
|
return
|
|
end
|
|
|
|
-- Get the character under the cursor and check if it's in 'matchpairs'.
|
|
local cursor = api.nvim_win_get_cursor(win)
|
|
local c_lnum = cursor[1]
|
|
local c_col = cursor[2] + 1
|
|
local before = 0
|
|
|
|
local text = api.nvim_buf_get_lines(buf, c_lnum - 1, c_lnum, false)[1] or ''
|
|
-- Cursor columns are byte indexes,
|
|
-- while 'matchpairs' entries may be multibyte characters.
|
|
-- Use UTF-8 boundaries to extract the whole character around the byte column.
|
|
local c_before = char_before(text, c_col)
|
|
local c = char_at(text, c_col)
|
|
---@type string[]
|
|
local plist = vim.split(vim.bo[buf].matchpairs, '[:,]', { trimempty = true })
|
|
---@type integer?
|
|
local i = vim.iter(ipairs(plist)):find(function(_, item)
|
|
return item == c
|
|
end)
|
|
if i == nil then
|
|
-- not found, in Insert mode try character before the cursor
|
|
local mode = api.nvim_get_mode().mode
|
|
if c_col > 1 and (mode == 'i' or mode == 'R') then
|
|
before = #c_before
|
|
c = c_before
|
|
i = vim.iter(ipairs(plist)):find(function(_, item)
|
|
return item == c
|
|
end)
|
|
end
|
|
if i == nil then
|
|
-- not found, nothing to do
|
|
return
|
|
end
|
|
end
|
|
|
|
-- Figure out the arguments for searchpairpos().
|
|
local flags ---@type string
|
|
local c2 ---@type string
|
|
if i % 2 == 1 then
|
|
flags = 'nW'
|
|
c2 = plist[i + 1]
|
|
else
|
|
flags = 'nbW'
|
|
c2 = c
|
|
c = plist[i - 1]
|
|
end
|
|
if c == '[' then
|
|
c = [=[\[]=]
|
|
c2 = [=[\]]=]
|
|
end
|
|
|
|
-- Find the match. When it was just before the cursor move it there for a
|
|
-- moment.
|
|
local save_cursor ---@type [integer, integer, integer, integer, integer]?
|
|
if before > 0 then
|
|
save_cursor = fn.getcurpos(win)
|
|
api.nvim_win_set_cursor(win, { c_lnum, c_col - before - 1 })
|
|
end
|
|
|
|
local skip ---@type fun(): boolean
|
|
if vim.g.syntax_on == nil then
|
|
skip = function()
|
|
return false
|
|
end
|
|
elseif vim.b[buf].ts_highlight ~= nil and vim.bo[buf].syntax ~= 'on' then
|
|
skip = function()
|
|
for _, capture in ipairs(vim.treesitter.get_captures_at_cursor(win)) do
|
|
for _, skip_name in ipairs(skip_names) do
|
|
if capture:find(skip_name, 1, true) ~= nil then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
else
|
|
-- do not attempt to match when the syntax item where the cursor is
|
|
-- indicates there does not exist a matching parenthesis, e.g. for shells
|
|
-- case statement: "case $var in foobar)"
|
|
--
|
|
-- add the check behind a filetype check, so it only needs to be
|
|
-- evaluated for certain filetypes
|
|
if vim.bo[buf].filetype == 'sh' then
|
|
local pos = api.nvim_win_get_cursor(win)
|
|
---@type integer[]
|
|
local stack = api.nvim_win_call(win, function()
|
|
return fn.synstack(pos[1], pos[2] + 1)
|
|
end)
|
|
for _, id in ipairs(stack) do
|
|
if fn.synIDattr(id, 'name'):lower():find('shsnglcase') ~= nil then
|
|
if save_cursor ~= nil then
|
|
api.nvim_win_call(win, function()
|
|
fn.setpos('.', save_cursor)
|
|
end)
|
|
end
|
|
return
|
|
end
|
|
end
|
|
end
|
|
-- Build an expression that detects whether the current cursor position is
|
|
-- in certain syntax types (string, comment, etc.), for use as
|
|
-- searchpairpos()'s skip argument.
|
|
-- We match "escape" for special items, such as lispEscapeSpecial, and
|
|
-- match "symbol" for lispBarSymbol.
|
|
skip = function()
|
|
local pos = api.nvim_win_get_cursor(win)
|
|
for _, id in ipairs(fn.synstack(pos[1], pos[2] + 1)) do
|
|
local name = fn.synIDattr(id, 'name'):lower()
|
|
for _, skip_name in ipairs(skip_names) do
|
|
if name:find(skip_name, 1, true) ~= nil then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
-- If executing the expression determines that the cursor is currently in
|
|
-- one of the syntax types, then we want searchpairpos() to find the pair
|
|
-- within those syntax types (i.e., not skip). Otherwise, the cursor is
|
|
-- outside of the syntax types and skip should keep its value so we skip
|
|
-- any matching pair inside the syntax types.
|
|
if api.nvim_win_call(win, skip) then
|
|
skip = function()
|
|
return false
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Limit the search to lines visible in the window.
|
|
---@type integer, integer
|
|
local stoplinetop, stoplinebottom = unpack(api.nvim_win_call(win, function()
|
|
return { fn.line('w0'), fn.line('w$') }
|
|
end))
|
|
local stopline ---@type integer
|
|
if i % 2 == 1 then
|
|
stopline = stoplinebottom
|
|
else
|
|
stopline = stoplinetop
|
|
end
|
|
|
|
-- Limit the search time to 300 msec to avoid a hang on very long lines.
|
|
local timeout ---@type integer
|
|
local mode = api.nvim_get_mode().mode
|
|
if mode == 'i' or mode == 'R' then
|
|
timeout = vim.b[buf].matchparen_insert_timeout or vim.g.matchparen_insert_timeout
|
|
else
|
|
timeout = vim.b[buf].matchparen_timeout or vim.g.matchparen_timeout
|
|
end
|
|
|
|
---@type boolean, [integer, integer]|string
|
|
local ok, match = pcall(api.nvim_win_call, win, function()
|
|
return fn.searchpairpos(c, '', c2, flags, skip, stopline, timeout)
|
|
end)
|
|
if not ok then ---@cast match string
|
|
if save_cursor ~= nil then
|
|
api.nvim_win_call(win, function()
|
|
fn.setpos('.', save_cursor)
|
|
end)
|
|
end
|
|
error(match)
|
|
end ---@cast match [integer, integer]
|
|
if save_cursor ~= nil then
|
|
api.nvim_win_call(win, function()
|
|
fn.setpos('.', save_cursor)
|
|
end)
|
|
end
|
|
|
|
local m_lnum = match[1]
|
|
local m_col = match[2]
|
|
|
|
-- If a match is found setup match highlighting.
|
|
if m_lnum > 0 and m_lnum >= stoplinetop and m_lnum <= stoplinebottom then
|
|
local ids = vim.w[win].matchparen_ids
|
|
if tonumber(vim.g.matchparen_disable_cursor_hl) == 0 then
|
|
table.insert(
|
|
ids,
|
|
fn.matchaddpos(
|
|
'MatchParen',
|
|
{ { c_lnum, c_col - before }, { m_lnum, m_col } },
|
|
10,
|
|
-1,
|
|
{ window = win }
|
|
)
|
|
)
|
|
else
|
|
table.insert(
|
|
ids,
|
|
fn.matchaddpos('MatchParen', { { m_lnum, m_col } }, 10, -1, { window = win })
|
|
)
|
|
end
|
|
vim.w[win].matchparen_ids = ids
|
|
vim.w[win].paren_hl_on = 1
|
|
end
|
|
end
|
|
|
|
---@param win? integer
|
|
function M.remove_matches(win)
|
|
win = win or api.nvim_get_current_win()
|
|
|
|
if vim.w[win].paren_hl_on ~= nil and vim.w[win].paren_hl_on ~= 0 then
|
|
local ids = vim.w[win].matchparen_ids or {}
|
|
while #ids > 0 do
|
|
pcall(fn.matchdelete, table.remove(ids, 1), win)
|
|
end
|
|
vim.w[win].matchparen_ids = ids
|
|
vim.w[win].paren_hl_on = 0
|
|
end
|
|
end
|
|
|
|
---@param tab? integer
|
|
function M.no_matchparen(tab)
|
|
tab = tab or api.nvim_get_current_tabpage()
|
|
for _, win in ipairs(api.nvim_tabpage_list_wins(tab)) do
|
|
M.remove_matches(win)
|
|
end
|
|
vim.g.loaded_matchparen = nil
|
|
pcall(api.nvim_clear_autocmds, { group = 'matchparen' })
|
|
end
|
|
|
|
---@param tab? integer
|
|
function M.do_matchparen(tab)
|
|
if vim.g.loaded_matchparen == nil then
|
|
M.enable()
|
|
end
|
|
tab = tab or api.nvim_get_current_tabpage()
|
|
for _, win in ipairs(api.nvim_tabpage_list_wins(tab)) do
|
|
M.highlight_matching_pair(win)
|
|
end
|
|
end
|
|
|
|
return M
|