neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

_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 }