feat(comment): add built-in commenting

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()`.
This commit is contained in:
Evgeni Chasnovski 2024-04-04 18:10:12 +03:00 committed by Christian Clason
parent 2b9d8dc87e
commit 73de98256c
6 changed files with 970 additions and 0 deletions

View File

@ -346,6 +346,8 @@ The following new APIs and features were added.
• |extmarks| option `scoped`: only show the extmarks in its namespace's scope.
• Added built-in |commenting| support.
==============================================================================
CHANGED FEATURES *news-changed*

View File

@ -557,5 +557,43 @@ LessInitFunc in your vimrc, for example: >
set nocursorcolumn nocursorline
endfunc
<
==============================================================================
3. Commenting *commenting*
Nvim supports commenting and uncommenting of lines based on 'commentstring'.
Acting on a single line behaves as follows:
- If the line matches 'commentstring', the comment markers are removed (e.g.
`/*foo*/` is transformed to `foo`).
- Otherwise the comment markers are added to the current line (e.g. `foo` is
transformed to `/*foo*/`). Blank lines are ignored.
Acting on multiple lines behaves as follows:
- If each affected non-blank line matches 'commentstring', then all comment
markers are removed.
- Otherwise all affected lines are converted to comments; blank lines are
transformed to empty comments (e.g. `/**/`). Comment markers are aligned to
the least indented line.
If the filetype of the buffer is associated with a language for which a
|treesitter| parser is installed, then |vim.filetype.get_option()| is called
to look up the value of 'commentstring' corresponding to the cursor position.
(This can be different from the buffer's 'commentstring' in case of
|treesitter-language-injections|.)
*gc-default*
gc{motion} Comment or uncomment lines covered by {motion}.
*gcc-default*
gcc Comment or uncomment [count] lines starting at cursor.
*v_gc-default*
{Visual}gc Comment or uncomment the selected line(s).
*o_gc-default*
gc Text object for the largest contiguous block of
non-blank commented lines around the cursor (e.g.
`gcgc` uncomments a comment block; `dgc` deletes it).
Works only in Operator-pending mode.
vim:noet:tw=78:ts=8:ft=help:norl:

View File

@ -134,6 +134,8 @@ of these in your config by simply removing the mapping, e.g. ":unmap Y".
- @ |v_@-default|
- # |v_#-default|
- * |v_star-default|
- gc |gc-default| |v_gc-default| |o_gc-default|
- gcc |gcc-default|
- Nvim LSP client defaults |lsp-defaults|
- K |K-lsp-default|

View File

@ -0,0 +1,266 @@
---@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 }

View File

@ -114,6 +114,24 @@ do
do_open(table.concat(vim.iter(lines):map(vim.trim):totable()))
end, { desc = gx_desc })
end
--- Default maps for built-in commenting
do
local operator_rhs = function()
return require('vim._comment').operator()
end
vim.keymap.set({ 'n', 'x' }, 'gc', operator_rhs, { expr = true, desc = 'Toggle comment' })
local line_rhs = function()
return require('vim._comment').operator() .. '_'
end
vim.keymap.set('n', 'gcc', line_rhs, { expr = true, desc = 'Toggle comment line' })
local textobject_rhs = function()
require('vim._comment').textobject()
end
vim.keymap.set({ 'o' }, 'gc', textobject_rhs, { desc = 'Comment textobject' })
end
end
--- Default menus

View File

@ -0,0 +1,644 @@
local helpers = require('test.functional.helpers')(after_each)
local api = helpers.api
local clear = helpers.clear
local eq = helpers.eq
local exec_capture = helpers.exec_capture
local exec_lua = helpers.exec_lua
local feed = helpers.feed
-- Reference text
-- aa
-- aa
-- aa
--
-- aa
-- aa
-- aa
local example_lines = { 'aa', ' aa', ' aa', '', ' aa', ' aa', 'aa' }
local set_commentstring = function(commentstring)
api.nvim_set_option_value('commentstring', commentstring, { buf = 0 })
end
local get_lines = function(from, to)
from, to = from or 0, to or -1
return api.nvim_buf_get_lines(0, from, to, false)
end
local set_lines = function(lines, from, to)
from, to = from or 0, to or -1
api.nvim_buf_set_lines(0, from, to, false, lines)
end
local set_cursor = function(row, col)
api.nvim_win_set_cursor(0, { row, col })
end
local get_cursor = function()
return api.nvim_win_get_cursor(0)
end
local setup_treesitter = function()
-- NOTE: This leverages bundled Vimscript and Lua tree-sitter parsers
api.nvim_set_option_value('filetype', 'vim', { buf = 0 })
exec_lua('vim.treesitter.start()')
end
before_each(function()
clear({ args_rm = { '--cmd' }, args = { '--clean' } })
end)
describe('commenting', function()
before_each(function()
set_lines(example_lines)
set_commentstring('# %s')
end)
describe('toggle_lines()', function()
local toggle_lines = function(...)
exec_lua('require("vim._comment").toggle_lines(...)', ...)
end
it('works', function()
toggle_lines(3, 5)
eq(get_lines(2, 5), { ' # aa', ' #', ' # aa' })
toggle_lines(3, 5)
eq(get_lines(2, 5), { ' aa', '', ' aa' })
end)
it("works with different 'commentstring' options", function()
local validate = function(lines_before, lines_after, lines_again)
set_lines(lines_before)
toggle_lines(1, #lines_before)
eq(get_lines(), lines_after)
toggle_lines(1, #lines_before)
eq(get_lines(), lines_again or lines_before)
end
-- Single whitespace inside comment parts (main case)
set_commentstring('# %s #')
-- - General case
validate(
{ 'aa', ' aa', 'aa ', ' aa ' },
{ '# aa #', '# aa #', '# aa #', '# aa #' }
)
-- - Tabs
validate(
{ 'aa', '\taa', 'aa\t', '\taa\t' },
{ '# aa #', '# \taa #', '# aa\t #', '# \taa\t #' }
)
-- - With indent
validate({ ' aa', ' aa' }, { ' # aa #', ' # aa #' })
-- - With blank/empty lines
validate(
{ ' aa', '', ' ', '\t' },
{ ' # aa #', ' ##', ' ##', ' ##' },
{ ' aa', '', '', '' }
)
set_commentstring('# %s')
validate({ 'aa', ' aa', 'aa ', ' aa ' }, { '# aa', '# aa', '# aa ', '# aa ' })
validate({ 'aa', '\taa', 'aa\t', '\taa\t' }, { '# aa', '# \taa', '# aa\t', '# \taa\t' })
validate({ ' aa', ' aa' }, { ' # aa', ' # aa' })
validate(
{ ' aa', '', ' ', '\t' },
{ ' # aa', ' #', ' #', ' #' },
{ ' aa', '', '', '' }
)
set_commentstring('%s #')
validate({ 'aa', ' aa', 'aa ', ' aa ' }, { 'aa #', ' aa #', 'aa #', ' aa #' })
validate({ 'aa', '\taa', 'aa\t', '\taa\t' }, { 'aa #', '\taa #', 'aa\t #', '\taa\t #' })
validate({ ' aa', ' aa' }, { ' aa #', ' aa #' })
validate(
{ ' aa', '', ' ', '\t' },
{ ' aa #', ' #', ' #', ' #' },
{ ' aa', '', '', '' }
)
-- No whitespace in parts
set_commentstring('#%s#')
validate({ 'aa', ' aa', 'aa ', ' aa ' }, { '#aa#', '# aa#', '#aa #', '# aa #' })
validate({ 'aa', '\taa', 'aa\t', '\taa\t' }, { '#aa#', '#\taa#', '#aa\t#', '#\taa\t#' })
validate({ ' aa', ' aa' }, { ' #aa#', ' # aa#' })
validate(
{ ' aa', '', ' ', '\t' },
{ ' #aa#', ' ##', ' ##', ' ##' },
{ ' aa', '', '', '' }
)
set_commentstring('#%s')
validate({ 'aa', ' aa', 'aa ', ' aa ' }, { '#aa', '# aa', '#aa ', '# aa ' })
validate({ 'aa', '\taa', 'aa\t', '\taa\t' }, { '#aa', '#\taa', '#aa\t', '#\taa\t' })
validate({ ' aa', ' aa' }, { ' #aa', ' # aa' })
validate({ ' aa', '', ' ', '\t' }, { ' #aa', ' #', ' #', ' #' }, { ' aa', '', '', '' })
set_commentstring('%s#')
validate({ 'aa', ' aa', 'aa ', ' aa ' }, { 'aa#', ' aa#', 'aa #', ' aa #' })
validate({ 'aa', '\taa', 'aa\t', '\taa\t' }, { 'aa#', '\taa#', 'aa\t#', '\taa\t#' })
validate({ ' aa', ' aa' }, { ' aa#', ' aa#' })
validate({ ' aa', '', ' ', '\t' }, { ' aa#', ' #', ' #', ' #' }, { ' aa', '', '', '' })
-- Extra whitespace inside comment parts
set_commentstring('# %s #')
validate(
{ 'aa', ' aa', 'aa ', ' aa ' },
{ '# aa #', '# aa #', '# aa #', '# aa #' }
)
validate(
{ 'aa', '\taa', 'aa\t', '\taa\t' },
{ '# aa #', '# \taa #', '# aa\t #', '# \taa\t #' }
)
validate({ ' aa', ' aa' }, { ' # aa #', ' # aa #' })
validate(
{ ' aa', '', ' ', '\t' },
{ ' # aa #', ' ##', ' ##', ' ##' },
{ ' aa', '', '', '' }
)
set_commentstring('# %s')
validate({ 'aa', ' aa', 'aa ', ' aa ' }, { '# aa', '# aa', '# aa ', '# aa ' })
validate({ 'aa', '\taa', 'aa\t', '\taa\t' }, { '# aa', '# \taa', '# aa\t', '# \taa\t' })
validate({ ' aa', ' aa' }, { ' # aa', ' # aa' })
validate(
{ ' aa', '', ' ', '\t' },
{ ' # aa', ' #', ' #', ' #' },
{ ' aa', '', '', '' }
)
set_commentstring('%s #')
validate({ 'aa', ' aa', 'aa ', ' aa ' }, { 'aa #', ' aa #', 'aa #', ' aa #' })
validate({ 'aa', '\taa', 'aa\t', '\taa\t' }, { 'aa #', '\taa #', 'aa\t #', '\taa\t #' })
validate({ ' aa', ' aa' }, { ' aa #', ' aa #' })
validate(
{ ' aa', '', ' ', '\t' },
{ ' aa #', ' #', ' #', ' #' },
{ ' aa', '', '', '' }
)
-- Whitespace outside of comment parts
set_commentstring(' # %s # ')
validate(
{ 'aa', ' aa', 'aa ', ' aa ' },
{ ' # aa # ', ' # aa # ', ' # aa # ', ' # aa # ' }
)
validate(
{ 'aa', '\taa', 'aa\t', '\taa\t' },
{ ' # aa # ', ' # \taa # ', ' # aa\t # ', ' # \taa\t # ' }
)
validate({ ' aa', ' aa' }, { ' # aa # ', ' # aa # ' })
validate(
{ ' aa', '', ' ', '\t' },
{ ' # aa # ', ' ##', ' ##', ' ##' },
{ ' aa', '', '', '' }
)
set_commentstring(' # %s ')
validate(
{ 'aa', ' aa', 'aa ', ' aa ' },
{ ' # aa ', ' # aa ', ' # aa ', ' # aa ' }
)
validate(
{ 'aa', '\taa', 'aa\t', '\taa\t' },
{ ' # aa ', ' # \taa ', ' # aa\t ', ' # \taa\t ' }
)
validate({ ' aa', ' aa' }, { ' # aa ', ' # aa ' })
validate(
{ ' aa', '', ' ', '\t' },
{ ' # aa ', ' #', ' #', ' #' },
{ ' aa', '', '', '' }
)
set_commentstring(' %s # ')
validate(
{ 'aa', ' aa', 'aa ', ' aa ' },
{ ' aa # ', ' aa # ', ' aa # ', ' aa # ' }
)
validate(
{ 'aa', '\taa', 'aa\t', '\taa\t' },
{ ' aa # ', ' \taa # ', ' aa\t # ', ' \taa\t # ' }
)
validate({ ' aa', ' aa' }, { ' aa # ', ' aa # ' })
validate(
{ ' aa', '', ' ', '\t' },
{ ' aa # ', ' #', ' #', ' #' },
{ ' aa', '', '', '' }
)
-- LaTeX
set_commentstring('% %s')
validate({ 'aa', ' aa', 'aa ', ' aa ' }, { '% aa', '% aa', '% aa ', '% aa ' })
validate({ 'aa', '\taa', 'aa\t', '\taa\t' }, { '% aa', '% \taa', '% aa\t', '% \taa\t' })
validate({ ' aa', ' aa' }, { ' % aa', ' % aa' })
validate(
{ ' aa', '', ' ', '\t' },
{ ' % aa', ' %', ' %', ' %' },
{ ' aa', '', '', '' }
)
end)
it('respects tree-sitter injections', function()
setup_treesitter()
local lines = {
'set background=dark',
'lua << EOF',
'print(1)',
'vim.api.nvim_exec2([[',
' set background=light',
']])',
'EOF',
}
-- Single line comments
local validate = function(line, ref_output)
set_lines(lines)
toggle_lines(line, line)
eq(get_lines(line - 1, line)[1], ref_output)
end
validate(1, '"set background=dark')
validate(2, '"lua << EOF')
validate(3, '-- print(1)')
validate(4, '-- vim.api.nvim_exec2([[')
validate(5, ' "set background=light')
validate(6, '-- ]])')
validate(7, '"EOF')
-- Multiline comments should be computed based on first line 'commentstring'
set_lines(lines)
toggle_lines(1, 3)
local out_lines = get_lines()
eq(out_lines[1], '"set background=dark')
eq(out_lines[2], '"lua << EOF')
eq(out_lines[3], '"print(1)')
end)
it('correctly computes indent', function()
toggle_lines(2, 4)
eq(get_lines(1, 4), { ' # aa', ' # aa', ' #' })
end)
it('correctly detects comment/uncomment', function()
local validate = function(from, to, ref_lines)
set_lines({ '', 'aa', '# aa', '# aa', 'aa', '' })
toggle_lines(from, to)
eq(get_lines(), ref_lines)
end
-- It should uncomment only if all non-blank lines are comments
validate(3, 4, { '', 'aa', 'aa', 'aa', 'aa', '' })
validate(2, 4, { '', '# aa', '# # aa', '# # aa', 'aa', '' })
validate(3, 5, { '', 'aa', '# # aa', '# # aa', '# aa', '' })
validate(1, 6, { '#', '# aa', '# # aa', '# # aa', '# aa', '#' })
-- Blank lines should be ignored when making a decision
set_lines({ '# aa', '', ' ', '\t', '# aa' })
toggle_lines(1, 5)
eq(get_lines(), { 'aa', '', ' ', '\t', 'aa' })
end)
it('matches comment parts strictly when detecting comment/uncomment', function()
local validate = function(from, to, ref_lines)
set_lines({ '#aa', '# aa', '# aa' })
toggle_lines(from, to)
eq(get_lines(), ref_lines)
end
set_commentstring('#%s')
validate(1, 3, { 'aa', ' aa', ' aa' })
validate(2, 3, { '#aa', ' aa', ' aa' })
validate(3, 3, { '#aa', '# aa', ' aa' })
set_commentstring('# %s')
validate(1, 3, { '# #aa', '# # aa', '# # aa' })
validate(2, 3, { '#aa', 'aa', ' aa' })
validate(3, 3, { '#aa', '# aa', ' aa' })
set_commentstring('# %s')
validate(1, 3, { '# #aa', '# # aa', '# # aa' })
validate(2, 3, { '#aa', '# # aa', '# # aa' })
validate(3, 3, { '#aa', '# aa', 'aa' })
end)
it('uncomments on inconsistent indent levels', function()
set_lines({ '# aa', ' # aa', ' # aa' })
toggle_lines(1, 3)
eq(get_lines(), { 'aa', ' aa', ' aa' })
end)
it('respects tabs', function()
api.nvim_set_option_value('expandtab', false, { buf = 0 })
set_lines({ '\t\taa', '\t\taa' })
toggle_lines(1, 2)
eq(get_lines(), { '\t\t# aa', '\t\t# aa' })
toggle_lines(1, 2)
eq(get_lines(), { '\t\taa', '\t\taa' })
end)
it('works with trailing whitespace', function()
-- Without right-hand side
set_commentstring('# %s')
set_lines({ ' aa', ' aa ', ' ' })
toggle_lines(1, 3)
eq(get_lines(), { ' # aa', ' # aa ', ' #' })
toggle_lines(1, 3)
eq(get_lines(), { ' aa', ' aa ', '' })
-- With right-hand side
set_commentstring('%s #')
set_lines({ ' aa', ' aa ', ' ' })
toggle_lines(1, 3)
eq(get_lines(), { ' aa #', ' aa #', ' #' })
toggle_lines(1, 3)
eq(get_lines(), { ' aa', ' aa ', '' })
-- Trailing whitespace after right side should be preserved for non-blanks
set_commentstring('%s #')
set_lines({ ' aa # ', ' aa #\t', ' # ', ' #\t' })
toggle_lines(1, 4)
eq(get_lines(), { ' aa ', ' aa\t', '', '' })
end)
end)
describe('Operator', function()
it('works in Normal mode', function()
set_cursor(2, 2)
feed('gc', 'ap')
eq(get_lines(), { '# aa', '# aa', '# aa', '#', ' aa', ' aa', 'aa' })
-- Cursor moves to start line
eq(get_cursor(), { 1, 0 })
-- Supports `v:count`
set_lines(example_lines)
set_cursor(2, 0)
feed('2gc', 'ap')
eq(get_lines(), { '# aa', '# aa', '# aa', '#', '# aa', '# aa', '# aa' })
end)
it('allows dot-repeat in Normal mode', function()
local doubly_commented = { '# # aa', '# # aa', '# # aa', '# #', '# aa', '# aa', '# aa' }
set_lines(example_lines)
set_cursor(2, 2)
feed('gc', 'ap')
feed('.')
eq(get_lines(), doubly_commented)
-- Not immediate dot-repeat
set_lines(example_lines)
set_cursor(2, 2)
feed('gc', 'ap')
set_cursor(7, 0)
feed('.')
eq(get_lines(), doubly_commented)
end)
it('works in Visual mode', function()
set_cursor(2, 2)
feed('v', 'ap', 'gc')
eq(get_lines(), { '# aa', '# aa', '# aa', '#', ' aa', ' aa', 'aa' })
-- Cursor moves to start line
eq(get_cursor(), { 1, 0 })
end)
it('allows dot-repeat after initial Visual mode', function()
-- local example_lines = { 'aa', ' aa', ' aa', '', ' aa', ' aa', 'aa' }
set_lines(example_lines)
set_cursor(2, 2)
feed('vip', 'gc')
eq(get_lines(), { '# aa', '# aa', '# aa', '', ' aa', ' aa', 'aa' })
eq(get_cursor(), { 1, 0 })
-- Dot-repeat after first application in Visual mode should apply to the same
-- relative region
feed('.')
eq(get_lines(), example_lines)
set_cursor(3, 0)
feed('.')
eq(get_lines(), { 'aa', ' aa', ' # aa', ' #', ' # aa', ' aa', 'aa' })
end)
it("respects 'commentstring'", function()
set_commentstring('/*%s*/')
set_cursor(2, 2)
feed('gc', 'ap')
eq(get_lines(), { '/*aa*/', '/* aa*/', '/* aa*/', '/**/', ' aa', ' aa', 'aa' })
end)
it("works with empty 'commentstring'", function()
set_commentstring('')
set_cursor(2, 2)
feed('gc', 'ap')
eq(get_lines(), example_lines)
eq(exec_capture('1messages'), [[Option 'commentstring' is empty.]])
end)
it('respects tree-sitter injections', function()
setup_treesitter()
local lines = {
'set background=dark',
'lua << EOF',
'print(1)',
'vim.api.nvim_exec2([[',
' set background=light',
']])',
'EOF',
}
-- Single line comments
local validate = function(line, ref_output)
set_lines(lines)
set_cursor(line, 0)
feed('gc_')
eq(get_lines(line - 1, line)[1], ref_output)
end
validate(1, '"set background=dark')
validate(2, '"lua << EOF')
validate(3, '-- print(1)')
validate(4, '-- vim.api.nvim_exec2([[')
validate(5, ' "set background=light')
validate(6, '-- ]])')
validate(7, '"EOF')
-- Has proper dot-repeat which recomputes 'commentstring'
set_lines(lines)
set_cursor(1, 0)
feed('gc_')
eq(get_lines()[1], '"set background=dark')
set_cursor(3, 0)
feed('.')
eq(get_lines()[3], '-- print(1)')
-- Multiline comments should be computed based on cursor position
-- which in case of Visual selection means its left part
set_lines(lines)
set_cursor(1, 0)
feed('v2j', 'gc')
local out_lines = get_lines()
eq(out_lines[1], '"set background=dark')
eq(out_lines[2], '"lua << EOF')
eq(out_lines[3], '"print(1)')
end)
it("recomputes local 'commentstring' based on cursor position", function()
setup_treesitter()
local lines = {
'lua << EOF',
' print(1)',
'EOF',
}
set_lines(lines)
-- Vimscript's tree-sitter grammar is (currently) written in a way that Lua's
-- injection really starts at the first non-blank character
set_cursor(2, 1)
feed('gc_')
eq(get_lines()[2], ' "print(1)')
set_lines(lines)
set_cursor(2, 2)
feed('.')
eq(get_lines()[2], ' -- print(1)')
end)
it('preserves marks', function()
set_cursor(2, 0)
-- Set '`<' and '`>' marks
feed('VV')
feed('gc', 'ip')
eq(api.nvim_buf_get_mark(0, '<'), { 2, 0 })
eq(api.nvim_buf_get_mark(0, '>'), { 2, 2147483647 })
end)
end)
describe('Current line', function()
it('works', function()
set_lines(example_lines)
set_cursor(1, 1)
feed('gcc')
eq(get_lines(0, 2), { '# aa', ' aa' })
-- Does not comment empty line
set_lines(example_lines)
set_cursor(4, 0)
feed('gcc')
eq(get_lines(2, 5), { ' aa', '', ' aa' })
-- Supports `v:count`
set_lines(example_lines)
set_cursor(2, 0)
feed('2gcc')
eq(get_lines(0, 3), { 'aa', ' # aa', ' # aa' })
end)
it('allows dot-repeat', function()
set_lines(example_lines)
set_cursor(1, 1)
feed('gcc')
feed('.')
eq(get_lines(), example_lines)
-- Not immediate dot-repeat
set_lines(example_lines)
set_cursor(1, 1)
feed('gcc')
set_cursor(7, 0)
feed('.')
eq(get_lines(6, 7), { '# aa' })
end)
it('respects tree-sitter injections', function()
setup_treesitter()
local lines = {
'set background=dark',
'lua << EOF',
'print(1)',
'EOF',
}
set_lines(lines)
set_cursor(1, 0)
feed('gcc')
eq(get_lines(), { '"set background=dark', 'lua << EOF', 'print(1)', 'EOF' })
-- Should work with dot-repeat
set_cursor(3, 0)
feed('.')
eq(get_lines(), { '"set background=dark', 'lua << EOF', '-- print(1)', 'EOF' })
end)
end)
describe('Textobject', function()
it('works', function()
set_lines({ 'aa', '# aa', '# aa', 'aa' })
set_cursor(2, 0)
feed('d', 'gc')
eq(get_lines(), { 'aa', 'aa' })
end)
it('allows dot-repeat', function()
set_lines({ 'aa', '# aa', '# aa', 'aa', '# aa' })
set_cursor(2, 0)
feed('d', 'gc')
set_cursor(3, 0)
feed('.')
eq(get_lines(), { 'aa', 'aa' })
end)
it('does nothing when not inside textobject', function()
-- Builtin operators
feed('d', 'gc')
eq(get_lines(), example_lines)
-- Comment operator
local validate_no_action = function(line, col)
set_lines(example_lines)
set_cursor(line, col)
feed('gc', 'gc')
eq(get_lines(), example_lines)
end
validate_no_action(1, 1)
validate_no_action(2, 2)
-- Doesn't work (but should) because both `[` and `]` are set to (1, 0)
-- (instead of more reasonable (1, -1) or (0, 2147483647)).
-- validate_no_action(1, 0)
end)
it('respects tree-sitter injections', function()
setup_treesitter()
local lines = {
'"set background=dark',
'"set termguicolors',
'lua << EOF',
'-- print(1)',
'-- print(2)',
'EOF',
}
set_lines(lines)
set_cursor(1, 0)
feed('dgc')
eq(get_lines(), { 'lua << EOF', '-- print(1)', '-- print(2)', 'EOF' })
-- Should work with dot-repeat
set_cursor(2, 0)
feed('.')
eq(get_lines(), { 'lua << EOF', 'EOF' })
end)
end)
end)