Neovim sign gutter, designed to be mostly VCS agnostic
at experimental_diffthis 398 lines 11 kB view raw
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