From 73de98256cf3932dca156fbfd0c82c1cc10d487e Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Thu, 4 Apr 2024 18:10:12 +0300 Subject: [PATCH] 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()`. --- runtime/doc/news.txt | 2 + runtime/doc/various.txt | 38 ++ runtime/doc/vim_diff.txt | 2 + runtime/lua/vim/_comment.lua | 266 +++++++++++ runtime/lua/vim/_defaults.lua | 18 + test/functional/lua/comment_spec.lua | 644 +++++++++++++++++++++++++++ 6 files changed, 970 insertions(+) create mode 100644 runtime/lua/vim/_comment.lua create mode 100644 test/functional/lua/comment_spec.lua 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': <%s> + 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: + -- + 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)