From 00bf84cb05d357e864b56354b7a809dd0212b4f4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 1 Feb 2026 23:17:20 -0600 Subject: [PATCH] feat: highlights --- doc/fugitive-ts.nvim.txt | 40 ++++- lua/fugitive-ts/highlight.lua | 68 +++++-- lua/fugitive-ts/init.lua | 33 ++-- spec/highlight_spec.lua | 328 +++++++++++++++++++++++++++++++++- spec/init_spec.lua | 9 +- 5 files changed, 437 insertions(+), 41 deletions(-) diff --git a/doc/fugitive-ts.nvim.txt b/doc/fugitive-ts.nvim.txt index 9d5e5de..19a8bbc 100644 --- a/doc/fugitive-ts.nvim.txt +++ b/doc/fugitive-ts.nvim.txt @@ -56,11 +56,6 @@ CONFIGURATION *fugitive-ts-config* Example: >lua disabled_languages = { 'markdown', 'text' } < - {highlight_headers} (boolean, default: true) - Highlight function context in hunk headers. - The context portion of `@@ -10,3 +10,4 @@ func()` - will receive treesitter highlighting. - {debounce_ms} (integer, default: 50) Debounce delay in milliseconds for re-highlighting after buffer changes. Lower values feel snappier @@ -70,6 +65,37 @@ CONFIGURATION *fugitive-ts-config* Skip treesitter highlighting for hunks larger than this many lines. Prevents lag on massive diffs. + {conceal_prefixes} (boolean, default: true) + Hide diff prefixes (`+`/`-`/` `) using extmark + conceal. Makes code appear without the leading + diff character. + + {highlights} (table, default: see below) + Controls which highlight features are enabled. + See |fugitive-ts.Highlights| for fields. + + *fugitive-ts.Highlights* + Highlights table fields: ~ + {treesitter} (boolean, default: true) + Apply treesitter syntax highlighting to code. + + {headers} (boolean, default: true) + Highlight function context in hunk headers. + The context portion of `@@ -10,3 +10,4 @@ func()` + will receive treesitter highlighting. + + {background} (boolean, default: true) + Apply `DiffAdd` background to `+` lines and + `DiffDelete` background to `-` lines. + + {linenr} (boolean, default: true) + Highlight line numbers with `DiffAdd`/`DiffDelete` + colors matching the line background. + + {vim} (boolean, default: false) + Experimental: Use vim syntax highlighting as + fallback when no treesitter parser is available. + ============================================================================== API *fugitive-ts-api* @@ -103,8 +129,10 @@ IMPLEMENTATION *fugitive-ts-implementation* - Language is detected from the filename using |vim.filetype.match()| - Diff prefixes (`+`/`-`/` `) are stripped from code lines - Code is parsed with |vim.treesitter.get_string_parser()| + - Background extmarks (`DiffAdd`/`DiffDelete`) applied at priority 198 + - `Normal` extmarks at priority 199 clear underlying diff foreground - Treesitter highlights are applied as extmarks at priority 200 - - A `Normal` extmark at priority 199 clears underlying diff colors + - Conceal extmarks hide diff prefixes when `conceal_prefixes` is enabled 4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events ============================================================================== diff --git a/lua/fugitive-ts/highlight.lua b/lua/fugitive-ts/highlight.lua index 6286f10..31878b6 100644 --- a/lua/fugitive-ts/highlight.lua +++ b/lua/fugitive-ts/highlight.lua @@ -55,26 +55,30 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, debug) return extmark_count end +---@class fugitive-ts.HunkOpts +---@field max_lines integer +---@field conceal_prefixes boolean +---@field highlights fugitive-ts.Highlights +---@field debug boolean + ---@param bufnr integer ---@param ns integer ---@param hunk fugitive-ts.Hunk ----@param max_lines integer ----@param highlight_headers boolean ----@param debug? boolean -function M.highlight_hunk(bufnr, ns, hunk, max_lines, highlight_headers, debug) +---@param opts fugitive-ts.HunkOpts +function M.highlight_hunk(bufnr, ns, hunk, opts) local lang = hunk.lang if not lang then return end - if #hunk.lines > max_lines then - if debug then + if #hunk.lines > opts.max_lines then + if opts.debug then dbg( 'skipping hunk %s:%d (%d lines > %d max)', hunk.filename, hunk.start_line, #hunk.lines, - max_lines + opts.max_lines ) end return @@ -93,7 +97,7 @@ function M.highlight_hunk(bufnr, ns, hunk, max_lines, highlight_headers, debug) local ok, parser_obj = pcall(vim.treesitter.get_string_parser, code, lang) if not ok or not parser_obj then - if debug then + if opts.debug then dbg('failed to create parser for lang: %s', lang) end return @@ -101,7 +105,7 @@ function M.highlight_hunk(bufnr, ns, hunk, max_lines, highlight_headers, debug) local trees = parser_obj:parse() if not trees or #trees == 0 then - if debug then + if opts.debug then dbg('parse returned no trees for lang: %s', lang) end return @@ -109,22 +113,29 @@ function M.highlight_hunk(bufnr, ns, hunk, max_lines, highlight_headers, debug) local query = vim.treesitter.query.get(lang, 'highlights') if not query then - if debug then + if opts.debug then dbg('no highlights query for lang: %s', lang) end return end - if highlight_headers and hunk.header_context and hunk.header_context_col then + if opts.highlights.headers and 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, debug) - if debug and header_extmarks > 0 then + local header_extmarks = highlight_text( + bufnr, + ns, + hunk, + hunk.header_context_col, + hunk.header_context, + lang, + opts.debug + ) + if opts.debug and header_extmarks > 0 then dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks) end end @@ -132,7 +143,28 @@ function M.highlight_hunk(bufnr, ns, hunk, max_lines, highlight_headers, debug) for i, line in ipairs(hunk.lines) do local buf_line = hunk.start_line + i - 1 local line_len = #line - if line_len > 1 then + local prefix = line:sub(1, 1) + + if opts.conceal_prefixes then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + end_col = 1, + conceal = '', + }) + end + + if opts.highlights.background and (prefix == '+' or prefix == '-') then + local line_hl = prefix == '+' and 'DiffAdd' or 'DiffDelete' + local extmark_opts = { + line_hl_group = line_hl, + priority = 198, + } + if opts.highlights.linenr then + extmark_opts.number_hl_group = line_hl + end + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, extmark_opts) + end + + if line_len > 1 and opts.highlights.treesitter then pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, { end_col = line_len, hl_group = 'Normal', @@ -141,6 +173,10 @@ function M.highlight_hunk(bufnr, ns, hunk, max_lines, highlight_headers, debug) end end + if not opts.highlights.treesitter then + return + end + local extmark_count = 0 for id, node, _ in query:iter_captures(trees[1]:root(), code) do local capture_name = '@' .. query.captures[id] @@ -160,7 +196,7 @@ function M.highlight_hunk(bufnr, ns, hunk, max_lines, highlight_headers, debug) extmark_count = extmark_count + 1 end - if debug then + if opts.debug then dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count) end end diff --git a/lua/fugitive-ts/init.lua b/lua/fugitive-ts/init.lua index 31084a2..b659342 100644 --- a/lua/fugitive-ts/init.lua +++ b/lua/fugitive-ts/init.lua @@ -1,11 +1,19 @@ +---@class fugitive-ts.Highlights +---@field treesitter boolean +---@field headers boolean +---@field background boolean +---@field linenr boolean +---@field vim boolean + ---@class fugitive-ts.Config ---@field enabled boolean ---@field debug boolean ---@field languages table ---@field disabled_languages string[] ----@field highlight_headers boolean ---@field debounce_ms integer ---@field max_lines_per_hunk integer +---@field conceal_prefixes boolean +---@field highlights fugitive-ts.Highlights ---@class fugitive-ts ---@field attach fun(bufnr?: integer) @@ -24,9 +32,16 @@ local default_config = { debug = false, languages = {}, disabled_languages = {}, - highlight_headers = true, debounce_ms = 50, max_lines_per_hunk = 500, + conceal_prefixes = true, + highlights = { + treesitter = true, + headers = true, + background = true, + linenr = true, + vim = false, + }, } ---@type fugitive-ts.Config @@ -61,14 +76,12 @@ local function highlight_buffer(bufnr) parser.parse_buffer(bufnr, config.languages, config.disabled_languages, config.debug) dbg('found %d hunks in buffer %d', #hunks, bufnr) for _, hunk in ipairs(hunks) do - highlight.highlight_hunk( - bufnr, - ns, - hunk, - config.max_lines_per_hunk, - config.highlight_headers, - config.debug - ) + highlight.highlight_hunk(bufnr, ns, hunk, { + max_lines = config.max_lines_per_hunk, + conceal_prefixes = config.conceal_prefixes, + highlights = config.highlights, + debug = config.debug, + }) end end diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 62d57b8..3f800f7 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -25,6 +25,33 @@ describe('highlight', function() return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) end + local function default_opts(overrides) + local opts = { + max_lines = 500, + conceal_prefixes = false, + highlights = { + treesitter = true, + headers = false, + background = false, + linenr = false, + vim = false, + }, + debug = false, + } + if overrides then + for k, v in pairs(overrides) do + if k == 'highlights' then + for hk, hv in pairs(v) do + opts.highlights[hk] = hv + end + else + opts[k] = v + end + end + end + return opts + end + it('applies extmarks for lua code', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -39,7 +66,7 @@ describe('highlight', function() lines = { ' local x = 1', '+local y = 2' }, } - highlight.highlight_hunk(bufnr, ns, hunk, 500, false, false) + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) local extmarks = get_extmarks(bufnr) assert.is_true(#extmarks > 0) @@ -60,7 +87,7 @@ describe('highlight', function() lines = { ' local x = 1', '+local y = 2' }, } - highlight.highlight_hunk(bufnr, ns, hunk, 500, false, false) + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) local extmarks = get_extmarks(bufnr) local has_normal = false @@ -90,7 +117,7 @@ describe('highlight', function() lines = hunk_lines, } - highlight.highlight_hunk(bufnr, ns, hunk, 500, false, false) + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) local extmarks = get_extmarks(bufnr) assert.are.equal(0, #extmarks) @@ -111,7 +138,7 @@ describe('highlight', function() lines = { ' some content', '+more content' }, } - highlight.highlight_hunk(bufnr, ns, hunk, 500, false, false) + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) local extmarks = get_extmarks(bufnr) assert.are.equal(0, #extmarks) @@ -134,7 +161,7 @@ describe('highlight', function() lines = { ' local x = 1', '+local y = 2' }, } - highlight.highlight_hunk(bufnr, ns, hunk, 500, true, false) + highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ highlights = { headers = true } })) local extmarks = get_extmarks(bufnr) local has_header_extmark = false @@ -163,7 +190,7 @@ describe('highlight', function() lines = { ' local x = 1' }, } - highlight.highlight_hunk(bufnr, ns, hunk, 500, false, false) + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) local extmarks = get_extmarks(bufnr) local header_extmarks = 0 @@ -189,7 +216,7 @@ describe('highlight', function() } assert.has_no.errors(function() - highlight.highlight_hunk(bufnr, ns, hunk, 500, false, false) + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) end) delete_buffer(bufnr) end) @@ -209,9 +236,294 @@ describe('highlight', function() } assert.has_no.errors(function() - highlight.highlight_hunk(bufnr, ns, hunk, 500, false, false) + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) end) delete_buffer(bufnr) end) + + it('applies conceal extmarks when conceal_prefixes enabled', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ conceal_prefixes = true })) + + local extmarks = get_extmarks(bufnr) + local conceal_count = 0 + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].conceal == '' then + conceal_count = conceal_count + 1 + end + end + assert.are.equal(2, conceal_count) + delete_buffer(bufnr) + end) + + it('does not apply conceal extmarks when conceal_prefixes disabled', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ conceal_prefixes = false })) + + local extmarks = get_extmarks(bufnr) + local conceal_count = 0 + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].conceal == '' then + conceal_count = conceal_count + 1 + end + end + assert.are.equal(0, conceal_count) + delete_buffer(bufnr) + end) + + it('applies DiffAdd background to + lines when background enabled', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true } }) + ) + + local extmarks = get_extmarks(bufnr) + local has_diff_add = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].line_hl_group == 'DiffAdd' then + has_diff_add = true + break + end + end + assert.is_true(has_diff_add) + delete_buffer(bufnr) + end) + + it('applies DiffDelete background to - lines when background enabled', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,1 @@', + ' local x = 1', + '-local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '-local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true } }) + ) + + local extmarks = get_extmarks(bufnr) + local has_diff_delete = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].line_hl_group == 'DiffDelete' then + has_diff_delete = true + break + end + end + assert.is_true(has_diff_delete) + delete_buffer(bufnr) + end) + + it('does not apply background when background disabled', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = false } }) + ) + + local extmarks = get_extmarks(bufnr) + local has_line_hl = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].line_hl_group then + has_line_hl = true + break + end + end + assert.is_false(has_line_hl) + delete_buffer(bufnr) + end) + + it('applies number_hl_group when linenr enabled', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true, linenr = true } }) + ) + + local extmarks = get_extmarks(bufnr) + local has_number_hl = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].number_hl_group then + has_number_hl = true + break + end + end + assert.is_true(has_number_hl) + delete_buffer(bufnr) + end) + + it('does not apply number_hl_group when linenr disabled', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true, linenr = false } }) + ) + + local extmarks = get_extmarks(bufnr) + local has_number_hl = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].number_hl_group then + has_number_hl = true + break + end + end + assert.is_false(has_number_hl) + delete_buffer(bufnr) + end) + + it('skips treesitter highlights when treesitter disabled', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { treesitter = false, background = true } }) + ) + + local extmarks = get_extmarks(bufnr) + local has_ts_highlight = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@') then + has_ts_highlight = true + break + end + end + assert.is_false(has_ts_highlight) + delete_buffer(bufnr) + end) + + it('still applies background when treesitter disabled', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { treesitter = false, background = true } }) + ) + + local extmarks = get_extmarks(bufnr) + local has_diff_add = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].line_hl_group == 'DiffAdd' then + has_diff_add = true + break + end + end + assert.is_true(has_diff_add) + delete_buffer(bufnr) + end) end) end) diff --git a/spec/init_spec.lua b/spec/init_spec.lua index 5e7a0ff..77c3db0 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -22,9 +22,16 @@ describe('fugitive-ts', function() debug = true, languages = { ['.envrc'] = 'bash' }, disabled_languages = { 'markdown' }, - highlight_headers = false, debounce_ms = 100, max_lines_per_hunk = 1000, + conceal_prefixes = false, + highlights = { + treesitter = true, + headers = false, + background = true, + linenr = true, + vim = false, + }, }) end) end)