feat: rename everything

This commit is contained in:
Barrett Ruth 2026-02-02 22:09:13 -05:00
parent 8f7442eaa2
commit 67116f38bc
16 changed files with 172 additions and 165 deletions

20
lua/diffs/health.lua Normal file
View file

@ -0,0 +1,20 @@
local M = {}
function M.check()
vim.health.start('diffs.nvim')
if vim.fn.has('nvim-0.9.0') == 1 then
vim.health.ok('Neovim 0.9.0+ detected')
else
vim.health.error('diffs.nvim requires Neovim 0.9.0+')
end
local fugitive_loaded = vim.fn.exists(':Git') == 2
if fugitive_loaded then
vim.health.ok('vim-fugitive detected')
else
vim.health.warn('vim-fugitive not detected (required for unified diff highlighting)')
end
end
return M

304
lua/diffs/highlight.lua Normal file
View file

@ -0,0 +1,304 @@
local M = {}
local dbg = require('diffs.log').dbg
---@param bufnr integer
---@param ns integer
---@param hunk diffs.Hunk
---@param col_offset integer
---@param text string
---@param lang string
---@return integer
local function highlight_text(bufnr, ns, hunk, col_offset, text, lang)
local ok, parser_obj = pcall(vim.treesitter.get_string_parser, text, lang)
if not ok or not parser_obj then
return 0
end
local trees = parser_obj:parse()
if not trees or #trees == 0 then
return 0
end
local query = vim.treesitter.query.get(lang, 'highlights')
if not query then
return 0
end
local extmark_count = 0
local header_line = hunk.start_line - 1
for id, node, _ in query:iter_captures(trees[1]:root(), text) do
local capture_name = '@' .. query.captures[id]
local sr, sc, er, ec = node:range()
local buf_sr = header_line + sr
local buf_er = header_line + er
local buf_sc = col_offset + sc
local buf_ec = col_offset + ec
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er,
end_col = buf_ec,
hl_group = capture_name,
priority = 200,
})
extmark_count = extmark_count + 1
end
return extmark_count
end
---@class diffs.HunkOpts
---@field hide_prefix boolean
---@field treesitter diffs.TreesitterConfig
---@field vim diffs.VimConfig
---@field highlights diffs.Highlights
---@param bufnr integer
---@param ns integer
---@param hunk diffs.Hunk
---@param code_lines string[]
---@return integer
local function highlight_treesitter(bufnr, ns, hunk, code_lines)
local lang = hunk.lang
if not lang then
return 0
end
local code = table.concat(code_lines, '\n')
if code == '' then
return 0
end
local ok, parser_obj = pcall(vim.treesitter.get_string_parser, code, lang)
if not ok or not parser_obj then
dbg('failed to create parser for lang: %s', lang)
return 0
end
local trees = parser_obj:parse()
if not trees or #trees == 0 then
dbg('parse returned no trees for lang: %s', lang)
return 0
end
local query = vim.treesitter.query.get(lang, 'highlights')
if not query then
dbg('no highlights query for lang: %s', lang)
return 0
end
if hunk.header_context and hunk.header_context_col then
local header_line = hunk.start_line - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, {
end_col = hunk.header_context_col + #hunk.header_context,
hl_group = 'Normal',
priority = 199,
})
local header_extmarks =
highlight_text(bufnr, ns, hunk, hunk.header_context_col, hunk.header_context, lang)
if header_extmarks > 0 then
dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks)
end
end
local extmark_count = 0
for id, node, _ in query:iter_captures(trees[1]:root(), code) do
local capture_name = '@' .. query.captures[id]
local sr, sc, er, ec = node:range()
local buf_sr = hunk.start_line + sr
local buf_er = hunk.start_line + er
local buf_sc = sc + 1
local buf_ec = ec + 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er,
end_col = buf_ec,
hl_group = capture_name,
priority = 200,
})
extmark_count = extmark_count + 1
end
return extmark_count
end
---@alias diffs.SyntaxQueryFn fun(line: integer, col: integer): integer, string
---@param query_fn diffs.SyntaxQueryFn
---@param code_lines string[]
---@return {line: integer, col_start: integer, col_end: integer, hl_name: string}[]
function M.coalesce_syntax_spans(query_fn, code_lines)
local spans = {}
for i, line in ipairs(code_lines) do
local col = 1
local line_len = #line
while col <= line_len do
local syn_id, hl_name = query_fn(i, col)
if syn_id == 0 then
col = col + 1
else
local span_start = col
col = col + 1
while col <= line_len do
local next_id, next_name = query_fn(i, col)
if next_id == 0 or next_name ~= hl_name then
break
end
col = col + 1
end
if hl_name ~= '' then
table.insert(spans, {
line = i,
col_start = span_start,
col_end = col,
hl_name = hl_name,
})
end
end
end
end
return spans
end
---@param bufnr integer
---@param ns integer
---@param hunk diffs.Hunk
---@param code_lines string[]
---@return integer
local function highlight_vim_syntax(bufnr, ns, hunk, code_lines)
local ft = hunk.ft
if not ft then
return 0
end
if #code_lines == 0 then
return 0
end
local scratch = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(scratch, 0, -1, false, code_lines)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scratch })
local spans = {}
vim.api.nvim_buf_call(scratch, function()
vim.cmd('setlocal syntax=' .. ft)
vim.cmd('redraw')
---@param line integer
---@param col integer
---@return integer, string
local function query_fn(line, col)
local syn_id = vim.fn.synID(line, col, 1)
if syn_id == 0 then
return 0, ''
end
return syn_id, vim.fn.synIDattr(vim.fn.synIDtrans(syn_id), 'name')
end
spans = M.coalesce_syntax_spans(query_fn, code_lines)
end)
vim.api.nvim_buf_delete(scratch, { force = true })
local extmark_count = 0
for _, span in ipairs(spans) do
local buf_line = hunk.start_line + span.line - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
end_col = span.col_end,
hl_group = span.hl_name,
priority = 200,
})
extmark_count = extmark_count + 1
end
return extmark_count
end
---@param bufnr integer
---@param ns integer
---@param hunk diffs.Hunk
---@param opts diffs.HunkOpts
function M.highlight_hunk(bufnr, ns, hunk, opts)
local use_ts = hunk.lang and opts.treesitter.enabled
local use_vim = not use_ts and hunk.ft and opts.vim.enabled
local max_lines = use_ts and opts.treesitter.max_lines or opts.vim.max_lines
if (use_ts or use_vim) and #hunk.lines > max_lines then
dbg(
'skipping hunk %s:%d (%d lines > %d max)',
hunk.filename,
hunk.start_line,
#hunk.lines,
max_lines
)
use_ts = false
use_vim = false
end
local apply_syntax = use_ts or use_vim
---@type string[]
local code_lines = {}
if apply_syntax then
for _, line in ipairs(hunk.lines) do
table.insert(code_lines, line:sub(2))
end
end
local extmark_count = 0
if use_ts then
extmark_count = highlight_treesitter(bufnr, ns, hunk, code_lines)
elseif use_vim then
extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines)
end
local syntax_applied = extmark_count > 0
for i, line in ipairs(hunk.lines) do
local buf_line = hunk.start_line + i - 1
local line_len = #line
local prefix = line:sub(1, 1)
local is_diff_line = prefix == '+' or prefix == '-'
local line_hl = is_diff_line and (prefix == '+' and 'DiffsAdd' or 'DiffsDelete') or nil
local number_hl = is_diff_line and (prefix == '+' and 'DiffsAddNr' or 'DiffsDeleteNr') or nil
if opts.hide_prefix then
local virt_hl = (opts.highlights.background and line_hl) or nil
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
virt_text = { { ' ', virt_hl } },
virt_text_pos = 'overlay',
})
end
if opts.highlights.background and is_diff_line then
local extmark_opts = {
line_hl_group = line_hl,
priority = 198,
}
if opts.highlights.gutter then
extmark_opts.number_hl_group = number_hl
end
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, extmark_opts)
end
if line_len > 1 and syntax_applied then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, {
end_col = line_len,
hl_group = 'Normal',
priority = 199,
})
end
end
dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count)
end
return M

