aboutsummaryrefslogtreecommitdiff
path: root/runtime/lua/vim/_comment.lua
blob: efe289b3e110cf299129379416dac26842daf659 (plain) (blame)
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
---@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 has_ts_parser, ts_parser = pcall(vim.treesitter.get_parser)
  if not has_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 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 }