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)