349
lua/diffs/init.lua Normal file
View file

@ -0,0 +1,349 @@
---@class diffs.Highlights
---@field background boolean
---@field gutter boolean
---@class diffs.TreesitterConfig
---@field enabled boolean
---@field max_lines integer
---@class diffs.VimConfig
---@field enabled boolean
---@field max_lines integer
---@class diffs.Config
---@field enabled boolean
---@field debug boolean
---@field debounce_ms integer
---@field hide_prefix boolean
---@field treesitter diffs.TreesitterConfig
---@field vim diffs.VimConfig
---@field highlights diffs.Highlights
---@class diffs
---@field attach fun(bufnr?: integer)
---@field refresh fun(bufnr?: integer)
---@field setup fun(opts?: diffs.Config)
local M = {}
local highlight = require('diffs.highlight')
local log = require('diffs.log')
local parser = require('diffs.parser')
local ns = vim.api.nvim_create_namespace('diffs')
---@param hex integer
---@param bg_hex integer
---@param alpha number
---@return integer
local function blend_color(hex, bg_hex, alpha)
---@diagnostic disable: undefined-global
local r = bit.band(bit.rshift(hex, 16), 0xFF)
local g = bit.band(bit.rshift(hex, 8), 0xFF)
local b = bit.band(hex, 0xFF)
local bg_r = bit.band(bit.rshift(bg_hex, 16), 0xFF)
local bg_g = bit.band(bit.rshift(bg_hex, 8), 0xFF)
local bg_b = bit.band(bg_hex, 0xFF)
local blend_r = math.floor(r * alpha + bg_r * (1 - alpha))
local blend_g = math.floor(g * alpha + bg_g * (1 - alpha))
local blend_b = math.floor(b * alpha + bg_b * (1 - alpha))
return bit.bor(bit.lshift(blend_r, 16), bit.lshift(blend_g, 8), blend_b)
---@diagnostic enable: undefined-global
end
---@param name string
---@return table
local function resolve_hl(name)
local hl = vim.api.nvim_get_hl(0, { name = name })
while hl.link do
hl = vim.api.nvim_get_hl(0, { name = hl.link })
end
return hl
end
---@type diffs.Config
local default_config = {
enabled = true,
debug = false,
debounce_ms = 0,
hide_prefix = false,
treesitter = {
enabled = true,
max_lines = 500,
},
vim = {
enabled = false,
max_lines = 200,
},
highlights = {
background = true,
gutter = true,
},
}
---@type diffs.Config
local config = vim.deepcopy(default_config)
---@type table<integer, boolean>
local attached_buffers = {}
---@type table<integer, boolean>
local diff_windows = {}
---@param bufnr integer
---@return boolean
function M.is_fugitive_buffer(bufnr)
return vim.api.nvim_buf_get_name(bufnr):match('^fugitive://') ~= nil
end
local dbg = log.dbg
---@param bufnr integer
local function highlight_buffer(bufnr)
if not config.enabled then
return
end
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
local hunks = parser.parse_buffer(bufnr)
dbg('found %d hunks in buffer %d', #hunks, bufnr)
for _, hunk in ipairs(hunks) do
highlight.highlight_hunk(bufnr, ns, hunk, {
hide_prefix = config.hide_prefix,
treesitter = config.treesitter,
vim = config.vim,
highlights = config.highlights,
})
end
end
---@param bufnr integer
---@return fun()
local function create_debounced_highlight(bufnr)
local timer = nil ---@type table?
return function()
if timer then
timer:stop() ---@diagnostic disable-line: undefined-field
timer:close() ---@diagnostic disable-line: undefined-field
timer = nil
end
local t = vim.uv.new_timer()
if not t then
highlight_buffer(bufnr)
return
end
timer = t
t:start(
config.debounce_ms,
0,
vim.schedule_wrap(function()
if timer == t then
timer = nil
t:close()
end
highlight_buffer(bufnr)
end)
)
end
end
---@param bufnr? integer
function M.attach(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
if attached_buffers[bufnr] then
return
end
attached_buffers[bufnr] = true
dbg('attaching to buffer %d', bufnr)
local debounced = create_debounced_highlight(bufnr)
highlight_buffer(bufnr)
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
buffer = bufnr,
callback = debounced,
})
vim.api.nvim_create_autocmd('Syntax', {
buffer = bufnr,
callback = function()
dbg('syntax event, re-highlighting buffer %d', bufnr)
highlight_buffer(bufnr)
end,
})
vim.api.nvim_create_autocmd('BufReadPost', {
buffer = bufnr,
callback = function()
dbg('BufReadPost event, re-highlighting buffer %d', bufnr)
highlight_buffer(bufnr)
end,
})
vim.api.nvim_create_autocmd('BufWipeout', {
buffer = bufnr,
callback = function()
attached_buffers[bufnr] = nil
end,
})
end
---@param bufnr? integer
function M.refresh(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
highlight_buffer(bufnr)
end
local function compute_highlight_groups()
local normal = vim.api.nvim_get_hl(0, { name = 'Normal' })
local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' })
local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' })
local diff_added = resolve_hl('diffAdded')
local diff_removed = resolve_hl('diffRemoved')
local bg = normal.bg or 0x1e1e2e
local add_bg = diff_add.bg or 0x2e4a3a
local del_bg = diff_delete.bg or 0x4a2e3a
local add_fg = diff_added.fg or diff_add.fg or 0x80c080
local del_fg = diff_removed.fg or diff_delete.fg or 0xc08080
local blended_add = blend_color(add_bg, bg, 0.4)
local blended_del = blend_color(del_bg, bg, 0.4)
vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = blended_add })
vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = blended_del })
vim.api.nvim_set_hl(0, 'DiffsAddNr', { fg = add_fg, bg = blended_add })
vim.api.nvim_set_hl(0, 'DiffsDeleteNr', { fg = del_fg, bg = blended_del })
local diff_change = resolve_hl('DiffChange')
local diff_text = resolve_hl('DiffText')
vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { bg = diff_add.bg })
vim.api.nvim_set_hl(0, 'DiffsDiffDelete', { bg = diff_delete.bg })
vim.api.nvim_set_hl(0, 'DiffsDiffChange', { bg = diff_change.bg })
vim.api.nvim_set_hl(0, 'DiffsDiffText', { bg = diff_text.bg })
end
local DIFF_WINHIGHLIGHT = table.concat({
'DiffAdd:DiffsDiffAdd',
'DiffDelete:DiffsDiffDelete',
'DiffChange:DiffsDiffChange',
'DiffText:DiffsDiffText',
}, ',')
function M.attach_diff()
if not config.enabled then
return
end
local tabpage = vim.api.nvim_get_current_tabpage()
local wins = vim.api.nvim_tabpage_list_wins(tabpage)
local diff_wins = {}
for _, win in ipairs(wins) do
if vim.api.nvim_win_is_valid(win) and vim.wo[win].diff then
table.insert(diff_wins, win)
end
end
if #diff_wins == 0 then
return
end
for _, win in ipairs(diff_wins) do
vim.api.nvim_set_option_value('winhighlight', DIFF_WINHIGHLIGHT, { win = win })
diff_windows[win] = true
dbg('applied diff winhighlight to window %d', win)
end
end
function M.detach_diff()
for win, _ in pairs(diff_windows) do
if vim.api.nvim_win_is_valid(win) then
vim.api.nvim_set_option_value('winhighlight', '', { win = win })
end
diff_windows[win] = nil
end
end
---@param opts? diffs.Config
function M.setup(opts)
opts = opts or {}
vim.validate({
enabled = { opts.enabled, 'boolean', true },
debug = { opts.debug, 'boolean', true },
debounce_ms = { opts.debounce_ms, 'number', true },
hide_prefix = { opts.hide_prefix, 'boolean', true },
treesitter = { opts.treesitter, 'table', true },
vim = { opts.vim, 'table', true },
highlights = { opts.highlights, 'table', true },
})
if opts.treesitter then
vim.validate({
['treesitter.enabled'] = { opts.treesitter.enabled, 'boolean', true },
['treesitter.max_lines'] = { opts.treesitter.max_lines, 'number', true },
})
end
if opts.vim then
vim.validate({
['vim.enabled'] = { opts.vim.enabled, 'boolean', true },
['vim.max_lines'] = { opts.vim.max_lines, 'number', true },
})
end
if opts.highlights then
vim.validate({
['highlights.background'] = { opts.highlights.background, 'boolean', true },
['highlights.gutter'] = { opts.highlights.gutter, 'boolean', true },
})
end
if opts.debounce_ms and opts.debounce_ms < 0 then
error('diffs: debounce_ms must be >= 0')
end
if opts.treesitter and opts.treesitter.max_lines and opts.treesitter.max_lines < 1 then
error('diffs: treesitter.max_lines must be >= 1')
end
if opts.vim and opts.vim.max_lines and opts.vim.max_lines < 1 then
error('diffs: vim.max_lines must be >= 1')
end
config = vim.tbl_deep_extend('force', default_config, opts)
log.set_enabled(config.debug)
compute_highlight_groups()
vim.api.nvim_create_autocmd('ColorScheme', {
callback = function()
compute_highlight_groups()
for bufnr, _ in pairs(attached_buffers) do
highlight_buffer(bufnr)
end
end,
})
vim.api.nvim_create_autocmd('WinClosed', {
callback = function(args)
local win = tonumber(args.match)
if win and diff_windows[win] then
diff_windows[win] = nil
end
end,
})
end
return M

