diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 09fd4aa..241084c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,18 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} version: latest - args: --color always --check lua/ + args: --color always --check lua/ tests/ + test: + name: test + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: v0.10.0 + - run: make test docs: name: vimdoc runs-on: ubuntu-latest diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b2165ec --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +PLENARY_DIR ?= /tmp/plenary.nvim + +.PHONY: test plenary lint + +plenary: + @if [ ! -d "$(PLENARY_DIR)" ]; then \ + git clone --depth 1 https://github.com/nvim-lua/plenary.nvim $(PLENARY_DIR); \ + fi + +test: plenary + PLENARY_DIR=$(PLENARY_DIR) nvim --headless -u tests/minimal_init.lua -c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/minimal_init.lua'}" + +lint: + stylua --check lua/ tests/ diff --git a/tests/helpers.lua b/tests/helpers.lua new file mode 100644 index 0000000..028f32e --- /dev/null +++ b/tests/helpers.lua @@ -0,0 +1,90 @@ +local M = {} + +-- Tracked values +M.feedkeys_calls = {} +M.notifications = {} + +-- Original functions +local originals = {} + +function M.setup_mocks() + M.feedkeys_calls = {} + M.notifications = {} + + originals.mode = vim.fn.mode + originals.expand = vim.fn.expand + originals.feedkeys = vim.api.nvim_feedkeys + originals.replace_termcodes = vim.api.nvim_replace_termcodes + originals.buf_get_mark = vim.api.nvim_buf_get_mark + originals.buf_get_lines = vim.api.nvim_buf_get_lines + originals.notify = vim.notify + + -- Default mock: normal mode + vim.fn.mode = function() + return 'n' + end + + vim.fn.expand = function() + return 'word' + end + + vim.api.nvim_feedkeys = function(keys, mode, escape_ks) + table.insert(M.feedkeys_calls, { keys = keys, mode = mode, escape_ks = escape_ks }) + end + + vim.api.nvim_replace_termcodes = function(str, from_part, do_lt, special) + return str + end + + vim.api.nvim_buf_get_mark = function(buf, mark) + return { 1, 0 } + end + + vim.api.nvim_buf_get_lines = function(buf, start_line, end_line, strict) + return { 'some text here' } + end + + vim.notify = function(msg, level) + table.insert(M.notifications, { msg = msg, level = level }) + end +end + +function M.set_mode(mode) + vim.fn.mode = function() + return mode + end +end + +function M.set_expand(result) + vim.fn.expand = function() + return result + end +end + +function M.set_visual_marks(start_pos, end_pos) + vim.api.nvim_buf_get_mark = function(buf, mark) + if mark == '<' then + return start_pos + elseif mark == '>' then + return end_pos + end + end +end + +function M.set_buffer_lines(lines) + vim.api.nvim_buf_get_lines = function(buf, start_line, end_line, strict) + return lines + end +end + +function M.teardown_mocks() + vim.fn.mode = originals.mode + vim.fn.expand = originals.expand + vim.api.nvim_feedkeys = originals.feedkeys + vim.api.nvim_replace_termcodes = originals.replace_termcodes + vim.api.nvim_buf_get_mark = originals.buf_get_mark + vim.api.nvim_buf_get_lines = originals.buf_get_lines + vim.notify = originals.notify +end + +return M diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 0000000..2fc474e --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,21 @@ +local plenary_dir = os.getenv('PLENARY_DIR') or '/tmp/plenary.nvim' + +if vim.fn.isdirectory(plenary_dir) == 0 then + vim.fn.system({ + 'git', + 'clone', + '--depth', + '1', + 'https://github.com/nvim-lua/plenary.nvim', + plenary_dir, + }) +end + +vim.opt.rtp:append('.') +vim.opt.rtp:append(plenary_dir) + +-- Allow spec files to require('helpers') +package.path = './tests/?.lua;' .. package.path + +vim.cmd('runtime plugin/plenary.vim') +require('plenary.busted') diff --git a/tests/scalpel_spec.lua b/tests/scalpel_spec.lua new file mode 100644 index 0000000..f641a97 --- /dev/null +++ b/tests/scalpel_spec.lua @@ -0,0 +1,169 @@ +local helpers = require('helpers') + +describe('scalpel', function() + local scalpel + + before_each(function() + helpers.setup_mocks() + package.loaded['scalpel'] = nil + scalpel = require('scalpel') + end) + + after_each(function() + helpers.teardown_mocks() + end) + + describe('setup', function() + it('does not error', function() + assert.has_no.errors(function() + scalpel.setup() + end) + end) + end) + + describe('substitute', function() + describe('normal mode', function() + it('feeds substitution command with word under cursor', function() + helpers.set_mode('n') + helpers.set_expand('hello') + + scalpel.substitute() + + assert.equals(1, #helpers.feedkeys_calls) + local call = helpers.feedkeys_calls[1] + assert.truthy(call.keys:find('hello')) + assert.truthy(call.keys:find(':%%s/\\v')) + assert.truthy(call.keys:find('//gc')) + end) + + it('uses very magic regex pattern', function() + helpers.set_mode('n') + helpers.set_expand('myVar') + + scalpel.substitute() + + local call = helpers.feedkeys_calls[1] + assert.truthy(call.keys:find('\\v')) + end) + + it('includes cursor move keys to position in replacement field', function() + helpers.set_mode('n') + helpers.set_expand('test') + + scalpel.substitute() + + local call = helpers.feedkeys_calls[1] + assert.truthy(call.keys:find('')) + end) + + it('notifies when word is blank', function() + helpers.set_mode('n') + helpers.set_expand(' ') + + scalpel.substitute() + + assert.equals(0, #helpers.feedkeys_calls) + assert.equals(1, #helpers.notifications) + assert.truthy(helpers.notifications[1].msg:find('blank')) + end) + + it('notifies when word is empty', function() + helpers.set_mode('n') + helpers.set_expand('') + + scalpel.substitute() + + assert.equals(0, #helpers.feedkeys_calls) + assert.equals(1, #helpers.notifications) + assert.truthy(helpers.notifications[1].msg:find('blank')) + end) + end) + + describe('visual mode', function() + -- Note: get_visual_range() calls nvim_feedkeys with first, + -- so visual mode tests have an extra feedkeys call at index 1. + + it('feeds substitution command with selected text', function() + helpers.set_mode('v') + helpers.set_visual_marks({ 1, 4 }, { 1, 8 }) + helpers.set_buffer_lines({ 'the hello world line' }) + + scalpel.substitute() + + assert.equals(2, #helpers.feedkeys_calls) + local call = helpers.feedkeys_calls[2] + assert.truthy(call.keys:find('hello')) + assert.truthy(call.keys:find(':%%s/\\v')) + assert.truthy(call.keys:find('//gc')) + end) + + it('extracts correct substring from line', function() + helpers.set_mode('v') + helpers.set_visual_marks({ 1, 0 }, { 1, 2 }) + helpers.set_buffer_lines({ 'foo bar baz' }) + + scalpel.substitute() + + local call = helpers.feedkeys_calls[2] + assert.truthy(call.keys:find('foo')) + end) + + it('notifies when selection spans multiple lines', function() + helpers.set_mode('v') + helpers.set_visual_marks({ 1, 0 }, { 3, 5 }) + + scalpel.substitute() + + -- Only the feedkeys call, no substitution + assert.equals(1, #helpers.feedkeys_calls) + assert.equals(1, #helpers.notifications) + assert.truthy(helpers.notifications[1].msg:find('Cannot substitute')) + end) + + it('notifies when selected text is blank', function() + helpers.set_mode('v') + helpers.set_visual_marks({ 1, 4 }, { 1, 6 }) + helpers.set_buffer_lines({ 'test end' }) + + scalpel.substitute() + + -- Only the feedkeys call, no substitution + assert.equals(1, #helpers.feedkeys_calls) + assert.equals(1, #helpers.notifications) + assert.truthy(helpers.notifications[1].msg:find('blank')) + end) + end) + + describe('visual line mode', function() + it('feeds line-scoped substitution command without a word', function() + helpers.set_mode('V') + + scalpel.substitute() + + assert.equals(1, #helpers.feedkeys_calls) + local call = helpers.feedkeys_calls[1] + assert.truthy(call.keys:find(':s///gc')) + -- Should NOT contain %s (buffer-wide) or \v (very magic) + assert.falsy(call.keys:find('%%s')) + assert.falsy(call.keys:find('\\v')) + end) + + it('includes cursor move keys to position in search field', function() + helpers.set_mode('V') + + scalpel.substitute() + + local call = helpers.feedkeys_calls[1] + assert.truthy(call.keys:find('')) + end) + + it('does not trigger any notifications', function() + helpers.set_mode('V') + + scalpel.substitute() + + assert.equals(0, #helpers.notifications) + end) + end) + end) +end)