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..6910389 --- /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/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) diff --git a/doc/fugitive-ts.nvim.txt b/doc/fugitive-ts.nvim.txt index cc900b1..9d5e5de 100644 --- a/doc/fugitive-ts.nvim.txt +++ b/doc/fugitive-ts.nvim.txt @@ -14,72 +14,128 @@ 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* ->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, - }) +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|. ============================================================================== -COMMANDS *fugitive-ts-commands* +CONFIGURATION *fugitive-ts-config* -This plugin works automatically when you open a fugitive buffer. No commands -are required. + *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. ============================================================================== 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 with `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/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/lua/fugitive-ts/health.lua b/lua/fugitive-ts/health.lua index 48f946b..726efb0 100644 --- a/lua/fugitive-ts/health.lua +++ b/lua/fugitive-ts/health.lua @@ -15,30 +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 - - 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..31084a2 100644 --- a/lua/fugitive-ts/init.lua +++ b/lua/fugitive-ts/init.lua @@ -75,19 +75,27 @@ end ---@param bufnr integer ---@return fun() local function create_debounced_highlight(bufnr) - local timer = nil + local timer = nil ---@type table? return function() if timer then timer:stop() ---@diagnostic disable-line: undefined-field timer:close() ---@diagnostic disable-line: undefined-field + timer = nil end - 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) ) 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 }) 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/spec/helpers.lua b/spec/helpers.lua new file mode 100644 index 0000000..db9016a --- /dev/null +++ b/spec/helpers.lua @@ -0,0 +1,32 @@ +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) + 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..567b26c --- /dev/null +++ b/spec/parser_spec.lua @@ -0,0 +1,220 @@ +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 + + 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) + 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, test_langs, {}, 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, test_langs, {}, 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, test_langs, {}, 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, test_langs, {}, 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, test_langs, {}, 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, test_langs, {}, 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 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 + local bufnr = create_buffer({ + prefix .. ' test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + local hunks = parser.parse_buffer(bufnr, test_langs, {}, 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, test_langs, {}, 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, test_langs, {}, 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)