mirror of
https://github.com/neovim/neovim.git
synced 2024-12-25 21:55:17 -07:00
73de98256c
Design - Enable commenting support only through `gc` mappings for simplicity. No ability to configure, no Lua module, no user commands. Yet. - Overall implementation is a simplified version of 'mini.comment' module of 'echasnovski/mini.nvim' adapted to be a better suit for core. It basically means reducing code paths which use only specific fixed set of plugin config. All used options are default except `pad_comment_parts = false`. This means that 'commentstring' option is used as is without forcing single space inner padding. As 'tpope/vim-commentary' was considered for inclusion earlier, here is a quick summary of how this commit differs from it: - **User-facing features**. Both implement similar user-facing mappings. This commit does not include `gcu` which is essentially a `gcgc`. There are no commands, events, or configuration in this commit. - **Size**. Both have reasonably comparable number of lines of code, while this commit has more comments in tricky areas. - **Maintainability**. This commit has (purely subjectively) better readability, tests, and Lua types. - **Configurability**. This commit has no user configuration, while 'vim-commentary' has some (partially as a counter-measure to possibly modifying 'commentstring' option). - **Extra features**: - This commit supports tree-sitter by computing `'commentstring'` option under cursor, which can matter in presence of tree-sitter injected languages. - This commit comments blank lines while 'tpope/vim-commentary' does not. At the same time, blank lines are not taken into account when deciding the toggle action. - This commit has much better speed on larger chunks of lines (like above 1000). This is thanks to using `nvim_buf_set_lines()` to set all new lines at once, and not with `vim.fn.setline()`.
267 lines
8.9 KiB
Lua
267 lines
8.9 KiB
Lua
---@nodoc
|
|
---@class vim._comment.Parts
|
|
---@field left string Left part of comment
|
|
---@field right string Right part of comment
|
|
|
|
--- Get 'commentstring' at cursor
|
|
---@param ref_position integer[]
|
|
---@return string
|
|
local function get_commentstring(ref_position)
|
|
local buf_cs = vim.bo.commentstring
|
|
|
|
local has_ts_parser, ts_parser = pcall(vim.treesitter.get_parser)
|
|
if not has_ts_parser then
|
|
return buf_cs
|
|
end
|
|
|
|
-- Try to get 'commentstring' associated with local tree-sitter language.
|
|
-- This is useful for injected languages (like markdown with code blocks).
|
|
local row, col = ref_position[1] - 1, ref_position[2]
|
|
local ref_range = { row, col, row, col + 1 }
|
|
|
|
-- - Get 'commentstring' from the deepest LanguageTree which both contains
|
|
-- reference range and has valid 'commentstring' (meaning it has at least
|
|
-- one associated 'filetype' with valid 'commentstring').
|
|
-- In simple cases using `parser:language_for_range()` would be enough, but
|
|
-- it fails for languages without valid 'commentstring' (like 'comment').
|
|
local ts_cs, res_level = nil, 0
|
|
|
|
---@param lang_tree vim.treesitter.LanguageTree
|
|
local function traverse(lang_tree, level)
|
|
if not lang_tree:contains(ref_range) then
|
|
return
|
|
end
|
|
|
|
local lang = lang_tree:lang()
|
|
local filetypes = vim.treesitter.language.get_filetypes(lang)
|
|
for _, ft in ipairs(filetypes) do
|
|
local cur_cs = vim.filetype.get_option(ft, 'commentstring')
|
|
if cur_cs ~= '' and level > res_level then
|
|
ts_cs = cur_cs
|
|
end
|
|
end
|
|
|
|
for _, child_lang_tree in pairs(lang_tree:children()) do
|
|
traverse(child_lang_tree, level + 1)
|
|
end
|
|
end
|
|
traverse(ts_parser, 1)
|
|
|
|
return ts_cs or buf_cs
|
|
end
|
|
|
|
--- Compute comment parts from 'commentstring'
|
|
---@param ref_position integer[]
|
|
---@return vim._comment.Parts
|
|
local function get_comment_parts(ref_position)
|
|
local cs = get_commentstring(ref_position)
|
|
|
|
if cs == nil or cs == '' then
|
|
vim.api.nvim_echo({ { "Option 'commentstring' is empty.", 'WarningMsg' } }, true, {})
|
|
return { left = '', right = '' }
|
|
end
|
|
|
|
if not (type(cs) == 'string' and cs:find('%%s') ~= nil) then
|
|
error(vim.inspect(cs) .. " is not a valid 'commentstring'.")
|
|
end
|
|
|
|
-- Structure of 'commentstring': <left part> <%s> <right part>
|
|
local left, right = cs:match('^(.-)%%s(.-)$')
|
|
return { left = left, right = right }
|
|
end
|
|
|
|
--- Make a function that checks if a line is commented
|
|
---@param parts vim._comment.Parts
|
|
---@return fun(line: string): boolean
|
|
local function make_comment_check(parts)
|
|
local l_esc, r_esc = vim.pesc(parts.left), vim.pesc(parts.right)
|
|
|
|
-- Commented line has the following structure:
|
|
-- <possible whitespace> <left> <anything> <right> <possible whitespace>
|
|
local nonblank_regex = '^%s-' .. l_esc .. '.*' .. r_esc .. '%s-$'
|
|
|
|
-- Commented blank line can have any amoung of whitespace around parts
|
|
local blank_regex = '^%s-' .. vim.trim(l_esc) .. '%s*' .. vim.trim(r_esc) .. '%s-$'
|
|
|
|
return function(line)
|
|
return line:find(nonblank_regex) ~= nil or line:find(blank_regex) ~= nil
|
|
end
|
|
end
|
|
|
|
--- Compute comment-related information about lines
|
|
---@param lines string[]
|
|
---@param parts vim._comment.Parts
|
|
---@return string indent
|
|
---@return boolean is_commented
|
|
local function get_lines_info(lines, parts)
|
|
local comment_check = make_comment_check(parts)
|
|
|
|
local is_commented = true
|
|
local indent_width = math.huge
|
|
---@type string
|
|
local indent
|
|
|
|
for _, l in ipairs(lines) do
|
|
-- Update lines indent: minimum of all indents except blank lines
|
|
local _, indent_width_cur, indent_cur = l:find('^(%s*)')
|
|
|
|
-- Ignore blank lines completely when making a decision
|
|
if indent_width_cur < l:len() then
|
|
-- NOTE: Copying actual indent instead of recreating it with `indent_width`
|
|
-- allows to handle both tabs and spaces
|
|
if indent_width_cur < indent_width then
|
|
---@diagnostic disable-next-line:cast-local-type
|
|
indent_width, indent = indent_width_cur, indent_cur
|
|
end
|
|
|
|
-- Update comment info: commented if every non-blank line is commented
|
|
if is_commented then
|
|
is_commented = comment_check(l)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- `indent` can still be `nil` in case all `lines` are empty
|
|
return indent or '', is_commented
|
|
end
|
|
|
|
--- Compute whether a string is blank
|
|
---@param x string
|
|
---@return boolean is_blank
|
|
local function is_blank(x)
|
|
return x:find('^%s*$') ~= nil
|
|
end
|
|
|
|
--- Make a function which comments a line
|
|
---@param parts vim._comment.Parts
|
|
---@param indent string
|
|
---@return fun(line: string): string
|
|
local function make_comment_function(parts, indent)
|
|
local prefix, nonindent_start, suffix = indent .. parts.left, indent:len() + 1, parts.right
|
|
local blank_comment = indent .. vim.trim(parts.left) .. vim.trim(parts.right)
|
|
|
|
return function(line)
|
|
if is_blank(line) then
|
|
return blank_comment
|
|
end
|
|
return prefix .. line:sub(nonindent_start) .. suffix
|
|
end
|
|
end
|
|
|
|
--- Make a function which uncomments a line
|
|
---@param parts vim._comment.Parts
|
|
---@return fun(line: string): string
|
|
local function make_uncomment_function(parts)
|
|
local l_esc, r_esc = vim.pesc(parts.left), vim.pesc(parts.right)
|
|
local nonblank_regex = '^(%s*)' .. l_esc .. '(.*)' .. r_esc .. '(%s-)$'
|
|
local blank_regex = '^(%s*)' .. vim.trim(l_esc) .. '(%s*)' .. vim.trim(r_esc) .. '(%s-)$'
|
|
|
|
return function(line)
|
|
-- Try both non-blank and blank regexes
|
|
local indent, new_line, trail = line:match(nonblank_regex)
|
|
if new_line == nil then
|
|
indent, new_line, trail = line:match(blank_regex)
|
|
end
|
|
|
|
-- Return original if line is not commented
|
|
if new_line == nil then
|
|
return line
|
|
end
|
|
|
|
-- Prevent trailing whitespace
|
|
if is_blank(new_line) then
|
|
indent, trail = '', ''
|
|
end
|
|
|
|
return indent .. new_line .. trail
|
|
end
|
|
end
|
|
|
|
--- Comment/uncomment buffer range
|
|
---@param line_start integer
|
|
---@param line_end integer
|
|
---@param ref_position? integer[]
|
|
local function toggle_lines(line_start, line_end, ref_position)
|
|
ref_position = ref_position or { line_start, 0 }
|
|
local parts = get_comment_parts(ref_position)
|
|
local lines = vim.api.nvim_buf_get_lines(0, line_start - 1, line_end, false)
|
|
local indent, is_comment = get_lines_info(lines, parts)
|
|
|
|
local f = is_comment and make_uncomment_function(parts) or make_comment_function(parts, indent)
|
|
|
|
-- Direct `nvim_buf_set_lines()` essentially removes both regular and
|
|
-- extended marks (squashes to empty range at either side of the region)
|
|
-- inside region. Use 'lockmarks' to preserve regular marks.
|
|
-- Preserving extmarks is not a universally good thing to do:
|
|
-- - Good for non-highlighting in text area extmarks (like showing signs).
|
|
-- - Debatable for highlighting in text area (like LSP semantic tokens).
|
|
-- Mostly because it causes flicker as highlighting is preserved during
|
|
-- comment toggling.
|
|
package.loaded['vim._comment']._lines = vim.tbl_map(f, lines)
|
|
local lua_cmd = string.format(
|
|
'vim.api.nvim_buf_set_lines(0, %d, %d, false, package.loaded["vim._comment"]._lines)',
|
|
line_start - 1,
|
|
line_end
|
|
)
|
|
vim.cmd.lua({ lua_cmd, mods = { lockmarks = true } })
|
|
package.loaded['vim._comment']._lines = nil
|
|
end
|
|
|
|
--- Operator which toggles user-supplied range of lines
|
|
---@param mode string?
|
|
---|"'line'"
|
|
---|"'char'"
|
|
---|"'block'"
|
|
local function operator(mode)
|
|
-- Used without arguments as part of expression mapping. Otherwise it is
|
|
-- called as 'operatorfunc'.
|
|
if mode == nil then
|
|
vim.o.operatorfunc = "v:lua.require'vim._comment'.operator"
|
|
return 'g@'
|
|
end
|
|
|
|
-- Compute target range
|
|
local mark_from, mark_to = "'[", "']"
|
|
local lnum_from, col_from = vim.fn.line(mark_from), vim.fn.col(mark_from)
|
|
local lnum_to, col_to = vim.fn.line(mark_to), vim.fn.col(mark_to)
|
|
|
|
-- Do nothing if "from" mark is after "to" (like in empty textobject)
|
|
if (lnum_from > lnum_to) or (lnum_from == lnum_to and col_from > col_to) then
|
|
return
|
|
end
|
|
|
|
-- NOTE: use cursor position as reference for possibly computing local
|
|
-- tree-sitter-based 'commentstring'. Recompute every time for a proper
|
|
-- dot-repeat. In Visual and sometimes Normal mode it uses start position.
|
|
toggle_lines(lnum_from, lnum_to, vim.api.nvim_win_get_cursor(0))
|
|
return ''
|
|
end
|
|
|
|
--- Select contiguous commented lines at cursor
|
|
local function textobject()
|
|
local lnum_cur = vim.fn.line('.')
|
|
local parts = get_comment_parts({ lnum_cur, vim.fn.col('.') })
|
|
local comment_check = make_comment_check(parts)
|
|
|
|
if not comment_check(vim.fn.getline(lnum_cur)) then
|
|
return
|
|
end
|
|
|
|
-- Compute commented range
|
|
local lnum_from = lnum_cur
|
|
while (lnum_from >= 2) and comment_check(vim.fn.getline(lnum_from - 1)) do
|
|
lnum_from = lnum_from - 1
|
|
end
|
|
|
|
local lnum_to = lnum_cur
|
|
local n_lines = vim.api.nvim_buf_line_count(0)
|
|
while (lnum_to <= n_lines - 1) and comment_check(vim.fn.getline(lnum_to + 1)) do
|
|
lnum_to = lnum_to + 1
|
|
end
|
|
|
|
-- Select range linewise for operator to act upon
|
|
vim.cmd('normal! ' .. lnum_from .. 'GV' .. lnum_to .. 'G')
|
|
end
|
|
|
|
return { operator = operator, textobject = textobject, toggle_lines = toggle_lines }
|