From 69943a09c40c922a7822ed7ce16be8afcf1a08d6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 2 Feb 2026 15:18:25 -0500 Subject: [PATCH 1/8] feat: cleanup config options --- doc/fugitive-ts.nvim.txt | 59 +++++++++++++++++--------------- lua/fugitive-ts/highlight.lua | 12 ++++--- lua/fugitive-ts/init.lua | 54 ++++++++++++++++++++---------- lua/fugitive-ts/parser.lua | 23 ++----------- spec/init_spec.lua | 13 +++++--- spec/parser_spec.lua | 63 ++++++----------------------------- 6 files changed, 98 insertions(+), 126 deletions(-) diff --git a/doc/fugitive-ts.nvim.txt b/doc/fugitive-ts.nvim.txt index d7a56fb..6250d2e 100644 --- a/doc/fugitive-ts.nvim.txt +++ b/doc/fugitive-ts.nvim.txt @@ -42,45 +42,51 @@ CONFIGURATION *fugitive-ts-config* Enable debug logging to |:messages| with `[fugitive-ts]` prefix. - {languages} (table, default: {}) - Custom filename to treesitter language mappings. - Useful for non-standard file extensions. - Example: >lua - languages = { - ['.envrc'] = 'bash', - ['Justfile'] = 'just', - } -< - {disabled_languages} (string[], default: {}) - Treesitter language names to skip highlighting. - Example: >lua - disabled_languages = { 'markdown', 'text' } -< {debounce_ms} (integer, default: 0) Debounce delay in milliseconds for re-highlighting after buffer changes. Lower values feel snappier but use more CPU. - {max_lines_per_hunk} (integer, default: 500) - Skip treesitter highlighting for hunks larger than - this many lines. Prevents lag on massive diffs. - - {hide_prefix} (boolean, default: true) + {hide_prefix} (boolean, default: false) Hide diff prefixes (`+`/`-`/` `) using virtual text overlay. Makes code appear without the leading diff character. When `highlights.background` is also enabled, the overlay inherits the line's background color. + {treesitter} (table, default: see below) + Treesitter highlighting options. + See |fugitive-ts.TreesitterConfig| for fields. + + {vim} (table, default: see below) + Vim syntax highlighting options (experimental). + See |fugitive-ts.VimConfig| for fields. + {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) + *fugitive-ts.TreesitterConfig* + Treesitter config fields: ~ + {enabled} (boolean, default: true) Apply treesitter syntax highlighting to code. + {max_lines} (integer, default: 500) + Skip treesitter highlighting for hunks larger than + this many lines. Prevents lag on massive diffs. + + *fugitive-ts.VimConfig* + Vim config fields: ~ + {enabled} (boolean, default: false) + Experimental: Use vim syntax highlighting as + fallback when no treesitter parser is available. + + {max_lines} (integer, default: 200) + Skip vim syntax highlighting for hunks larger than + this many lines. + + *fugitive-ts.Highlights* + Highlights table fields: ~ {background} (boolean, default: true) Apply background highlighting to `+`/`-` lines using `FugitiveTsAdd`/`FugitiveTsDelete` groups @@ -90,13 +96,14 @@ CONFIGURATION *fugitive-ts-config* Highlight line numbers with matching colors. Only visible if line numbers are enabled. - {vim} (boolean, default: false) - Experimental: Use vim syntax highlighting as - fallback when no treesitter parser is available. - Note: Header context (e.g., `@@ -10,3 +10,4 @@ func()`) is always highlighted with treesitter when a parser is available. + Language detection uses Neovim's built-in |vim.filetype.match()| and + |vim.treesitter.language.get_lang()|. To customize filetype detection + or register treesitter parsers for custom filetypes, use + |vim.filetype.add()| and |vim.treesitter.language.register()|. + ============================================================================== API *fugitive-ts-api* diff --git a/lua/fugitive-ts/highlight.lua b/lua/fugitive-ts/highlight.lua index 755c983..b5adb88 100644 --- a/lua/fugitive-ts/highlight.lua +++ b/lua/fugitive-ts/highlight.lua @@ -65,8 +65,9 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang) end ---@class fugitive-ts.HunkOpts ----@field max_lines integer ---@field hide_prefix boolean +---@field treesitter fugitive-ts.TreesitterConfig +---@field vim fugitive-ts.VimConfig ---@field highlights fugitive-ts.Highlights ---@param bufnr integer @@ -79,13 +80,14 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) return end - if #hunk.lines > opts.max_lines then + local max_lines = opts.treesitter.max_lines + if #hunk.lines > max_lines then dbg( 'skipping hunk %s:%d (%d lines > %d max)', hunk.filename, hunk.start_line, #hunk.lines, - opts.max_lines + max_lines ) return end @@ -120,7 +122,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, extmark_opts) end - if line_len > 1 and opts.highlights.treesitter then + if line_len > 1 and opts.treesitter.enabled then pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, { end_col = line_len, hl_group = 'Normal', @@ -129,7 +131,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end end - if not opts.highlights.treesitter then + if not opts.treesitter.enabled then return end diff --git a/lua/fugitive-ts/init.lua b/lua/fugitive-ts/init.lua index b71ae24..7f2c037 100644 --- a/lua/fugitive-ts/init.lua +++ b/lua/fugitive-ts/init.lua @@ -1,17 +1,22 @@ ---@class fugitive-ts.Highlights ----@field treesitter boolean ---@field background boolean ---@field gutter boolean ----@field vim boolean + +---@class fugitive-ts.TreesitterConfig +---@field enabled boolean +---@field max_lines integer + +---@class fugitive-ts.VimConfig +---@field enabled boolean +---@field max_lines integer ---@class fugitive-ts.Config ---@field enabled boolean ---@field debug boolean ----@field languages table ----@field disabled_languages string[] ---@field debounce_ms integer ----@field max_lines_per_hunk integer ---@field hide_prefix boolean +---@field treesitter fugitive-ts.TreesitterConfig +---@field vim fugitive-ts.VimConfig ---@field highlights fugitive-ts.Highlights ---@class fugitive-ts @@ -61,16 +66,19 @@ end local default_config = { enabled = true, debug = false, - languages = {}, - disabled_languages = {}, debounce_ms = 0, - max_lines_per_hunk = 500, hide_prefix = false, + treesitter = { + enabled = true, + max_lines = 500, + }, + vim = { + enabled = false, + max_lines = 200, + }, highlights = { - treesitter = true, background = true, gutter = true, - vim = false, }, } @@ -102,12 +110,13 @@ local function highlight_buffer(bufnr) vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) - local hunks = parser.parse_buffer(bufnr, config.languages, config.disabled_languages) + 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, { - max_lines = config.max_lines_per_hunk, hide_prefix = config.hide_prefix, + treesitter = config.treesitter, + vim = config.vim, highlights = config.highlights, }) end @@ -192,20 +201,31 @@ function M.setup(opts) vim.validate({ enabled = { opts.enabled, 'boolean', true }, debug = { opts.debug, 'boolean', true }, - languages = { opts.languages, 'table', true }, - disabled_languages = { opts.disabled_languages, 'table', true }, debounce_ms = { opts.debounce_ms, 'number', true }, - max_lines_per_hunk = { opts.max_lines_per_hunk, '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.treesitter'] = { opts.highlights.treesitter, 'boolean', true }, ['highlights.background'] = { opts.highlights.background, 'boolean', true }, ['highlights.gutter'] = { opts.highlights.gutter, 'boolean', true }, - ['highlights.vim'] = { opts.highlights.vim, 'boolean', true }, }) end diff --git a/lua/fugitive-ts/parser.lua b/lua/fugitive-ts/parser.lua index fa0bd16..3bf9346 100644 --- a/lua/fugitive-ts/parser.lua +++ b/lua/fugitive-ts/parser.lua @@ -26,19 +26,8 @@ local function dbg(msg, ...) end ---@param filename string ----@param custom_langs? table ----@param disabled_langs? string[] ---@return string? -local function get_lang_from_filename(filename, custom_langs, disabled_langs) - if custom_langs and custom_langs[filename] then - local lang = custom_langs[filename] - if disabled_langs and vim.tbl_contains(disabled_langs, lang) then - dbg('lang disabled: %s', lang) - return nil - end - return lang - end - +local function get_lang_from_filename(filename) local ft = vim.filetype.match({ filename = filename }) if not ft then dbg('no filetype for: %s', filename) @@ -47,10 +36,6 @@ local function get_lang_from_filename(filename, custom_langs, disabled_langs) local lang = vim.treesitter.language.get_lang(ft) if lang then - if disabled_langs and vim.tbl_contains(disabled_langs, lang) then - dbg('lang disabled: %s', lang) - return nil - end local ok = pcall(vim.treesitter.language.inspect, lang) if ok then return lang @@ -64,10 +49,8 @@ local function get_lang_from_filename(filename, custom_langs, disabled_langs) end ---@param bufnr integer ----@param custom_langs? table ----@param disabled_langs? string[] ---@return fugitive-ts.Hunk[] -function M.parse_buffer(bufnr, custom_langs, disabled_langs) +function M.parse_buffer(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) ---@type fugitive-ts.Hunk[] local hunks = {} @@ -107,7 +90,7 @@ function M.parse_buffer(bufnr, custom_langs, disabled_langs) if filename then flush_hunk() current_filename = filename - current_lang = get_lang_from_filename(filename, custom_langs, disabled_langs) + current_lang = get_lang_from_filename(filename) if current_lang then dbg('file: %s -> lang: %s', filename, current_lang) end diff --git a/spec/init_spec.lua b/spec/init_spec.lua index ce5b64e..5268b4a 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -20,16 +20,19 @@ describe('fugitive-ts', function() fugitive_ts.setup({ enabled = false, debug = true, - languages = { ['.envrc'] = 'bash' }, - disabled_languages = { 'markdown' }, debounce_ms = 100, - max_lines_per_hunk = 1000, hide_prefix = false, + treesitter = { + enabled = true, + max_lines = 1000, + }, + vim = { + enabled = false, + max_lines = 200, + }, highlights = { - treesitter = true, background = true, gutter = true, - vim = false, }, }) end) diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index 567b26c..acbd38f 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -15,19 +15,9 @@ describe('parser', function() end end - local test_langs = { - ['lua/test.lua'] = 'lua', - ['lua/foo.lua'] = 'lua', - ['src/bar.py'] = 'python', - ['test.lua'] = 'lua', - ['test.py'] = 'python', - ['other.lua'] = 'lua', - ['.envrc'] = 'bash', - } - it('returns empty table for empty buffer', function() local bufnr = create_buffer({}) - local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) + local hunks = parser.parse_buffer(bufnr) assert.are.same({}, hunks) delete_buffer(bufnr) end) @@ -40,7 +30,7 @@ describe('parser', function() 'Unstaged (1)', 'M lua/test.lua', }) - local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) + local hunks = parser.parse_buffer(bufnr) assert.are.same({}, hunks) delete_buffer(bufnr) end) @@ -54,7 +44,7 @@ describe('parser', function() '+local new = true', ' return M', }) - local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) + local hunks = parser.parse_buffer(bufnr) assert.are.equal(1, #hunks) assert.are.equal('lua/test.lua', hunks[1].filename) @@ -76,7 +66,7 @@ describe('parser', function() '+ print("hello")', ' end', }) - local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) + local hunks = parser.parse_buffer(bufnr) assert.are.equal(2, #hunks) assert.are.equal(2, hunks[1].start_line) @@ -95,7 +85,7 @@ describe('parser', function() ' def hello():', '+ pass', }) - local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) + local hunks = parser.parse_buffer(bufnr) assert.are.equal(2, #hunks) assert.are.equal('lua/foo.lua', hunks[1].filename) @@ -113,7 +103,7 @@ describe('parser', function() '+print(msg)', ' end', }) - local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) + local hunks = parser.parse_buffer(bufnr) assert.are.equal(1, #hunks) assert.are.equal('function M.hello()', hunks[1].header_context) @@ -128,46 +118,13 @@ describe('parser', function() ' local M = {}', '+local x = 1', }) - local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) + local hunks = parser.parse_buffer(bufnr) assert.are.equal(1, #hunks) assert.is_nil(hunks[1].header_context) delete_buffer(bufnr) end) - it('respects custom language mappings', function() - local bufnr = create_buffer({ - 'M .envrc', - '@@ -1,1 +1,2 @@', - ' export FOO=bar', - '+export BAZ=qux', - }) - local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) - - assert.are.equal(1, #hunks) - assert.are.equal('bash', hunks[1].lang) - delete_buffer(bufnr) - end) - - it('respects disabled_languages', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,1 +1,2 @@', - ' local M = {}', - '+local x = 1', - 'M test.py', - '@@ -1,1 +1,2 @@', - ' def foo():', - '+ pass', - }) - local hunks = parser.parse_buffer(bufnr, test_langs, { 'lua' }, false) - - assert.are.equal(1, #hunks) - assert.are.equal('test.py', hunks[1].filename) - assert.are.equal('python', hunks[1].lang) - delete_buffer(bufnr) - end) - it('handles all git status prefixes', function() local prefixes = { 'M', 'A', 'D', 'R', 'C', '?', '!' } for _, prefix in ipairs(prefixes) do @@ -177,7 +134,7 @@ describe('parser', function() ' local x = 1', '+local y = 2', }) - local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) + local hunks = parser.parse_buffer(bufnr) assert.are.equal(1, #hunks, 'Failed for prefix: ' .. prefix) delete_buffer(bufnr) end @@ -192,7 +149,7 @@ describe('parser', function() '', 'Some other content', }) - local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) + local hunks = parser.parse_buffer(bufnr) assert.are.equal(1, #hunks) assert.are.equal(2, #hunks[1].lines) @@ -209,7 +166,7 @@ describe('parser', function() '@@ -1,1 +1,1 @@', ' local z = 3', }) - local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) + local hunks = parser.parse_buffer(bufnr) assert.are.equal(2, #hunks) assert.are.equal(2, #hunks[1].lines) From 2c330732bb58012c7dd08557bd47381e3318b907 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 2 Feb 2026 15:28:58 -0500 Subject: [PATCH 2/8] feat(highlight): vim highlight fallback --- lua/fugitive-ts/highlight.lua | 231 ++++++++++++++++++++++++---------- lua/fugitive-ts/parser.lua | 23 +++- spec/highlight_spec.lua | 21 ++-- spec/parser_spec.lua | 20 +++ 4 files changed, 213 insertions(+), 82 deletions(-) diff --git a/lua/fugitive-ts/highlight.lua b/lua/fugitive-ts/highlight.lua index b5adb88..a3f0213 100644 --- a/lua/fugitive-ts/highlight.lua +++ b/lua/fugitive-ts/highlight.lua @@ -73,95 +73,35 @@ end ---@param bufnr integer ---@param ns integer ---@param hunk fugitive-ts.Hunk ----@param opts fugitive-ts.HunkOpts -function M.highlight_hunk(bufnr, ns, hunk, opts) +---@param code_lines string[] +---@return integer +local function highlight_treesitter(bufnr, ns, hunk, code_lines) local lang = hunk.lang if not lang then - return - end - - local max_lines = opts.treesitter.max_lines - if #hunk.lines > max_lines then - dbg( - 'skipping hunk %s:%d (%d lines > %d max)', - hunk.filename, - hunk.start_line, - #hunk.lines, - max_lines - ) - return - end - - 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 'FugitiveTsAdd' or 'FugitiveTsDelete') - or nil - local number_hl = is_diff_line and (prefix == '+' and 'FugitiveTsAddNr' or 'FugitiveTsDeleteNr') - 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 opts.treesitter.enabled then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, { - end_col = line_len, - hl_group = 'Normal', - priority = 199, - }) - end - end - - if not opts.treesitter.enabled then - return - end - - ---@type string[] - local code_lines = {} - for _, line in ipairs(hunk.lines) do - table.insert(code_lines, line:sub(2)) + return 0 end local code = table.concat(code_lines, '\n') if code == '' then - return + 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 + 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 + return 0 end local query = vim.treesitter.query.get(lang, 'highlights') if not query then dbg('no highlights query for lang: %s', lang) - return + return 0 end if hunk.header_context and hunk.header_context_col then @@ -197,6 +137,161 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) extmark_count = extmark_count + 1 end + return extmark_count +end + +---@param bufnr integer +---@param ns integer +---@param hunk fugitive-ts.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 extmark_count = 0 + + vim.api.nvim_buf_call(scratch, function() + vim.cmd('setlocal syntax=' .. ft) + vim.cmd('redraw') + + for i, line in ipairs(code_lines) do + local col = 1 + local line_len = #line + + while col <= line_len do + local syn_id = vim.fn.synID(i, col, 1) + if syn_id == 0 then + col = col + 1 + else + local hl_name = vim.fn.synIDattr(vim.fn.synIDtrans(syn_id), 'name') + local span_start = col + + col = col + 1 + while col <= line_len do + local next_id = vim.fn.synID(i, col, 1) + if next_id == 0 then + break + end + local next_name = vim.fn.synIDattr(vim.fn.synIDtrans(next_id), 'name') + if next_name ~= hl_name then + break + end + col = col + 1 + end + + if hl_name ~= '' then + local buf_line = hunk.start_line + i - 1 + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span_start, { + end_col = col, + hl_group = hl_name, + priority = 200, + }) + extmark_count = extmark_count + 1 + end + end + end + end + end) + + vim.api.nvim_buf_delete(scratch, { force = true }) + + return extmark_count +end + +---@param bufnr integer +---@param ns integer +---@param hunk fugitive-ts.Hunk +---@param opts fugitive-ts.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 + + if not use_ts and not use_vim and not hunk.ft then + return + end + + 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 + + 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 'FugitiveTsAdd' or 'FugitiveTsDelete') + or nil + local number_hl = is_diff_line and (prefix == '+' and 'FugitiveTsAddNr' or 'FugitiveTsDeleteNr') + 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 apply_syntax then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, { + end_col = line_len, + hl_group = 'Normal', + priority = 199, + }) + end + end + + if not apply_syntax then + return + end + + ---@type string[] + local code_lines = {} + for _, line in ipairs(hunk.lines) do + table.insert(code_lines, line:sub(2)) + 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 + dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count) end diff --git a/lua/fugitive-ts/parser.lua b/lua/fugitive-ts/parser.lua index 3bf9346..025186b 100644 --- a/lua/fugitive-ts/parser.lua +++ b/lua/fugitive-ts/parser.lua @@ -1,6 +1,7 @@ ---@class fugitive-ts.Hunk ---@field filename string ----@field lang string +---@field ft string? +---@field lang string? ---@field start_line integer ---@field header_context string? ---@field header_context_col integer? @@ -27,13 +28,17 @@ end ---@param filename string ---@return string? -local function get_lang_from_filename(filename) +local function get_ft_from_filename(filename) local ft = vim.filetype.match({ filename = filename }) if not ft then dbg('no filetype for: %s', filename) - return nil 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) @@ -44,7 +49,6 @@ local function get_lang_from_filename(filename) else dbg('no ts lang for filetype: %s', ft) end - return nil end @@ -58,6 +62,8 @@ function M.parse_buffer(bufnr) ---@type string? local current_filename = nil ---@type string? + local current_ft = nil + ---@type string? local current_lang = nil ---@type integer? local hunk_start = nil @@ -69,9 +75,10 @@ function M.parse_buffer(bufnr) local hunk_lines = {} local function flush_hunk() - if hunk_start and #hunk_lines > 0 and current_lang then + 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, @@ -90,9 +97,12 @@ function M.parse_buffer(bufnr) if filename then flush_hunk() current_filename = filename - current_lang = get_lang_from_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() @@ -109,6 +119,7 @@ function M.parse_buffer(bufnr) elseif line == '' or line:match('^[MADRC%?!]%s+') or line:match('^%a') then flush_hunk() current_filename = nil + current_ft = nil current_lang = nil end end diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 1982d43..6b35cac 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -31,20 +31,25 @@ describe('highlight', function() local function default_opts(overrides) local opts = { - max_lines = 500, hide_prefix = false, + treesitter = { + enabled = true, + max_lines = 500, + }, + vim = { + enabled = false, + max_lines = 200, + }, highlights = { - treesitter = true, background = false, gutter = false, - vim = 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 + if type(v) == 'table' and type(opts[k]) == 'table' then + for sk, sv in pairs(v) do + opts[k][sk] = sv end else opts[k] = v @@ -478,7 +483,7 @@ describe('highlight', function() bufnr, ns, hunk, - default_opts({ highlights = { treesitter = false, background = true } }) + default_opts({ treesitter = { enabled = false }, highlights = { background = true } }) ) local extmarks = get_extmarks(bufnr) @@ -511,7 +516,7 @@ describe('highlight', function() bufnr, ns, hunk, - default_opts({ highlights = { treesitter = false, background = true } }) + default_opts({ treesitter = { enabled = false }, highlights = { background = true } }) ) local extmarks = get_extmarks(bufnr) diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index acbd38f..6532b25 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -48,6 +48,7 @@ describe('parser', function() assert.are.equal(1, #hunks) assert.are.equal('lua/test.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) assert.are.equal('lua', hunks[1].lang) assert.are.equal(3, hunks[1].start_line) assert.are.equal(3, #hunks[1].lines) @@ -156,6 +157,25 @@ describe('parser', function() delete_buffer(bufnr) end) + it('emits hunk with ft when no ts parser available', function() + local bufnr = create_buffer({ + 'M test.xyz_no_parser', + '@@ -1,1 +1,2 @@', + ' some content', + '+more content', + }) + + vim.filetype.add({ extension = { xyz_no_parser = 'xyz_no_parser_ft' } }) + + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('xyz_no_parser_ft', hunks[1].ft) + assert.is_nil(hunks[1].lang) + assert.are.equal(2, #hunks[1].lines) + delete_buffer(bufnr) + end) + it('stops hunk at next file header', function() local bufnr = create_buffer({ 'M test.lua', From a082b9c4b3097652c64c3afeac2b5339f9a9d9a0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 2 Feb 2026 15:34:37 -0500 Subject: [PATCH 3/8] feat: update --- doc/fugitive-ts.nvim.txt | 11 ++- spec/highlight_spec.lua | 153 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 160 insertions(+), 4 deletions(-) diff --git a/doc/fugitive-ts.nvim.txt b/doc/fugitive-ts.nvim.txt index 6250d2e..7ade1bc 100644 --- a/doc/fugitive-ts.nvim.txt +++ b/doc/fugitive-ts.nvim.txt @@ -78,12 +78,17 @@ CONFIGURATION *fugitive-ts-config* *fugitive-ts.VimConfig* Vim config fields: ~ {enabled} (boolean, default: false) - Experimental: Use vim syntax highlighting as - fallback when no treesitter parser is available. + Use vim syntax highlighting as fallback when no + treesitter parser is available for a language. + Creates a scratch buffer, sets the filetype, and + queries |synID()| per character to extract + highlight groups. Slower than treesitter but + covers languages without a TS parser installed. {max_lines} (integer, default: 200) Skip vim syntax highlighting for hunks larger than - this many lines. + this many lines. Lower than the treesitter default + due to the per-character cost of |synID()|. *fugitive-ts.Highlights* Highlights table fields: ~ diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 6b35cac..a0f7816 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -131,7 +131,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('does nothing for nil lang', function() + it('does nothing for nil lang and nil ft', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', ' some content', @@ -140,6 +140,7 @@ describe('highlight', function() local hunk = { filename = 'test.unknown', + ft = nil, lang = nil, start_line = 1, lines = { ' some content', '+more content' }, @@ -530,5 +531,155 @@ describe('highlight', function() assert.is_true(has_diff_add) delete_buffer(bufnr) end) + + it('applies vim syntax extmarks when vim.enabled and no TS parser', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + ft = 'lua', + lang = nil, + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ vim = { enabled = true } })) + + local extmarks = get_extmarks(bufnr) + local has_syntax_hl = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'Normal' then + has_syntax_hl = true + break + end + end + assert.is_true(has_syntax_hl) + delete_buffer(bufnr) + end) + + it('skips vim fallback when vim.enabled is false', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + ft = 'lua', + lang = nil, + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ vim = { enabled = false } })) + + local extmarks = get_extmarks(bufnr) + local has_syntax_hl = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'Normal' then + has_syntax_hl = true + break + end + end + assert.is_false(has_syntax_hl) + delete_buffer(bufnr) + end) + + it('respects vim.max_lines', function() + local lines = { '@@ -1,100 +1,101 @@' } + local hunk_lines = {} + for i = 1, 250 do + table.insert(lines, ' line ' .. i) + table.insert(hunk_lines, ' line ' .. i) + end + + local bufnr = create_buffer(lines) + local hunk = { + filename = 'test.lua', + ft = 'lua', + lang = nil, + start_line = 1, + lines = hunk_lines, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ vim = { enabled = true, max_lines = 200 } }) + ) + + local extmarks = get_extmarks(bufnr) + assert.are.equal(0, #extmarks) + delete_buffer(bufnr) + end) + + it('applies background for vim fallback hunks', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + ft = 'lua', + lang = nil, + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ vim = { enabled = true }, 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 == 'FugitiveTsAdd' then + has_diff_add = true + break + end + end + assert.is_true(has_diff_add) + delete_buffer(bufnr) + end) + + it('applies Normal blanking for vim fallback hunks', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + ft = 'lua', + lang = nil, + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ vim = { enabled = true } })) + + local extmarks = get_extmarks(bufnr) + local has_normal = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group == 'Normal' then + has_normal = true + break + end + end + assert.is_true(has_normal) + delete_buffer(bufnr) + end) end) end) From 548c5386ab3dde1115f1872dbcf7b8725359130e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 2 Feb 2026 15:42:15 -0500 Subject: [PATCH 4/8] feat(vim): syntax highlighting --- lua/fugitive-ts/highlight.lua | 40 ++++++++++++++++------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/lua/fugitive-ts/highlight.lua b/lua/fugitive-ts/highlight.lua index a3f0213..122bb6d 100644 --- a/lua/fugitive-ts/highlight.lua +++ b/lua/fugitive-ts/highlight.lua @@ -217,10 +217,6 @@ 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 - if not use_ts and not use_vim and not hunk.ft then - return - end - 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( @@ -236,6 +232,23 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) 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 @@ -266,7 +279,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, extmark_opts) end - if line_len > 1 and apply_syntax then + 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', @@ -275,23 +288,6 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end end - if not apply_syntax then - return - end - - ---@type string[] - local code_lines = {} - for _, line in ipairs(hunk.lines) do - table.insert(code_lines, line:sub(2)) - 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 - dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count) end From 339171fe27a16242c2f09bf82241cbd3d23d8273 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 2 Feb 2026 15:47:16 -0500 Subject: [PATCH 5/8] fix: short circuit logic --- lua/fugitive-ts/init.lua | 57 +++++++++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/lua/fugitive-ts/init.lua b/lua/fugitive-ts/init.lua index 7f2c037..5a2d064 100644 --- a/lua/fugitive-ts/init.lua +++ b/lua/fugitive-ts/init.lua @@ -180,6 +180,14 @@ function M.attach(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() @@ -194,6 +202,28 @@ function M.refresh(bufnr) 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, 'FugitiveTsAdd', { bg = blended_add }) + vim.api.nvim_set_hl(0, 'FugitiveTsDelete', { bg = blended_del }) + vim.api.nvim_set_hl(0, 'FugitiveTsAddNr', { fg = add_fg, bg = blended_add }) + vim.api.nvim_set_hl(0, 'FugitiveTsDeleteNr', { fg = del_fg, bg = blended_del }) +end + ---@param opts? fugitive-ts.Config function M.setup(opts) opts = opts or {} @@ -233,25 +263,16 @@ function M.setup(opts) parser.set_debug(config.debug) highlight.set_debug(config.debug) - 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') + compute_highlight_groups() - 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, 'FugitiveTsAdd', { bg = blended_add }) - vim.api.nvim_set_hl(0, 'FugitiveTsDelete', { bg = blended_del }) - vim.api.nvim_set_hl(0, 'FugitiveTsAddNr', { fg = add_fg, bg = blended_add }) - vim.api.nvim_set_hl(0, 'FugitiveTsDeleteNr', { fg = del_fg, bg = blended_del }) + vim.api.nvim_create_autocmd('ColorScheme', { + callback = function() + compute_highlight_groups() + for bufnr, _ in pairs(attached_buffers) do + highlight_buffer(bufnr) + end + end, + }) end return M From 08050af5dbfbd9fc34ddd7eb3211dced775cf800 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 2 Feb 2026 16:03:50 -0500 Subject: [PATCH 6/8] feat(scripts): ci script to run quickly --- scripts/ci.sh | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100755 scripts/ci.sh diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..98d17fb --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BOLD='\033[1m' +RESET='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT_DIR" + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +run_job() { + local name=$1 + shift + local log="$tmpdir/$name.log" + if "$@" >"$log" 2>&1; then + echo -e "${GREEN}✓${RESET} $name" + return 0 + else + echo -e "${RED}✗${RESET} $name" + cat "$log" + return 1 + fi +} + +echo -e "${BOLD}Running CI jobs in parallel...${RESET}" +echo + +pids=() +jobs_names=() + +run_job "stylua" stylua --check . & +pids+=($!); jobs_names+=("stylua") + +run_job "selene" selene --display-style quiet . & +pids+=($!); jobs_names+=("selene") + +run_job "prettier" prettier --check . & +pids+=($!); jobs_names+=("prettier") + +run_job "busted" env \ + LUA_PATH="/usr/share/lua/5.1/?.lua;/usr/share/lua/5.1/?/init.lua;/usr/lib/lua/5.1/?.lua;/usr/lib/lua/5.1/?/init.lua;;" \ + LUA_CPATH="/usr/lib/lua/5.1/?.so;;" \ + nvim -l /usr/lib/luarocks/rocks-5.1/busted/2.3.0-1/bin/busted --verbose spec/ & +pids+=($!); jobs_names+=("busted") + +failed=0 +for i in "${!pids[@]}"; do + if ! wait "${pids[$i]}"; then + failed=1 + fi +done + +echo +if [ "$failed" -eq 0 ]; then + echo -e "${GREEN}${BOLD}All jobs passed.${RESET}" +else + echo -e "${RED}${BOLD}Some jobs failed.${RESET}" + exit 1 +fi From 0c76e5efcbe8b61109221a57c6c779320a89eab0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 2 Feb 2026 16:07:55 -0500 Subject: [PATCH 7/8] feat(doc): new readme screenshot --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 92874b9..7318bb5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Enhance the great `vim-fugitive` with syntax-aware code to easily work with diffs. -![fugitive-ts.nvim preview](https://github.com/user-attachments/assets/5675b410-0668-4450-a946-7caedf7863b8) +![fugitive-ts.nvim preview](https://github.com/user-attachments/assets/fc849310-09c8-4282-8a92-a2edaf8fe2b4) ## Features From 1dd62373b67a81a29f13aaf045bd904afb61aaec Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 2 Feb 2026 21:05:25 -0500 Subject: [PATCH 8/8] feat: cleanup ci tests --- lua/fugitive-ts/highlight.lua | 99 ++++++++++++++++++++++------------- spec/highlight_spec.lua | 87 ++++++++++++++++++++++++++++++ spec/parser_spec.lua | 22 ++++++++ 3 files changed, 172 insertions(+), 36 deletions(-) diff --git a/lua/fugitive-ts/highlight.lua b/lua/fugitive-ts/highlight.lua index 122bb6d..b6fb393 100644 --- a/lua/fugitive-ts/highlight.lua +++ b/lua/fugitive-ts/highlight.lua @@ -140,6 +140,47 @@ local function highlight_treesitter(bufnr, ns, hunk, code_lines) return extmark_count end +---@alias fugitive-ts.SyntaxQueryFn fun(line: integer, col: integer): integer, string + +---@param query_fn fugitive-ts.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 fugitive-ts.Hunk @@ -159,53 +200,39 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines) vim.api.nvim_buf_set_lines(scratch, 0, -1, false, code_lines) vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scratch }) - local extmark_count = 0 + local spans = {} vim.api.nvim_buf_call(scratch, function() vim.cmd('setlocal syntax=' .. ft) vim.cmd('redraw') - for i, line in ipairs(code_lines) do - local col = 1 - local line_len = #line - - while col <= line_len do - local syn_id = vim.fn.synID(i, col, 1) - if syn_id == 0 then - col = col + 1 - else - local hl_name = vim.fn.synIDattr(vim.fn.synIDtrans(syn_id), 'name') - local span_start = col - - col = col + 1 - while col <= line_len do - local next_id = vim.fn.synID(i, col, 1) - if next_id == 0 then - break - end - local next_name = vim.fn.synIDattr(vim.fn.synIDtrans(next_id), 'name') - if next_name ~= hl_name then - break - end - col = col + 1 - end - - if hl_name ~= '' then - local buf_line = hunk.start_line + i - 1 - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span_start, { - end_col = col, - hl_group = hl_name, - priority = 200, - }) - extmark_count = extmark_count + 1 - end - end + ---@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 diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index a0f7816..b4862e6 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -533,6 +533,19 @@ describe('highlight', function() end) it('applies vim syntax extmarks when vim.enabled and no TS parser', function() + local orig_synID = vim.fn.synID + local orig_synIDtrans = vim.fn.synIDtrans + local orig_synIDattr = vim.fn.synIDattr + vim.fn.synID = function(_line, _col, _trans) + return 1 + end + vim.fn.synIDtrans = function(id) + return id + end + vim.fn.synIDattr = function(_id, _what) + return 'Identifier' + end + local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', ' local x = 1', @@ -549,6 +562,10 @@ describe('highlight', function() highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ vim = { enabled = true } })) + vim.fn.synID = orig_synID + vim.fn.synIDtrans = orig_synIDtrans + vim.fn.synIDattr = orig_synIDattr + local extmarks = get_extmarks(bufnr) local has_syntax_hl = false for _, mark in ipairs(extmarks) do @@ -654,6 +671,19 @@ describe('highlight', function() end) it('applies Normal blanking for vim fallback hunks', function() + local orig_synID = vim.fn.synID + local orig_synIDtrans = vim.fn.synIDtrans + local orig_synIDattr = vim.fn.synIDattr + vim.fn.synID = function(_line, _col, _trans) + return 1 + end + vim.fn.synIDtrans = function(id) + return id + end + vim.fn.synIDattr = function(_id, _what) + return 'Identifier' + end + local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', ' local x = 1', @@ -670,6 +700,10 @@ describe('highlight', function() highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ vim = { enabled = true } })) + vim.fn.synID = orig_synID + vim.fn.synIDtrans = orig_synIDtrans + vim.fn.synIDattr = orig_synIDattr + local extmarks = get_extmarks(bufnr) local has_normal = false for _, mark in ipairs(extmarks) do @@ -682,4 +716,57 @@ describe('highlight', function() delete_buffer(bufnr) end) end) + + describe('coalesce_syntax_spans', function() + it('coalesces adjacent chars with same hl group', function() + local function query_fn(_line, _col) + return 1, 'Keyword' + end + local spans = highlight.coalesce_syntax_spans(query_fn, { 'hello' }) + assert.are.equal(1, #spans) + assert.are.equal(1, spans[1].col_start) + assert.are.equal(6, spans[1].col_end) + assert.are.equal('Keyword', spans[1].hl_name) + end) + + it('splits spans at hl group boundaries', function() + local function query_fn(_line, col) + if col <= 3 then + return 1, 'Keyword' + end + return 2, 'String' + end + local spans = highlight.coalesce_syntax_spans(query_fn, { 'abcdef' }) + assert.are.equal(2, #spans) + assert.are.equal('Keyword', spans[1].hl_name) + assert.are.equal(1, spans[1].col_start) + assert.are.equal(4, spans[1].col_end) + assert.are.equal('String', spans[2].hl_name) + assert.are.equal(4, spans[2].col_start) + assert.are.equal(7, spans[2].col_end) + end) + + it('skips syn_id 0 gaps', function() + local function query_fn(_line, col) + if col == 2 or col == 3 then + return 0, '' + end + return 1, 'Identifier' + end + local spans = highlight.coalesce_syntax_spans(query_fn, { 'abcd' }) + assert.are.equal(2, #spans) + assert.are.equal(1, spans[1].col_start) + assert.are.equal(2, spans[1].col_end) + assert.are.equal(4, spans[2].col_start) + assert.are.equal(5, spans[2].col_end) + end) + + it('skips empty hl_name spans', function() + local function query_fn(_line, _col) + return 1, '' + end + local spans = highlight.coalesce_syntax_spans(query_fn, { 'abc' }) + assert.are.equal(0, #spans) + end) + end) end) diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index 6532b25..d4c1d2b 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -76,6 +76,25 @@ describe('parser', function() end) it('detects hunks across multiple files', function() + local orig_get_lang = vim.treesitter.language.get_lang + local orig_inspect = vim.treesitter.language.inspect + vim.treesitter.language.get_lang = function(ft) + local result = orig_get_lang(ft) + if result then + return result + end + if ft == 'python' then + return 'python' + end + return nil + end + vim.treesitter.language.inspect = function(lang) + if lang == 'python' then + return {} + end + return orig_inspect(lang) + end + local bufnr = create_buffer({ 'M lua/foo.lua', '@@ -1,1 +1,2 @@', @@ -88,6 +107,9 @@ describe('parser', function() }) local hunks = parser.parse_buffer(bufnr) + vim.treesitter.language.get_lang = orig_get_lang + vim.treesitter.language.inspect = orig_inspect + assert.are.equal(2, #hunks) assert.are.equal('lua/foo.lua', hunks[1].filename) assert.are.equal('lua', hunks[1].lang)