Neovim sign gutter, designed to be mostly VCS agnostic
1local M = {}
2
3local bit = require "bit"
4local util = require "vcsigns.util"
5local state = require "vcsigns.state"
6
7local band = bit.band
8local bor = bit.bor
9
10local function _popcount(x)
11 -- Count the number of bits set in x.
12 local count = 0
13 while x > 0 do
14 count = count + band(x, 1)
15 x = bit.rshift(x, 1)
16 end
17 return count
18end
19
20-- Will be overridden by user config.
21M.signs = nil
22
23---@enum SignType
24local SignType = {
25 ADD = 1,
26 CHANGE = 2,
27 DELETE_BELOW = 4,
28 DELETE_ABOVE = 8,
29}
30M.SignType = SignType
31
32function M.sign_type_to_string(sign_type)
33 local types = {}
34 if band(sign_type, SignType.ADD) ~= 0 then
35 table.insert(types, "ADD")
36 end
37 if band(sign_type, SignType.CHANGE) ~= 0 then
38 table.insert(types, "CHANGE")
39 end
40 if band(sign_type, SignType.DELETE_BELOW) ~= 0 then
41 table.insert(types, "DELETE_BELOW")
42 end
43 if band(sign_type, SignType.DELETE_ABOVE) ~= 0 then
44 table.insert(types, "DELETE_ABOVE")
45 end
46 return table.concat(types, "|")
47end
48
49---@class SignData
50---@field type SignType
51---@field count integer|nil
52local SignData = {}
53
54---@class VimSign
55---@field text string The sign text.
56---@field hl string The highlight group for the sign.
57local VimSign = {}
58
59--- Convert the internal sign representation to a vim sign.
60---@param sign SignData
61local function _to_vim_sign(sign)
62 ---@param count integer
63 ---@param text string
64 local function _delete_text(count, text)
65 if not vim.g.vcsigns_show_delete_count then
66 return text
67 end
68 if count == 1 then
69 -- Keep the sign as is.
70 elseif count < 10 then
71 text = text .. count
72 elseif count < 100 then
73 text = "" .. count
74 else
75 text = ">" .. text
76 end
77 return text
78 end
79
80 local bit_count = _popcount(sign.type)
81 if bit_count == 1 then
82 -- Simple happy case: Only one sign type.
83 local text = ""
84 local hl = nil
85 if band(sign.type, SignType.ADD) ~= 0 then
86 text = text .. M.signs.text.add
87 hl = M.signs.hl.add
88 elseif band(sign.type, SignType.CHANGE) ~= 0 then
89 text = text .. M.signs.text.change
90 hl = M.signs.hl.change
91 elseif band(sign.type, SignType.DELETE_BELOW) ~= 0 then
92 text = text .. _delete_text(sign.count, M.signs.text.delete_below)
93 hl = M.signs.hl.delete
94 elseif band(sign.type, SignType.DELETE_ABOVE) ~= 0 then
95 text = text .. _delete_text(sign.count, M.signs.text.delete_above)
96 hl = M.signs.hl.delete
97 end
98 return { text = text, hl = hl }
99 end
100
101 if M.signs.text.combined then
102 -- We have too many signs on one line and a combined sign is provided.
103 -- TODO(algmyr): Rename things to reflect new use.
104 return {
105 text = M.signs.text.combined,
106 hl = M.signs.hl.combined,
107 }
108 end
109
110 local is_add = band(sign.type, SignType.ADD) ~= 0
111 local is_change = band(sign.type, SignType.CHANGE) ~= 0
112 assert(not (is_add and is_change), "Sign cannot have both ADD and CHANGE.")
113
114 -- Add/change first.
115 local text = ""
116 local hls = {}
117 if is_add then
118 text = text .. M.signs.text.add
119 hls[#hls + 1] = M.signs.hl.add
120 end
121 if is_change then
122 text = text .. M.signs.text.change
123 hls[#hls + 1] = M.signs.hl.change
124 end
125
126 -- Then deletions.
127 local is_delete_below = band(sign.type, SignType.DELETE_BELOW) ~= 0
128 local is_delete_above = band(sign.type, SignType.DELETE_ABOVE) ~= 0
129 if is_delete_below and is_delete_above then
130 -- Combined delete above and below.
131 text = text .. M.signs.text.delete_above_below
132 hls[#hls + 1] = M.signs.hl.delete
133 elseif is_delete_below then
134 text = text .. M.signs.text.delete_below
135 hls[#hls + 1] = M.signs.hl.delete
136 elseif is_delete_above then
137 text = text .. M.signs.text.delete_above
138 hls[#hls + 1] = M.signs.hl.delete
139 end
140
141 if #hls == 1 then
142 return { text = text, hl = hls[1] }
143 else
144 return {
145 text = text,
146 -- TODO(algmyr): Change this naming to be about "combined".
147 -- Or somehow figure out multi highlight signs.
148 hl = M.signs.hl.combined or hls[1],
149 }
150 end
151end
152
153--- Try avoiding overlaps by flipping delete below into delete above.
154---@param signs table<number, SignData> The signs to adjust.
155---@param line_count integer The number of lines in the buffer.
156---@return table<number, SignData> The adjusted signs.
157local function _decongest_signs(signs, line_count)
158 local function flip(i)
159 signs[i + 1] = {
160 type = SignType.DELETE_ABOVE,
161 count = signs[i].count,
162 }
163 signs[i].type = bit.bxor(signs[i].type, SignType.DELETE_BELOW)
164 signs[i].count = 0
165 end
166
167 local function try_flip(i)
168 if i > line_count then
169 -- Ran into eof.
170 return false
171 end
172 if not signs[i] then
173 -- Space is free.
174 return true
175 end
176 if signs[i].type == SignType.DELETE_BELOW then
177 if try_flip(i + 1) then
178 flip(i)
179 return true
180 else
181 return false
182 end
183 end
184 -- Couldn't make space.
185 return false
186 end
187
188 -- See if congested deletion below can be flipped into a deletion above.
189 for i = 1, line_count - 1 do
190 local sign = signs[i]
191 if
192 sign
193 and _popcount(sign.type) > 1
194 and band(sign.type, SignType.DELETE_BELOW) ~= 0
195 then
196 if try_flip(i + 1) then
197 flip(i)
198 end
199 end
200 end
201 return signs
202end
203
204--- Adjust signs to be in range and to avoid overlaps.
205---@param signs table<number, SignData> The signs to adjust.
206---@param line_count integer The number of lines in the buffer.
207---@return table<number, SignData> The adjusted signs.
208local function _adjust_signs(signs, line_count)
209 signs = vim.deepcopy(signs)
210 if not vim.g.vcsigns_skip_sign_decongestion then
211 signs = _decongest_signs(signs, line_count)
212 end
213
214 -- Correct deletion on the 0th line, if it exists.
215 if signs[0] then
216 assert(_popcount(signs[0].type) == 1)
217 assert(signs[0].type == SignType.DELETE_BELOW)
218 local one = signs[1] or { type = 0, count = 0 }
219 one.type = bor(one.type, SignType.DELETE_ABOVE)
220 one.count = one.count + signs[0].count
221 signs[1] = one
222 signs[0] = nil
223 end
224
225 return signs
226end
227
228---@param hunks Hunk[]
229---@return { signs: table<number, SignData>, stats: { added: integer, modified: integer, removed: integer } }
230local function _compute_signs_unadjusted(hunks)
231 ---@type table<number, SignData>
232 local sign_lines = {}
233 local added = 0
234 local modified = 0
235 local deleted = 0
236
237 local function _add_sign(line, sign_type, count)
238 local sign = sign_lines[line] or { type = 0, count = 0 }
239 sign.type = bor(sign.type, sign_type)
240 if count then
241 assert(sign.count == 0, "Sign count should be set only once.")
242 sign.count = count
243 end
244 sign_lines[line] = sign
245 end
246
247 local function _add_sign_range(start, count, sign_type)
248 for i = 0, count - 1 do
249 _add_sign(start + i, sign_type, nil)
250 end
251 end
252
253 for _, hunk in ipairs(hunks) do
254 if hunk.minus_count == 0 and hunk.plus_count > 0 then
255 -- Pure add.
256 added = added + hunk.plus_count
257 _add_sign_range(hunk.plus_start, hunk.plus_count, SignType.ADD)
258 elseif hunk.minus_count > 0 and hunk.plus_count == 0 then
259 -- Pure delete.
260 deleted = deleted + hunk.minus_count
261 _add_sign(hunk.plus_start, SignType.DELETE_BELOW, hunk.minus_count)
262 elseif hunk.minus_count > 0 and hunk.plus_count > 0 then
263 if hunk.minus_count == hunk.plus_count then
264 -- All lines changed.
265 modified = modified + hunk.plus_count
266 _add_sign_range(hunk.plus_start, hunk.plus_count, SignType.CHANGE)
267 elseif hunk.minus_count < hunk.plus_count then
268 -- Some lines added.
269 local diff = hunk.plus_count - hunk.minus_count
270 modified = modified + hunk.minus_count
271 added = added + diff
272 _add_sign_range(hunk.plus_start, hunk.minus_count, SignType.CHANGE)
273 _add_sign_range(hunk.plus_start + hunk.minus_count, diff, SignType.ADD)
274 else
275 -- Some lines deleted.
276 local diff = hunk.minus_count - hunk.plus_count
277 modified = modified + hunk.plus_count
278 deleted = deleted + diff
279 _add_sign(hunk.plus_start - 1, SignType.DELETE_BELOW, hunk.minus_count)
280 _add_sign_range(hunk.plus_start, hunk.plus_count, SignType.CHANGE)
281 end
282 end
283 end
284
285 return {
286 signs = sign_lines,
287 stats = {
288 added = added,
289 modified = modified,
290 removed = deleted,
291 },
292 }
293end
294
295---@param bufnr integer
296function M.debug_compute_signs(bufnr)
297 local hunks = state.get(bufnr).diff.hunks
298 local line_count = vim.api.nvim_buf_line_count(bufnr)
299
300 local raw_signs = _compute_signs_unadjusted(hunks)
301 local adjusted_signs = _adjust_signs(raw_signs.signs, line_count)
302 -- Put representation in a buffer for human inspection.
303 local function fmt(sign)
304 if sign then
305 return string.format(
306 "%s(%d)",
307 M.sign_type_to_string(sign.type),
308 sign.count or -1
309 )
310 else
311 return ""
312 end
313 end
314
315 local lines = {}
316 for i = 0, line_count + 1 do
317 local raw = fmt(raw_signs.signs[i])
318 local adjusted = fmt(adjusted_signs[i])
319 local line = string.format("%4d | %40s | %40s", i, raw, adjusted)
320 lines[#lines + 1] = line
321 end
322
323 -- Open a new buffer and display the lines.
324 local buf = vim.api.nvim_create_buf(false, true)
325 vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
326
327 local win = vim.api.nvim_open_win(buf, true, {
328 split = "right",
329 win = 0,
330 })
331end
332
333--- Compute the signs to show for a list of hunks.
334---@param hunks Hunk[]
335---@param line_count integer The number of lines in the buffer.
336---@return { signs: table<number, SignData>, stats: { added: integer, modified: integer, removed: integer } }
337function M.compute_signs(hunks, line_count)
338 local result = _compute_signs_unadjusted(hunks)
339 result.signs = _adjust_signs(result.signs, line_count)
340 return result
341end
342
343local function _sign_namespace()
344 return vim.api.nvim_create_namespace "vcsigns"
345end
346
347---@param bufnr integer
348---@param hunks Hunk[]
349---@return nil
350function M.add_signs(bufnr, hunks)
351 local ns = _sign_namespace()
352 local line_count = vim.api.nvim_buf_line_count(bufnr)
353
354 local function _add_sign(line, sign)
355 if line < 1 or line > line_count then
356 util.verbose(
357 string.format(
358 "Tried to add sign on line %d for a buffer with %d lines.",
359 line,
360 line_count
361 )
362 )
363 return false
364 end
365 local config = {
366 sign_text = sign.text,
367 sign_hl_group = sign.hl,
368 priority = M.signs.priority,
369 }
370 if vim.g.vcsigns_highlight_number then
371 config.number_hl_group = sign.hl
372 end
373 vim.api.nvim_buf_set_extmark(bufnr, ns, line - 1, 0, config)
374 return true
375 end
376
377 vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
378 local result = M.compute_signs(hunks, line_count)
379 if result then
380 -- Record stats for use in statuslines and similar.
381 -- The table format is compatible with the "diff" section of lualine.
382 vim.b[bufnr].vcsigns_stats = result.stats
383 for i = 1, line_count do
384 if result.signs[i] then
385 _add_sign(i, _to_vim_sign(result.signs[i]))
386 end
387 end
388 end
389end
390
391--- Clear all signs in the buffer.
392---@param bufnr integer The buffer number.
393function M.clear_signs(bufnr)
394 local ns = _sign_namespace()
395 vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
396end
397
398return M