Featured image of post 增强 neovim 0.10 的注释功能

增强 neovim 0.10 的注释功能

如何使用 neovim 自带的注释实现此前 Comment 插件的功能

在很长一段时间内,neovim 的使用者在使用注释功能的时候会依赖一个叫做 Comment.nvim 插件。这个插件功能非常全面,不但提供了注释的切换,还提供了向当前行的上方、下方、末尾添加注释的功能。然而,很不幸的是,这个插件目前似乎停止维护了——它的最后一次 commit 时间是 2024 年 8 月 20 日(也许作者跑去玩黑猴了),issue 也在以一个缓慢的速度逐渐增加。虽然我在它停更之后继续用了小半年,从没遇到任何 bug,但是对于不维护的插件,只要存在替代品,我都倾向于不再使用。

那么这个替代品是什么呢?2024 年 5 月 16 日,neovim 0.10.0 版本正式发布,正式提供了注释功能。我们可以通过 :h commenting 查看,大致功能包括:

  • 使用 gcc 切换注释
  • 使用 gc + motion 的方式进行多行注释,如 gc5jgc3{
  • 在 visual mode 下使用 gc 进行注释
  • 将注释作为一个 textobject,可以进行诸如 dgc 这样的操作

如果我没记错的话,前三个功能在 Comment.nvim 插件中都提供了,完全可以无缝切换过来;textobject 是一个非常酷的新功能,毕竟 neovim 编辑用着很爽的原因之一就是有着各种各样的 textobject。

但是很不幸,注释功能似乎就这么多了。我在 Comment.nvim 中非常喜欢的在行尾、上一行、下一行插入注释的功能完全没有提供。而且,如果你仔细看了帮助文档,你会发现这句话:

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.

什么?空白的行竟然不能通过 gcc 添加注释?您甭管我说这些功能有没有用,我之前用 Comment.nvim 的时候反正经常这么干,所以既然你不提供,那我就自己写好了。

1 在行尾 / 上一行 / 下一行添加注释

在 neovim 中有一个 buffer-local 的值:commentstring。比如说你在 lua 文件中运行 = vim.bo.commentstring,就会得到 -- %s。不难看到,这个值规定的是注释的格式,其中的 %s 会被替换为注释内容。所以如果我们要实现在行尾 / 上一行 / 下一行添加注释的功能,就可以利用这个值。我们要做的事情包括:

  • 对指定行的内容进行替换
  • 将光标移动到正确位置
  • 进入 insert mode(因为之前 Comment.nvim 有这一步,为了保持体验一致我们也这样做)

1.1 在行尾添加注释

我们先来看最简单的功能——至于为什么最简单我们之后会看到。

在 neovim 中,通过 lua 代码控制 buffer 内容是通过 vim.api.nvim_buf_set_lines() 实现的:

nvim_buf_set_lines({buffer}, {start}, {end}, {strict_indexing}, {replacement}) Sets (replaces) a line-range in the buffer.

Indexing is zero-based, end-exclusive. Negative indices are interpreted as
length+1+index: -1 refers to the index past the end. So to change or
delete the last element use start=-2 and end=-1.

To insert lines at a given index, set `start` and `end` to the same index.
To delete a range of lines, set `replacement` to an empty array.

Out-of-bounds indices are clamped to the nearest valid value, unless
`strict_indexing` is set.

Attributes: ~
    not allowed when |textlock| is active

Parameters: ~
  • {buffer}           Buffer handle, or 0 for current buffer
  • {start}            First line index
  • {end}              Last line index, exclusive
  • {strict_indexing}  Whether out-of-bounds should be an error.
  • {replacement}      Array of lines to use as replacement

好,那么我们就开始实现功能。首先我们需要获取一些内容:

1
2
3
4
5
6
local line = vim.api.nvim_get_current_line() -- 当前行的内容
local row = vim.api.nvim_win_get_cursor(0)[1] -- 当前所在的行数,从 1 开始

local commentstring = vim.bo.commentstring -- 获取 commentstring
local comment = commentstring:gsub("%%s", "") -- 将 commentstring 中的 %s 替换掉; %% 是对 % 转义
local index = commentstring:find "%%s" -- 获取光标插入处相对于注释的位置(从 1 开始),这个值相当于 %s 前面的字符数 + 1

现在,我们要在行尾添加注释内容。我们看到 vim.api.nvim_buf_set_lines() 并不支持在行尾添加内容,所以我们需要手动拼接当前行的内容和注释内容,然后去修改当前整行的内容。

不过在此之前,我们还有多余的一步要做:一般来说,在行尾添加注释会在注释前留一个空格;但是,如果这一行是空行,此时在注释前添加空格则显得多余,所以我们这里对 comment 进行修改:

1
2
3
4
if line:find "%S" then -- 查找第一个非空字符
    comment = " " .. comment
    index = index + 1 -- 相当于 commentstring 开头被添加了一位
end

OK,现在我们可以对当前行的内容进行修改了:

1
2
3
4
-- 0 代表当前 buffer
-- 这里的行数是从 0 开始的
-- 将第 [start, end - 1] 行的内容替换掉
vim.api.nvim_buf_set_lines(0, row - 1, row, false, { line .. comment })

然后,我们来修改光标的位置:

1
2
3
4
5
6
7
8
9
-- 0 代表当前 window
-- 这里的第二个参数是一个 table,由 row 和 col 组成,其中 row 从 1 开始,col 从 0 开始
--
-- 当我们的光标放在一行的第一位,col 为 0,所以 col 的值等于光标前面字符的数量
-- 我们现在要把光标放在 %s 再往前一位,例如如果是 // %s,则把光标放在 //█%s 的位置
-- 这样我们后续进入 insert mode 直接按 a 键就可以
-- 至于为什么不是放在 %s 处然后按 i 键,别忘了我们把 %s 替换掉了,最终的字符出在 %s 前面就结束了
-- 所以现在光标前面的字符包括:line 的全部内容 + %s 前面的字符数 - 1 = #line + index - 1 - 1
vim.api.nvim_win_set_cursor(0, { row, #line + index - 2 })

最后,调用 vim.api.nvim_feedkeys() 按下 a 键:

1
vim.api.nvim_feedkeys("a", "n", false)

完整代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
local function comment_end()
    local line = vim.api.nvim_get_current_line()
    local row = vim.api.nvim_win_get_cursor(0)[1]

    local commentstring = vim.bo.commentstring
    local comment = commentstring:gsub("%%s", "")
    local index = commentstring:find "%%s"

    if line:find "%S" then
        comment = " " .. comment
        index = index + 1
    end

    vim.api.nvim_buf_set_lines(0, row - 1, row, false, { line .. comment })
    vim.api.nvim_win_set_cursor(0, { row, #line + index - 2 })

    vim.api.nvim_feedkeys("a", "n", false)
end

vim.keymap.set("n", "gcA", comment_end)

1.2 在上一行 / 下一行添加注释

相比于在行尾添加注释,另起一行添加注释略有一点麻烦,因为我们还要给注释应用缩进。

我们先来考虑在上一行添加注释,此时我们希望让注释的缩进和当前行一致。前面的操作和在行尾添加注释一致:

1
2
3
4
5
6
local line = vim.api.nvim_get_current_line()
local row = vim.api.nvim_win_get_cursor(0)[1]

local commentstring = vim.bo.commentstring
local comment = commentstring:gsub("%%s", "")
local index = commentstring:find "%%s"

接着,我们来获取当前行的缩进:

1
2
3
4
5
-- 查找第一个非空字符,减 1 就是空白字符数
-- 如果没有非空字符,则空白字符数等于当前行的字符数
local blank_chars = (line:find "%S" or #line + 1) - 1

local blank = line:sub(1, blank_chars)

然后就是修改内容、放置光标、进入 insert mode:

1
2
3
4
5
6
-- 复习一下,我们可以理解是将第 [start, end - 1] 行的内容替换掉;如果 start 和 end 相等,则向 start 上面添加一行
-- 也就是在当前行前插入换行符,当前行变成第 row 行(行数从 0 开始),要写入的行变为第 row - 1 行
vim.api.nvim_buf_set_lines(0, row - 1, row - 1, true, { blank .. comment })

vim.api.nvim_win_set_cursor(0, { row, #blank + index - 2 })
vim.api.nvim_feedkeys("a", "n", false)

完整代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
local function comment_above()
    local line = vim.api.nvim_get_current_line()
    local row = vim.api.nvim_win_get_cursor(0)[1]

    local commentstring = vim.bo.commentstring
    local comment = commentstring:gsub("%%s", "")
    local index = commentstring:find "%%s"

    local blank_chars = (line:find "%S" or #line + 1) - 1
    local blank = line:sub(1, blank_chars)

    vim.api.nvim_buf_set_lines(0, row - 1, row - 1, true, { blank .. comment })
    vim.api.nvim_win_set_cursor(0, { row, #blank + index - 2 })

    vim.api.nvim_feedkeys("a", "n", false)
end

vim.keymap.set("n", "gcO", comment_above)

在下一行添加注释的整体逻辑差不多,但是此时我们的缩进应该按照下一行的缩进来。例如,我们在 python 中向 def 的下一行添加注释,那么这个注释的缩进应该和函数体内部缩进一致。代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
local function comment_below()
    local row = vim.api.nvim_win_get_cursor(0)[1]

    -- 如果当前行为最后一行,则仍然取用当前行的缩进
    local total_lines = vim.api.nvim_buf_line_count(0)
    local line
    if row == total_lines then
        line = vim.api.nvim_buf_get_lines(0, row - 1, row, true)[1]
    else
        line = vim.api.nvim_buf_get_lines(0, row, row + 1, true)[1]
    end

    local commentstring = vim.bo.commentstring
    local comment = commentstring:gsub("%%s", "")
    local index = commentstring:find "%%s"

    local blank_chars = (line:find "%S" or #line + 1) - 1
    local blank = line:sub(1, blank_chars)

    vim.api.nvim_buf_set_lines(0, row, row, true, { blank .. comment })
    vim.api.nvim_win_set_cursor(0, { row + 1, #blank + index - 2 })

    vim.api.nvim_feedkeys("a", "n", false)
end

vim.keymap.set("n", "gco", comment_below)

2 覆盖 gcc:针对空白行的注释添加

在经历了前面一系列麻烦的要死的操作之后,针对空白行添加注释似乎不是什么很困难的事情。然而……

前面我们是在凭空创造快捷键,而现在我们是要拓展 gcc 快捷键的功能——我们需要在当前行为空白的时候手动添加注释,在其他时候使用 gcc 原本的功能。你可能觉得这不是什么很麻烦的事情,比如我们都知道设置快捷键有一个选项叫 remap(比如,vim 中的 nmapnnoremap),我们只要不启用这个选项,就可以使用快捷键原本的功能。例如,我们可以这样:

1
vim.keymap.set("n", "j", "jA")

此时,j 本身的功能并没有被改变。但是:

1
vim.keymap.set("n", "gcc", "gcc")

按说此时这个快捷键应该不会发生任何改变,但是实际试验就会发现,这个快捷键直接失效了。至于为什么不能这样做我们姑且按下不表,我们暂且先假定不能通过按下 gcc 来实现注释功能,那直接去调用注释功能相关的 api 函数不就行了吗? 但是,更加令人抓狂的事情是,整个文档中,关于注释功能本身的内容只有 :h commenting 中提供的那么多。你以为它会提供一个 api 供我们调用吗?没有,至少在 0.10.4 中,vim.api 提供的 180 个 api 函数中并没有包含任何一个和注释相关的。

这就太烦人了。难道堂堂 neovim 还不允许我们在这上面做自定义了?我们不妨回来思考一下为什么 gcc 这个绑定这么特殊。前面 j 可以进行二次的绑定是因为这个快捷键是内置的快捷键,那 gcc 不能二次绑定难道是因为……这也是通过 lua 进行绑定的快捷键?

于是我去到 neovim 的 github 仓库中搜索了 gcc,发现了这段代码:

1
vim.keymap.set('n', 'gcc', line_rhs, { expr = true, desc = 'Toggle comment line' })

果然,这个快捷键也是用 lua 进行绑定的,所以我们再次使用 vim.keymap.set 的时候它就被覆盖掉了。而除了定位到原因之外,我们也找到了进行注释的 lua 函数:require("vim._comment).toggle_line()。这个函数接受三个参数,分别是注释开始的行(从 0 开始)、结束的行、以及用来判定当前注释状态的字符位置(大概就是根据这个位置的上下文判定是要添加注释还是取消注释)。 所以,我们只需要在当前行为空白的时候手动添加注释,其他时候手动调用这个函数即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
local function comment_line()
    local line = vim.api.nvim_get_current_line()

    local row = vim.api.nvim_win_get_cursor(0)[1]
    local commentstring = vim.bo.commentstring
    local comment = commentstring:gsub("%%s", "")
    local index = vim.bo.commentstring:find "%%s"

    if not line:find "%S" then
        vim.api.nvim_buf_set_lines(0, row - 1, row, false, { line .. comment })
        vim.api.nvim_win_set_cursor(0, { row, #line + index - 1 })
    else
        require("vim._comment").toggle_lines(row, row, { row, 0 })
    end
end

vim.keymap.set("n", "gcc", comment_line)

当然,我们现在这样做肯定不算是最完美的解法。neovim 没有把这个函数作为 api 暴露出来也许是有自己的考虑,可能在未来的版本中会有所改变。另外,我们用来判定缩进的方法可能也不是最理想的,譬如如果当前行是最后一行但是一个 python 的 def 语句,那么下一行的注释就应该自动增加缩进。此外,我们现在是通过 buffer 获取 commentstring,但有些时候我们在一个 buffer 中可能出现另一种语言的代码(例如 markdown 中出现其他语言的代码块),此时 commentstring 就应该通过 treesitter 的功能去获取。但是至少,现在我们把最基础的问题解决好了。

使用 Hugo + Stack 主题构建