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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270
|
---@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 ts_parser = vim.treesitter.get_parser(0, '', { error = false })
if not 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 tree-sitter captures' metadata.
-- Traverse backwards to prefer narrower captures.
local caps = vim.treesitter.get_captures_at_pos(0, row, col)
for i = #caps, 1, -1 do
local id, metadata = caps[i].id, caps[i].metadata
local md_cms = metadata['bo.commentstring'] or metadata[id] and metadata[id]['bo.commentstring']
if md_cms then
return md_cms
end
end
-- - 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:
-- <whitespace> <trimmed left> <anything> <trimmed right> <whitespace>
local regex = '^%s-' .. vim.trim(l_esc) .. '.*' .. vim.trim(r_esc) .. '%s-$'
return function(line)
return line:find(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 regex = '^(%s*)' .. l_esc .. '(.*)' .. r_esc .. '(%s-)$'
local regex_trimmed = '^(%s*)' .. vim.trim(l_esc) .. '(.*)' .. vim.trim(r_esc) .. '(%s-)$'
return function(line)
-- Try regex with exact comment parts first, fall back to trimmed parts
local indent, new_line, trail = line:match(regex)
if new_line == nil then
indent, new_line, trail = line:match(regex_trimmed)
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.
vim._with({ lockmarks = true }, function()
vim.api.nvim_buf_set_lines(0, line_start - 1, line_end, false, vim.tbl_map(f, lines))
end)
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 }
|