_comment.lua (9261B)
1 ---@class vim._comment.Parts 2 ---@field left string Left part of comment 3 ---@field right string Right part of comment 4 5 --- Get 'commentstring' at cursor 6 ---@param ref_position [integer,integer] 7 ---@return string 8 local function get_commentstring(ref_position) 9 local buf_cs = vim.bo.commentstring 10 11 local ts_parser = vim.treesitter.get_parser(0, '', { error = false }) 12 if not ts_parser then 13 return buf_cs 14 end 15 16 -- Try to get 'commentstring' associated with local tree-sitter language. 17 -- This is useful for injected languages (like markdown with code blocks). 18 local row, col = ref_position[1] - 1, ref_position[2] 19 local ref_range = { row, col, row, col + 1 } 20 21 -- Get 'commentstring' from tree-sitter captures' metadata. 22 -- Traverse backwards to prefer narrower captures. 23 local caps = vim.treesitter.get_captures_at_pos(0, row, col) 24 for i = #caps, 1, -1 do 25 local id, metadata = caps[i].id, caps[i].metadata 26 local md_cms = metadata['bo.commentstring'] or metadata[id] and metadata[id]['bo.commentstring'] 27 28 if md_cms then 29 return md_cms 30 end 31 end 32 33 -- - Get 'commentstring' from the deepest LanguageTree which both contains 34 -- reference range and has valid 'commentstring' (meaning it has at least 35 -- one associated 'filetype' with valid 'commentstring'). 36 -- In simple cases using `parser:language_for_range()` would be enough, but 37 -- it fails for languages without valid 'commentstring' (like 'comment'). 38 local ts_cs, res_level = nil, 0 39 40 ---@param lang_tree vim.treesitter.LanguageTree 41 local function traverse(lang_tree, level) 42 if not lang_tree:contains(ref_range) then 43 return 44 end 45 46 local lang = lang_tree:lang() 47 local filetypes = vim.treesitter.language.get_filetypes(lang) 48 for _, ft in ipairs(filetypes) do 49 local cur_cs = vim.filetype.get_option(ft, 'commentstring') 50 if cur_cs ~= '' and level > res_level then 51 ts_cs = cur_cs 52 end 53 end 54 55 for _, child_lang_tree in pairs(lang_tree:children()) do 56 traverse(child_lang_tree, level + 1) 57 end 58 end 59 traverse(ts_parser, 1) 60 61 return ts_cs or buf_cs 62 end 63 64 --- Compute comment parts from 'commentstring' 65 ---@param ref_position [integer,integer] 66 ---@return vim._comment.Parts 67 local function get_comment_parts(ref_position) 68 local cs = get_commentstring(ref_position) 69 70 if cs == nil or cs == '' then 71 vim.api.nvim_echo({ { "Option 'commentstring' is empty.", 'WarningMsg' } }, true, {}) 72 return { left = '', right = '' } 73 end 74 75 if not (type(cs) == 'string' and cs:find('%%s') ~= nil) then 76 error(vim.inspect(cs) .. " is not a valid 'commentstring'.") 77 end 78 79 -- Structure of 'commentstring': <left part> <%s> <right part> 80 local left, right = cs:match('^(.-)%%s(.-)$') 81 assert(left and right) 82 return { left = left, right = right } 83 end 84 85 --- Make a function that checks if a line is commented 86 ---@param parts vim._comment.Parts 87 ---@return fun(line: string): boolean 88 local function make_comment_check(parts) 89 local l_esc, r_esc = vim.pesc(parts.left), vim.pesc(parts.right) 90 91 -- Commented line has the following structure: 92 -- <whitespace> <trimmed left> <anything> <trimmed right> <whitespace> 93 local regex = '^%s-' .. vim.trim(l_esc) .. '.*' .. vim.trim(r_esc) .. '%s-$' 94 95 return function(line) 96 return line:find(regex) ~= nil 97 end 98 end 99 100 --- Compute comment-related information about lines 101 ---@param lines string[] 102 ---@param parts vim._comment.Parts 103 ---@return string indent 104 ---@return boolean is_commented 105 local function get_lines_info(lines, parts) 106 local comment_check = make_comment_check(parts) 107 108 local is_commented = true 109 local indent_width = math.huge 110 ---@type string 111 local indent 112 113 for _, l in ipairs(lines) do 114 -- Update lines indent: minimum of all indents except blank lines 115 local _, indent_width_cur, indent_cur = l:find('^(%s*)') 116 assert(indent_width_cur and indent_cur) 117 118 -- Ignore blank lines completely when making a decision 119 if indent_width_cur < l:len() then 120 -- NOTE: Copying actual indent instead of recreating it with `indent_width` 121 -- allows to handle both tabs and spaces 122 if indent_width_cur < indent_width then 123 ---@diagnostic disable-next-line:cast-local-type 124 indent_width, indent = indent_width_cur, indent_cur 125 end 126 127 -- Update comment info: commented if every non-blank line is commented 128 if is_commented then 129 is_commented = comment_check(l) 130 end 131 end 132 end 133 134 -- `indent` can still be `nil` in case all `lines` are empty 135 return indent or '', is_commented 136 end 137 138 --- Compute whether a string is blank 139 ---@param x string 140 ---@return boolean is_blank 141 local function is_blank(x) 142 return x:find('^%s*$') ~= nil 143 end 144 145 --- Make a function which comments a line 146 ---@param parts vim._comment.Parts 147 ---@param indent string 148 ---@return fun(line: string): string 149 local function make_comment_function(parts, indent) 150 local prefix, nonindent_start, suffix = indent .. parts.left, indent:len() + 1, parts.right 151 local blank_comment = indent .. vim.trim(parts.left) .. vim.trim(suffix) 152 153 return function(line) 154 if is_blank(line) then 155 return blank_comment 156 end 157 return prefix .. line:sub(nonindent_start) .. suffix 158 end 159 end 160 161 --- Make a function which uncomments a line 162 ---@param parts vim._comment.Parts 163 ---@return fun(line: string): string 164 local function make_uncomment_function(parts) 165 local l_esc, r_esc = vim.pesc(parts.left), vim.pesc(parts.right) 166 local regex = '^(%s*)' .. l_esc .. '(.*)' .. r_esc .. '(%s-)$' 167 local regex_trimmed = '^(%s*)' .. vim.trim(l_esc) .. '(.*)' .. vim.trim(r_esc) .. '(%s-)$' 168 169 return function(line) 170 -- Try regex with exact comment parts first, fall back to trimmed parts 171 local indent, new_line, trail = line:match(regex) 172 if new_line == nil then 173 indent, new_line, trail = line:match(regex_trimmed) 174 end 175 176 -- Return original if line is not commented 177 if new_line == nil then 178 return line 179 end 180 181 -- Prevent trailing whitespace 182 if is_blank(new_line) then 183 indent, trail = '', '' 184 end 185 186 return indent .. new_line .. trail 187 end 188 end 189 190 --- Comment/uncomment buffer range 191 ---@param line_start integer 192 ---@param line_end integer 193 ---@param ref_position? [integer, integer] 194 local function toggle_lines(line_start, line_end, ref_position) 195 ref_position = ref_position or { line_start, 0 } 196 local parts = get_comment_parts(ref_position) 197 local lines = vim.api.nvim_buf_get_lines(0, line_start - 1, line_end, false) 198 local indent, is_comment = get_lines_info(lines, parts) 199 200 local f = is_comment and make_uncomment_function(parts) or make_comment_function(parts, indent) 201 202 -- Direct `nvim_buf_set_lines()` essentially removes both regular and 203 -- extended marks (squashes to empty range at either side of the region) 204 -- inside region. Use 'lockmarks' to preserve regular marks. 205 -- Preserving extmarks is not a universally good thing to do: 206 -- - Good for non-highlighting in text area extmarks (like showing signs). 207 -- - Debatable for highlighting in text area (like LSP semantic tokens). 208 -- Mostly because it causes flicker as highlighting is preserved during 209 -- comment toggling. 210 vim._with({ lockmarks = true }, function() 211 vim.api.nvim_buf_set_lines(0, line_start - 1, line_end, false, vim.tbl_map(f, lines)) 212 end) 213 end 214 215 --- Operator which toggles user-supplied range of lines 216 ---@param mode string? 217 ---|"'line'" 218 ---|"'char'" 219 ---|"'block'" 220 local function operator(mode) 221 -- Used without arguments as part of expression mapping. Otherwise it is 222 -- called as 'operatorfunc'. 223 if mode == nil then 224 vim.o.operatorfunc = "v:lua.require'vim._comment'.operator" 225 return 'g@' 226 end 227 228 -- Compute target range 229 local mark_from, mark_to = "'[", "']" 230 local lnum_from, col_from = vim.fn.line(mark_from), vim.fn.col(mark_from) 231 local lnum_to, col_to = vim.fn.line(mark_to), vim.fn.col(mark_to) 232 233 -- Do nothing if "from" mark is after "to" (like in empty textobject) 234 if (lnum_from > lnum_to) or (lnum_from == lnum_to and col_from > col_to) then 235 return 236 end 237 238 -- NOTE: use cursor position as reference for possibly computing local 239 -- tree-sitter-based 'commentstring'. Recompute every time for a proper 240 -- dot-repeat. In Visual and sometimes Normal mode it uses start position. 241 toggle_lines(lnum_from, lnum_to, vim.api.nvim_win_get_cursor(0)) 242 return '' 243 end 244 245 --- Select contiguous commented lines at cursor 246 local function textobject() 247 local lnum_cur = vim.fn.line('.') 248 local parts = get_comment_parts({ lnum_cur, vim.fn.col('.') }) 249 local comment_check = make_comment_check(parts) 250 251 if not comment_check(vim.fn.getline(lnum_cur)) then 252 return 253 end 254 255 -- Compute commented range 256 local lnum_from = lnum_cur 257 while (lnum_from >= 2) and comment_check(vim.fn.getline(lnum_from - 1)) do 258 lnum_from = lnum_from - 1 259 end 260 261 local lnum_to = lnum_cur 262 local n_lines = vim.api.nvim_buf_line_count(0) 263 while (lnum_to <= n_lines - 1) and comment_check(vim.fn.getline(lnum_to + 1)) do 264 lnum_to = lnum_to + 1 265 end 266 267 -- Select range linewise for operator to act upon 268 vim.cmd('normal! ' .. lnum_from .. 'GV' .. lnum_to .. 'G') 269 end 270 271 return { operator = operator, textobject = textobject, toggle_lines = toggle_lines }