19
lua/diffs/log.lua Normal file
View file

@ -0,0 +1,19 @@
local M = {}
local enabled = false
---@param val boolean
function M.set_enabled(val)
enabled = val
end
---@param msg string
---@param ... any
function M.dbg(msg, ...)
if not enabled then
return
end
vim.notify('[diffs] ' .. string.format(msg, ...), vim.log.levels.DEBUG)
end
return M

124
lua/diffs/parser.lua Normal file
View file

@ -0,0 +1,124 @@
---@class diffs.Hunk
---@field filename string
---@field ft string?
---@field lang string?
---@field start_line integer
---@field header_context string?
---@field header_context_col integer?
---@field lines string[]
local M = {}
local dbg = require('diffs.log').dbg
---@param filename string
---@return string?
local function get_ft_from_filename(filename)
local ft = vim.filetype.match({ filename = filename })
if not ft then
dbg('no filetype for: %s', filename)
end
return ft
end
---@param ft string
---@return string?
local function get_lang_from_ft(ft)
local lang = vim.treesitter.language.get_lang(ft)
if lang then
local ok = pcall(vim.treesitter.language.inspect, lang)
if ok then
return lang
end
dbg('no parser for lang: %s (ft: %s)', lang, ft)
else
dbg('no ts lang for filetype: %s', ft)
end
return nil
end
---@param bufnr integer
---@return diffs.Hunk[]
function M.parse_buffer(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
---@type diffs.Hunk[]
local hunks = {}
---@type string?
local current_filename = nil
---@type string?
local current_ft = nil
---@type string?
local current_lang = nil
---@type integer?
local hunk_start = nil
---@type string?
local hunk_header_context = nil
---@type integer?
local hunk_header_context_col = nil
---@type string[]
local hunk_lines = {}
local function flush_hunk()
if hunk_start and #hunk_lines > 0 and (current_lang or current_ft) then
table.insert(hunks, {
filename = current_filename,
ft = current_ft,
lang = current_lang,
start_line = hunk_start,
header_context = hunk_header_context,
header_context_col = hunk_header_context_col,
lines = hunk_lines,
})
end
hunk_start = nil
hunk_header_context = nil
hunk_header_context_col = nil
hunk_lines = {}
end
for i, line in ipairs(lines) do
local filename = line:match('^[MADRC%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$')
if filename then
flush_hunk()
current_filename = filename
current_ft = get_ft_from_filename(filename)
current_lang = current_ft and get_lang_from_ft(current_ft) or nil
if current_lang then
dbg('file: %s -> lang: %s', filename, current_lang)
elseif current_ft then
dbg('file: %s -> ft: %s (no ts parser)', filename, current_ft)
end
elseif line:match('^@@.-@@') then
flush_hunk()
hunk_start = i
local prefix, context = line:match('^(@@.-@@%s*)(.*)')
if context and context ~= '' then
hunk_header_context = context
hunk_header_context_col = #prefix
end
elseif hunk_start then
local prefix = line:sub(1, 1)
if prefix == ' ' or prefix == '+' or prefix == '-' then
table.insert(hunk_lines, line)
elseif
line == ''
or line:match('^[MADRC%?!]%s+')
or line:match('^diff ')
or line:match('^index ')
or line:match('^Binary ')
then
flush_hunk()
current_filename = nil
current_ft = nil
current_lang = nil
end
end
end
flush_hunk()
return hunks
end
return M