_comment.lua 8.6 KB

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