From 5d5587b22f4f0d8b4848a73b61dfabbd1857d450 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 1 Feb 2026 20:20:55 -0500 Subject: [PATCH 1/7] feat(doc): add known limitations --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index c2fdd61..fd9d96c 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,14 @@ Using [lazy.nvim](https://github.com/folke/lazy.nvim): :help fugitive-ts.nvim ``` +## Known Limitations + +- Syntax "flashing": `fugitive-ts.nvim` hooks into the `FileType fugitive` event + triggered by `vim-fugitive`, at which point the `fugitive` buffer is + preliminarily painted. The buffer is then re-painted after `debounce_ms` + milliseconds, causing an unavoidable visual "flash" even when + `debounce_ms = 0`. Feel free to reach out if you know how to fix this! + ## Acknowledgements - [vim-fugitive](https://github.com/tpope/vim-fugitive) From d974567a8de11e98ba76c079bf00114f7cddff02 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 1 Feb 2026 22:57:04 -0500 Subject: [PATCH 2/7] documentation --- doc/fugitive-ts.nvim.txt | 148 +++++++++++++++++++++++++++---------- lua/fugitive-ts/health.lua | 8 -- lua/fugitive-ts/init.lua | 21 ++++-- 3 files changed, 123 insertions(+), 54 deletions(-) diff --git a/doc/fugitive-ts.nvim.txt b/doc/fugitive-ts.nvim.txt index cc900b1..b7fe124 100644 --- a/doc/fugitive-ts.nvim.txt +++ b/doc/fugitive-ts.nvim.txt @@ -14,72 +14,140 @@ default regex-based diff highlighting. REQUIREMENTS *fugitive-ts-requirements* - Neovim 0.9.0+ -- vim-fugitive +- vim-fugitive (https://github.com/tpope/vim-fugitive) - Treesitter parsers for languages you want highlighted ============================================================================== SETUP *fugitive-ts-setup* +Using lazy.nvim: >lua + { + 'barrettruth/fugitive-ts.nvim', + dependencies = { 'tpope/vim-fugitive' }, + opts = {}, + } +< +The plugin works automatically with no configuration required. For +customization, see |fugitive-ts-config|. + +============================================================================== +CONFIGURATION *fugitive-ts-config* + + *fugitive-ts.Config* + Fields: ~ + {enabled} (boolean, default: true) + Enable or disable highlighting globally. + + {debug} (boolean, default: false) + 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' } +< + {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 + 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. + +Example Configuration ~ >lua require('fugitive-ts').setup({ - -- Enable/disable highlighting (default: true) enabled = true, - - -- Enable debug logging (default: false) - -- Outputs to :messages with [fugitive-ts] prefix debug = false, - - -- Custom filename -> language mappings (optional) languages = {}, - - -- Languages to skip treesitter highlighting for (default: {}) - -- Uses treesitter language names, e.g. {"markdown", "vimdoc"} disabled_languages = {}, - - -- Highlight context in hunk headers (default: true) - -- e.g. "@@ -10,3 +10,4 @@ function foo()" -> "function foo()" gets highlighted highlight_headers = true, - - -- Debounce delay in ms (default: 50) debounce_ms = 50, - - -- Max lines per hunk before skipping treesitter (default: 500) - -- Prevents lag on large diffs max_lines_per_hunk = 500, }) < - -============================================================================== -COMMANDS *fugitive-ts-commands* - -This plugin works automatically when you open a fugitive buffer. No commands -are required. - ============================================================================== API *fugitive-ts-api* - *fugitive-ts.setup()* -setup({opts}) - Configure the plugin. See |fugitive-ts-setup| for options. +setup({opts}) *fugitive-ts.setup()* + Configure the plugin. Called automatically by lazy.nvim when using `opts`. - *fugitive-ts.attach()* -attach({bufnr}) + Parameters: ~ + {opts} (|fugitive-ts.Config|, optional) Configuration table. + +attach({bufnr}) *fugitive-ts.attach()* Manually attach highlighting to a buffer. Called automatically for - fugitive buffers. + fugitive buffers via the `FileType fugitive` autocmd. - *fugitive-ts.refresh()* -refresh({bufnr}) - Manually refresh highlighting for a buffer. + Parameters: ~ + {bufnr} (integer, optional) Buffer number. Defaults to current buffer. + +refresh({bufnr}) *fugitive-ts.refresh()* + Manually refresh highlighting for a buffer. Useful after external changes + or for debugging. + + Parameters: ~ + {bufnr} (integer, optional) Buffer number. Defaults to current buffer. ============================================================================== -ROADMAP *fugitive-ts-roadmap* +IMPLEMENTATION *fugitive-ts-implementation* -Planned features and improvements: +1. The `FileType fugitive` autocmd triggers |fugitive-ts.attach()| +2. The buffer is parsed to detect file headers (`M path/to/file.lua`) and + hunk headers (`@@ -10,3 +10,4 @@`) +3. For each hunk: + - 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()| + - Treesitter highlights are applied as extmarks at priority 200 + - A `Normal` extmark at priority 199 clears underlying diff colors +4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events -- Vim syntax fallback: For languages without treesitter parsers, fall back - to vim's built-in syntax highlighting via scratch buffers. This would - provide highlighting coverage for more languages at the cost of - implementation complexity. +============================================================================== +KNOWN LIMITATIONS *fugitive-ts-limitations* + +Syntax Highlighting Flash ~ + *fugitive-ts-flash* +When opening a fugitive buffer, there is an unavoidable visual "flash" where +the buffer briefly shows fugitive's default diff highlighting before +fugitive-ts.nvim applies treesitter highlights. + +This occurs because fugitive-ts.nvim hooks into the `FileType fugitive` event, +which fires after vim-fugitive has already painted the buffer. Even with +`debounce_ms = 0`, the re-painting goes through Neovim's event loop. + +To minimize the flash, use a low debounce value: >lua + require('fugitive-ts').setup({ + debounce_ms = 0, + }) +< +See https://github.com/barrettruth/fugitive-ts.nvim/issues/18 for discussion +and potential solutions. + +============================================================================== +HEALTH CHECK *fugitive-ts-health* + +Run |:checkhealth| fugitive-ts to verify your setup. + +Checks performed: +- Neovim version >= 0.9.0 +- vim-fugitive is installed ============================================================================== vim:tw=78:ts=8:ft=help:norl: diff --git a/lua/fugitive-ts/health.lua b/lua/fugitive-ts/health.lua index 48f946b..4f3cc48 100644 --- a/lua/fugitive-ts/health.lua +++ b/lua/fugitive-ts/health.lua @@ -31,14 +31,6 @@ function M.check() table.insert(missing, lang) end end - - if #available > 0 then - vim.health.ok('Treesitter parsers available: ' .. table.concat(available, ', ')) - end - - if #missing > 0 then - vim.health.info('Treesitter parsers not installed: ' .. table.concat(missing, ', ')) - end end return M diff --git a/lua/fugitive-ts/init.lua b/lua/fugitive-ts/init.lua index 209a19c..6e9f39c 100644 --- a/lua/fugitive-ts/init.lua +++ b/lua/fugitive-ts/init.lua @@ -75,19 +75,28 @@ end ---@param bufnr integer ---@return fun() local function create_debounced_highlight(bufnr) + ---@type uv_timer_t? local timer = nil return function() if timer then - timer:stop() ---@diagnostic disable-line: undefined-field - timer:close() ---@diagnostic disable-line: undefined-field + timer:stop() + timer:close() + timer = nil end - timer = vim.uv.new_timer() - timer:start( + 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() - timer:close() - timer = nil + t:close() + if timer == t then + timer = nil + end highlight_buffer(bufnr) end) ) From ae1df3e7a8d0599878e833dba048d8ed8c6519dd Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 1 Feb 2026 23:09:05 -0500 Subject: [PATCH 3/7] feat(test): testing infrastructure --- .busted | 9 ++ .github/workflows/test.yaml | 22 ++++ fugitive-ts.nvim-scm-1.rockspec | 24 +++- scripts/test.sh | 17 +++ spec/helpers.lua | 23 ++++ spec/highlight_spec.lua | 217 ++++++++++++++++++++++++++++++++ spec/init_spec.lua | 144 +++++++++++++++++++++ spec/minimal_init.lua | 4 + spec/parser_spec.lua | 211 +++++++++++++++++++++++++++++++ 9 files changed, 668 insertions(+), 3 deletions(-) create mode 100644 .busted create mode 100644 .github/workflows/test.yaml create mode 100755 scripts/test.sh create mode 100644 spec/helpers.lua create mode 100644 spec/highlight_spec.lua create mode 100644 spec/init_spec.lua create mode 100644 spec/minimal_init.lua create mode 100644 spec/parser_spec.lua diff --git a/.busted b/.busted new file mode 100644 index 0000000..53513b8 --- /dev/null +++ b/.busted @@ -0,0 +1,9 @@ +return { + _all = { + lua = 'nvim -l', + ROOT = { './spec/' }, + }, + default = { + verbose = true, + }, +} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..4a49f7a --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,22 @@ +name: test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + nvim: [stable, nightly] + name: Test (Neovim ${{ matrix.nvim }}) + steps: + - uses: actions/checkout@v4 + + - uses: nvim-neorocks/nvim-busted-action@v1 + with: + nvim-version: ${{ matrix.nvim }} diff --git a/fugitive-ts.nvim-scm-1.rockspec b/fugitive-ts.nvim-scm-1.rockspec index b8916ae..16658be 100644 --- a/fugitive-ts.nvim-scm-1.rockspec +++ b/fugitive-ts.nvim-scm-1.rockspec @@ -2,11 +2,29 @@ rockspec_format = '3.0' package = 'fugitive-ts.nvim' version = 'scm-1' -source = { url = 'git://github.com/barrettruth/fugitive-ts.nvim' } -build = { type = 'builtin' } +source = { + url = 'git+https://github.com/barrettruth/fugitive-ts.nvim.git', +} + +description = { + summary = 'Treesitter syntax highlighting for vim-fugitive', + homepage = 'https://github.com/barrettruth/fugitive-ts.nvim', + license = 'MIT', +} + +dependencies = { + 'lua >= 5.1', +} test_dependencies = { - 'lua >= 5.1', 'nlua', 'busted >= 2.1.1', } + +test = { + type = 'busted', +} + +build = { + type = 'builtin', +} diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..c20d0bf --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$ROOT_DIR" + +if command -v luarocks &> /dev/null; then + luarocks test --local +else + echo "luarocks not found, running nvim directly..." + nvim --headless --noplugin \ + -u spec/minimal_init.lua \ + -c "lua require('busted.runner')({ standalone = false })" \ + -c "qa!" +fi diff --git a/spec/helpers.lua b/spec/helpers.lua new file mode 100644 index 0000000..78c780a --- /dev/null +++ b/spec/helpers.lua @@ -0,0 +1,23 @@ +vim.cmd([[set runtimepath=$VIMRUNTIME]]) +vim.opt.runtimepath:append(vim.fn.getcwd()) +vim.opt.packpath = {} + +local M = {} + +function M.create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr +end + +function M.delete_buffer(bufnr) + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end +end + +function M.get_extmarks(bufnr, ns) + return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) +end + +return M diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua new file mode 100644 index 0000000..62d57b8 --- /dev/null +++ b/spec/highlight_spec.lua @@ -0,0 +1,217 @@ +require('spec.helpers') +local highlight = require('fugitive-ts.highlight') + +describe('highlight', function() + describe('highlight_hunk', function() + local ns + + before_each(function() + ns = vim.api.nvim_create_namespace('fugitive_ts_test') + end) + + local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + return bufnr + end + + local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + + local function get_extmarks(bufnr) + return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) + end + + it('applies extmarks for lua code', 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, 500, false, false) + + local extmarks = get_extmarks(bufnr) + assert.is_true(#extmarks > 0) + delete_buffer(bufnr) + end) + + it('applies Normal extmarks to clear diff colors', 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, 500, false, false) + + 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) + + it('skips hunks larger than max_lines', function() + local lines = { '@@ -1,100 +1,101 @@' } + local hunk_lines = {} + for i = 1, 600 do + table.insert(lines, ' line ' .. i) + table.insert(hunk_lines, ' line ' .. i) + end + + local bufnr = create_buffer(lines) + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = hunk_lines, + } + + highlight.highlight_hunk(bufnr, ns, hunk, 500, false, false) + + local extmarks = get_extmarks(bufnr) + assert.are.equal(0, #extmarks) + delete_buffer(bufnr) + end) + + it('does nothing for nil lang', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' some content', + '+more content', + }) + + local hunk = { + filename = 'test.unknown', + lang = nil, + start_line = 1, + lines = { ' some content', '+more content' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, 500, false, false) + + local extmarks = get_extmarks(bufnr) + assert.are.equal(0, #extmarks) + delete_buffer(bufnr) + end) + + it('highlights header context when enabled', function() + local bufnr = create_buffer({ + '@@ -10,3 +10,4 @@ function hello()', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + header_context = 'function hello()', + header_context_col = 18, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, 500, true, false) + + local extmarks = get_extmarks(bufnr) + local has_header_extmark = false + for _, mark in ipairs(extmarks) do + if mark[2] == 0 then + has_header_extmark = true + break + end + end + assert.is_true(has_header_extmark) + delete_buffer(bufnr) + end) + + it('does not highlight header when disabled', function() + local bufnr = create_buffer({ + '@@ -10,3 +10,4 @@ function hello()', + ' local x = 1', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + header_context = 'function hello()', + header_context_col = 18, + lines = { ' local x = 1' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, 500, false, false) + + local extmarks = get_extmarks(bufnr) + local header_extmarks = 0 + for _, mark in ipairs(extmarks) do + if mark[2] == 0 then + header_extmarks = header_extmarks + 1 + end + end + assert.are.equal(0, header_extmarks) + delete_buffer(bufnr) + end) + + it('handles empty hunk lines', function() + local bufnr = create_buffer({ + '@@ -1,0 +1,0 @@', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = {}, + } + + assert.has_no.errors(function() + highlight.highlight_hunk(bufnr, ns, hunk, 500, false, false) + end) + delete_buffer(bufnr) + end) + + it('handles code that is just whitespace', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' ', + '+ ', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' ', '+ ' }, + } + + assert.has_no.errors(function() + highlight.highlight_hunk(bufnr, ns, hunk, 500, false, false) + end) + delete_buffer(bufnr) + end) + end) +end) diff --git a/spec/init_spec.lua b/spec/init_spec.lua new file mode 100644 index 0000000..5e7a0ff --- /dev/null +++ b/spec/init_spec.lua @@ -0,0 +1,144 @@ +require('spec.helpers') +local fugitive_ts = require('fugitive-ts') + +describe('fugitive-ts', function() + describe('setup', function() + it('accepts empty config', function() + assert.has_no.errors(function() + fugitive_ts.setup({}) + end) + end) + + it('accepts nil config', function() + assert.has_no.errors(function() + fugitive_ts.setup() + end) + end) + + it('accepts full config', function() + assert.has_no.errors(function() + fugitive_ts.setup({ + enabled = false, + debug = true, + languages = { ['.envrc'] = 'bash' }, + disabled_languages = { 'markdown' }, + highlight_headers = false, + debounce_ms = 100, + max_lines_per_hunk = 1000, + }) + end) + end) + + it('accepts partial config', function() + assert.has_no.errors(function() + fugitive_ts.setup({ + debounce_ms = 25, + }) + end) + end) + end) + + describe('attach', function() + local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr + end + + local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + + before_each(function() + fugitive_ts.setup({ enabled = true }) + end) + + it('does not error on empty buffer', function() + local bufnr = create_buffer({}) + assert.has_no.errors(function() + fugitive_ts.attach(bufnr) + end) + delete_buffer(bufnr) + end) + + it('does not error on buffer with content', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + assert.has_no.errors(function() + fugitive_ts.attach(bufnr) + end) + delete_buffer(bufnr) + end) + + it('is idempotent', function() + local bufnr = create_buffer({}) + assert.has_no.errors(function() + fugitive_ts.attach(bufnr) + fugitive_ts.attach(bufnr) + fugitive_ts.attach(bufnr) + end) + delete_buffer(bufnr) + end) + end) + + describe('refresh', function() + local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr + end + + local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + + before_each(function() + fugitive_ts.setup({ enabled = true }) + end) + + it('does not error on unattached buffer', function() + local bufnr = create_buffer({}) + assert.has_no.errors(function() + fugitive_ts.refresh(bufnr) + end) + delete_buffer(bufnr) + end) + + it('does not error on attached buffer', function() + local bufnr = create_buffer({}) + fugitive_ts.attach(bufnr) + assert.has_no.errors(function() + fugitive_ts.refresh(bufnr) + end) + delete_buffer(bufnr) + end) + end) + + describe('config options', function() + it('enabled=false prevents highlighting', function() + fugitive_ts.setup({ enabled = false }) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + fugitive_ts.attach(bufnr) + + local ns = vim.api.nvim_create_namespace('fugitive_ts') + local extmarks = vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, {}) + assert.are.equal(0, #extmarks) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + end) +end) diff --git a/spec/minimal_init.lua b/spec/minimal_init.lua new file mode 100644 index 0000000..313d2a3 --- /dev/null +++ b/spec/minimal_init.lua @@ -0,0 +1,4 @@ +vim.cmd([[set runtimepath=$VIMRUNTIME]]) +vim.opt.runtimepath:append('.') +vim.opt.packpath = {} +vim.opt.loadplugins = false diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua new file mode 100644 index 0000000..486bd79 --- /dev/null +++ b/spec/parser_spec.lua @@ -0,0 +1,211 @@ +require('spec.helpers') +local parser = require('fugitive-ts.parser') + +describe('parser', function() + describe('parse_buffer', function() + local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + return bufnr + end + + local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + + it('returns empty table for empty buffer', function() + local bufnr = create_buffer({}) + local hunks = parser.parse_buffer(bufnr, {}, {}, false) + assert.are.same({}, hunks) + delete_buffer(bufnr) + end) + + it('returns empty table for buffer with no hunks', function() + local bufnr = create_buffer({ + 'Head: main', + 'Help: g?', + '', + 'Unstaged (1)', + 'M lua/test.lua', + }) + local hunks = parser.parse_buffer(bufnr, {}, {}, false) + assert.are.same({}, hunks) + delete_buffer(bufnr) + end) + + it('detects single hunk with lua file', function() + local bufnr = create_buffer({ + 'Unstaged (1)', + 'M lua/test.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + ' return M', + }) + local hunks = parser.parse_buffer(bufnr, {}, {}, false) + + assert.are.equal(1, #hunks) + assert.are.equal('lua/test.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].lang) + assert.are.equal(3, hunks[1].start_line) + assert.are.equal(3, #hunks[1].lines) + delete_buffer(bufnr) + end) + + it('detects multiple hunks in same file', function() + local bufnr = create_buffer({ + 'M lua/test.lua', + '@@ -1,2 +1,2 @@', + ' local M = {}', + '-local old = false', + '+local new = true', + '@@ -10,2 +10,3 @@', + ' function M.foo()', + '+ print("hello")', + ' end', + }) + local hunks = parser.parse_buffer(bufnr, {}, {}, false) + + assert.are.equal(2, #hunks) + assert.are.equal(2, hunks[1].start_line) + assert.are.equal(6, hunks[2].start_line) + delete_buffer(bufnr) + end) + + it('detects hunks across multiple files', function() + local bufnr = create_buffer({ + 'M lua/foo.lua', + '@@ -1,1 +1,2 @@', + ' local M = {}', + '+local x = 1', + 'M src/bar.py', + '@@ -1,1 +1,2 @@', + ' def hello():', + '+ pass', + }) + local hunks = parser.parse_buffer(bufnr, {}, {}, false) + + assert.are.equal(2, #hunks) + assert.are.equal('lua/foo.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].lang) + assert.are.equal('src/bar.py', hunks[2].filename) + assert.are.equal('python', hunks[2].lang) + delete_buffer(bufnr) + end) + + it('extracts header context', function() + local bufnr = create_buffer({ + 'M lua/test.lua', + '@@ -10,3 +10,4 @@ function M.hello()', + ' local msg = "hi"', + '+print(msg)', + ' end', + }) + local hunks = parser.parse_buffer(bufnr, {}, {}, false) + + assert.are.equal(1, #hunks) + assert.are.equal('function M.hello()', hunks[1].header_context) + assert.is_not_nil(hunks[1].header_context_col) + delete_buffer(bufnr) + end) + + it('handles header without context', function() + local bufnr = create_buffer({ + 'M lua/test.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + }) + local hunks = parser.parse_buffer(bufnr, {}, {}, false) + + 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 custom_langs = { ['.envrc'] = 'bash' } + local hunks = parser.parse_buffer(bufnr, custom_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, {}, { '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 + local bufnr = create_buffer({ + prefix .. ' test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + local hunks = parser.parse_buffer(bufnr, {}, {}, false) + assert.are.equal(1, #hunks, 'Failed for prefix: ' .. prefix) + delete_buffer(bufnr) + end + end) + + it('stops hunk at blank line', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,3 @@', + ' local x = 1', + '+local y = 2', + '', + 'Some other content', + }) + local hunks = parser.parse_buffer(bufnr, {}, {}, false) + + assert.are.equal(1, #hunks) + 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', + '@@ -1,2 +1,3 @@', + ' local x = 1', + '+local y = 2', + 'M other.lua', + '@@ -1,1 +1,1 @@', + ' local z = 3', + }) + local hunks = parser.parse_buffer(bufnr, {}, {}, false) + + assert.are.equal(2, #hunks) + assert.are.equal(2, #hunks[1].lines) + assert.are.equal(1, #hunks[2].lines) + delete_buffer(bufnr) + end) + end) +end) From 655e0324716c099520da95c1382b31db312748f2 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 1 Feb 2026 23:12:05 -0500 Subject: [PATCH 4/7] feat: remove unused vars --- lua/fugitive-ts/health.lua | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/lua/fugitive-ts/health.lua b/lua/fugitive-ts/health.lua index 4f3cc48..726efb0 100644 --- a/lua/fugitive-ts/health.lua +++ b/lua/fugitive-ts/health.lua @@ -15,22 +15,6 @@ function M.check() else vim.health.warn('vim-fugitive not detected (required for this plugin to be useful)') end - - ---@type string[] - local common_langs = { 'lua', 'python', 'javascript', 'typescript', 'rust', 'go', 'c', 'cpp' } - ---@type string[] - local available = {} - ---@type string[] - local missing = {} - - for _, lang in ipairs(common_langs) do - local ok = pcall(vim.treesitter.language.inspect, lang) - if ok then - table.insert(available, lang) - else - table.insert(missing, lang) - end - end end return M From 8f5e8e3b17a0f1e24a2ce33260377b673ed2d172 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 1 Feb 2026 23:41:59 -0500 Subject: [PATCH 5/7] fix(test): hopefully they work --- .github/workflows/test.yaml | 2 +- lua/fugitive-ts/init.lua | 7 +++---- spec/helpers.lua | 13 +++++++++++-- spec/parser_spec.lua | 35 ++++++++++++++++++++++------------- 4 files changed, 37 insertions(+), 20 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4a49f7a..6910389 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -19,4 +19,4 @@ jobs: - uses: nvim-neorocks/nvim-busted-action@v1 with: - nvim-version: ${{ matrix.nvim }} + nvim_version: ${{ matrix.nvim }} diff --git a/lua/fugitive-ts/init.lua b/lua/fugitive-ts/init.lua index 6e9f39c..31084a2 100644 --- a/lua/fugitive-ts/init.lua +++ b/lua/fugitive-ts/init.lua @@ -75,12 +75,11 @@ end ---@param bufnr integer ---@return fun() local function create_debounced_highlight(bufnr) - ---@type uv_timer_t? - local timer = nil + local timer = nil ---@type table? return function() if timer then - timer:stop() - timer:close() + timer:stop() ---@diagnostic disable-line: undefined-field + timer:close() ---@diagnostic disable-line: undefined-field timer = nil end local t = vim.uv.new_timer() diff --git a/spec/helpers.lua b/spec/helpers.lua index 78c780a..db9016a 100644 --- a/spec/helpers.lua +++ b/spec/helpers.lua @@ -1,7 +1,16 @@ -vim.cmd([[set runtimepath=$VIMRUNTIME]]) -vim.opt.runtimepath:append(vim.fn.getcwd()) +local plugin_dir = vim.fn.getcwd() +vim.opt.runtimepath:prepend(plugin_dir) vim.opt.packpath = {} +local function ensure_parser(lang) + local ok = pcall(vim.treesitter.language.inspect, lang) + if not ok then + error('Treesitter parser for ' .. lang .. ' not available. Neovim 0.10+ bundles lua parser.') + end +end + +ensure_parser('lua') + local M = {} function M.create_buffer(lines) diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index 486bd79..567b26c 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -15,9 +15,19 @@ 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, {}, {}, false) + local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) assert.are.same({}, hunks) delete_buffer(bufnr) end) @@ -30,7 +40,7 @@ describe('parser', function() 'Unstaged (1)', 'M lua/test.lua', }) - local hunks = parser.parse_buffer(bufnr, {}, {}, false) + local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) assert.are.same({}, hunks) delete_buffer(bufnr) end) @@ -44,7 +54,7 @@ describe('parser', function() '+local new = true', ' return M', }) - local hunks = parser.parse_buffer(bufnr, {}, {}, false) + local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) assert.are.equal(1, #hunks) assert.are.equal('lua/test.lua', hunks[1].filename) @@ -66,7 +76,7 @@ describe('parser', function() '+ print("hello")', ' end', }) - local hunks = parser.parse_buffer(bufnr, {}, {}, false) + local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) assert.are.equal(2, #hunks) assert.are.equal(2, hunks[1].start_line) @@ -85,7 +95,7 @@ describe('parser', function() ' def hello():', '+ pass', }) - local hunks = parser.parse_buffer(bufnr, {}, {}, false) + local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) assert.are.equal(2, #hunks) assert.are.equal('lua/foo.lua', hunks[1].filename) @@ -103,7 +113,7 @@ describe('parser', function() '+print(msg)', ' end', }) - local hunks = parser.parse_buffer(bufnr, {}, {}, false) + local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) assert.are.equal(1, #hunks) assert.are.equal('function M.hello()', hunks[1].header_context) @@ -118,7 +128,7 @@ describe('parser', function() ' local M = {}', '+local x = 1', }) - local hunks = parser.parse_buffer(bufnr, {}, {}, false) + local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) assert.are.equal(1, #hunks) assert.is_nil(hunks[1].header_context) @@ -132,8 +142,7 @@ describe('parser', function() ' export FOO=bar', '+export BAZ=qux', }) - local custom_langs = { ['.envrc'] = 'bash' } - local hunks = parser.parse_buffer(bufnr, custom_langs, {}, false) + local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) assert.are.equal(1, #hunks) assert.are.equal('bash', hunks[1].lang) @@ -151,7 +160,7 @@ describe('parser', function() ' def foo():', '+ pass', }) - local hunks = parser.parse_buffer(bufnr, {}, { 'lua' }, false) + local hunks = parser.parse_buffer(bufnr, test_langs, { 'lua' }, false) assert.are.equal(1, #hunks) assert.are.equal('test.py', hunks[1].filename) @@ -168,7 +177,7 @@ describe('parser', function() ' local x = 1', '+local y = 2', }) - local hunks = parser.parse_buffer(bufnr, {}, {}, false) + local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) assert.are.equal(1, #hunks, 'Failed for prefix: ' .. prefix) delete_buffer(bufnr) end @@ -183,7 +192,7 @@ describe('parser', function() '', 'Some other content', }) - local hunks = parser.parse_buffer(bufnr, {}, {}, false) + local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) assert.are.equal(1, #hunks) assert.are.equal(2, #hunks[1].lines) @@ -200,7 +209,7 @@ describe('parser', function() '@@ -1,1 +1,1 @@', ' local z = 3', }) - local hunks = parser.parse_buffer(bufnr, {}, {}, false) + local hunks = parser.parse_buffer(bufnr, test_langs, {}, false) assert.are.equal(2, #hunks) assert.are.equal(2, #hunks[1].lines) From 3b0e90a46e12d1ce9405dc51082682dbb7472cbd Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 1 Feb 2026 23:45:16 -0500 Subject: [PATCH 6/7] feat: fix bug --- lua/fugitive-ts/parser.lua | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lua/fugitive-ts/parser.lua b/lua/fugitive-ts/parser.lua index 67c84ee..8cd119b 100644 --- a/lua/fugitive-ts/parser.lua +++ b/lua/fugitive-ts/parser.lua @@ -22,7 +22,14 @@ end ---@return string? local function get_lang_from_filename(filename, custom_langs, disabled_langs, debug) if custom_langs and custom_langs[filename] then - return custom_langs[filename] + local lang = custom_langs[filename] + if disabled_langs and vim.tbl_contains(disabled_langs, lang) then + if debug then + dbg('lang disabled: %s', lang) + end + return nil + end + return lang end local ft = vim.filetype.match({ filename = filename }) From 1e0e8ee6ce7c13bb0e9e5c094ce4a23ab639f54a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 1 Feb 2026 23:49:29 -0500 Subject: [PATCH 7/7] cleanup docs, remove disfunctional test scripts --- doc/fugitive-ts.nvim.txt | 14 +----- scripts/test-env.sh | 93 ---------------------------------------- scripts/test.sh | 17 -------- 3 files changed, 1 insertion(+), 123 deletions(-) delete mode 100755 scripts/test-env.sh delete mode 100755 scripts/test.sh diff --git a/doc/fugitive-ts.nvim.txt b/doc/fugitive-ts.nvim.txt index b7fe124..9d5e5de 100644 --- a/doc/fugitive-ts.nvim.txt +++ b/doc/fugitive-ts.nvim.txt @@ -70,23 +70,11 @@ CONFIGURATION *fugitive-ts-config* Skip treesitter highlighting for hunks larger than this many lines. Prevents lag on massive diffs. -Example Configuration ~ ->lua - require('fugitive-ts').setup({ - enabled = true, - debug = false, - languages = {}, - disabled_languages = {}, - highlight_headers = true, - debounce_ms = 50, - max_lines_per_hunk = 500, - }) -< ============================================================================== API *fugitive-ts-api* setup({opts}) *fugitive-ts.setup()* - Configure the plugin. Called automatically by lazy.nvim when using `opts`. + Configure the plugin with `opts`. Parameters: ~ {opts} (|fugitive-ts.Config|, optional) Configuration table. diff --git a/scripts/test-env.sh b/scripts/test-env.sh deleted file mode 100755 index f0d967a..0000000 --- a/scripts/test-env.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env bash -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -TEMP_DIR=$(mktemp -d) - -echo "Creating test environment in $TEMP_DIR" - -cd "$TEMP_DIR" -git init -q - -cat > test.lua << 'EOF' -local M = {} - -function M.hello() - local msg = "hello world" - print(msg) - return true -end - -return M -EOF - -cat > test.py << 'EOF' -def hello(): - msg = "hello world" - print(msg) - return True - -if __name__ == "__main__": - hello() -EOF - -cat > test.js << 'EOF' -function hello() { - const msg = "hello world"; - console.log(msg); - return true; -} - -module.exports = { hello }; -EOF - -git add -A -git commit -q -m "initial commit" - -cat >> test.lua << 'EOF' - -function M.goodbye() - local msg = "goodbye world" - print(msg) - return false -end -EOF - -cat >> test.py << 'EOF' - -def goodbye(): - msg = "goodbye world" - print(msg) - return False -EOF - -cat >> test.js << 'EOF' - -function goodbye() { - const msg = "goodbye world"; - console.log(msg); - return false; -} -EOF - -git add test.lua - -cat > init.lua << EOF -vim.opt.rtp:prepend('$PLUGIN_DIR') -vim.opt.rtp:prepend(vim.fn.stdpath('data') .. '/lazy/vim-fugitive') - -require('fugitive-ts').setup({ - debug = true, -}) - -vim.cmd('Git') -EOF - -echo "Test repo created with:" -echo " - test.lua (staged changes)" -echo " - test.py (unstaged changes)" -echo " - test.js (unstaged changes)" -echo "" -echo "Opening neovim with fugitive..." - -nvim -u init.lua diff --git a/scripts/test.sh b/scripts/test.sh deleted file mode 100755 index c20d0bf..0000000 --- a/scripts/test.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(dirname "$SCRIPT_DIR")" - -cd "$ROOT_DIR" - -if command -v luarocks &> /dev/null; then - luarocks test --local -else - echo "luarocks not found, running nvim directly..." - nvim --headless --noplugin \ - -u spec/minimal_init.lua \ - -c "lua require('busted.runner')({ standalone = false })" \ - -c "qa!" -fi