aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEvgeni Chasnovski <evgeni.chasnovski@gmail.com>2024-04-04 18:10:12 +0300
committerChristian Clason <c.clason@uni-graz.at>2024-04-05 18:07:43 +0200
commit73de98256cf3932dca156fbfd0c82c1cc10d487e (patch)
treeb865c060dc3132c5e42eb31df896e5cd167f95b4
parent2b9d8dc87e84ea9c03eb8852af8fd9fe00a6033a (diff)
downloadrneovim-73de98256cf3932dca156fbfd0c82c1cc10d487e.tar.gz
rneovim-73de98256cf3932dca156fbfd0c82c1cc10d487e.tar.bz2
rneovim-73de98256cf3932dca156fbfd0c82c1cc10d487e.zip
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()`.
-rw-r--r--runtime/doc/news.txt2
-rw-r--r--runtime/doc/various.txt38
-rw-r--r--runtime/doc/vim_diff.txt2
-rw-r--r--runtime/lua/vim/_comment.lua266
-rw-r--r--runtime/lua/vim/_defaults.lua18
-rw-r--r--test/functional/lua/comment_spec.lua644
6 files changed, 970 insertions, 0 deletions
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
index 5aa274637b..c219762c3f 100644
--- a/runtime/doc/news.txt
+++ b/runtime/doc/news.txt
@@ -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*
diff --git a/runtime/doc/various.txt b/runtime/doc/various.txt
index 33f57580c7..803ca95cdf 100644
--- a/runtime/doc/various.txt
+++ b/runtime/doc/various.txt
@@ -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:
diff --git a/runtime/doc/vim_diff.txt b/runtime/doc/vim_diff.txt
index a76166abf7..82f3fe9b1b 100644
--- a/runtime/doc/vim_diff.txt
+++ b/runtime/doc/vim_diff.txt
@@ -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|
diff --git a/runtime/lua/vim/_comment.lua b/runtime/lua/vim/_comment.lua
new file mode 100644
index 0000000000..e9cd662c9d
--- /dev/null
+++ b/runtime/lua/vim/_comment.lua
@@ -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 }
diff --git a/runtime/lua/vim/_defaults.lua b/runtime/lua/vim/_defaults.lua
index 0798ca8d16..90d05f67a5 100644
--- a/runtime/lua/vim/_defaults.lua
+++ b/runtime/lua/vim/_defaults.lua
@@ -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
diff --git a/test/functional/lua/comment_spec.lua b/test/functional/lua/comment_spec.lua
new file mode 100644
index 0000000000..72935c47d5
--- /dev/null
+++ b/test/functional/lua/comment_spec.lua
@@ -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)