diff --git a/.github/workflows/refjump.yml b/.github/workflows/refjump.yml index e5e5034..00e7e08 100644 --- a/.github/workflows/refjump.yml +++ b/.github/workflows/refjump.yml @@ -2,17 +2,36 @@ name: Refjump Workflow on: push: branches: [main] - paths: - - .github/workflows/refjump.yml - - README.md + pull_request: + branches: [main] permissions: contents: write jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Neovim + run: | + sudo apt-get update + sudo apt-get install -y software-properties-common + sudo add-apt-repository -y ppa:neovim-ppa/stable + sudo apt-get update + sudo apt-get install -y neovim + - name: Install Plenary + run: | + git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ~/.local/share/nvim/lazy/plenary.nvim + - name: Run Tests + run: | + nvim --headless -u tests/minimal_init.lua -c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/minimal_init.lua'}" docs: name: Pandoc to Vimdoc runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: panvimdoc uses: kdheepak/panvimdoc@main with: diff --git a/README.md b/README.md index 739a335..10e9bb9 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,9 @@ opts = { enable = true, -- Highlight the LSP references on jump auto_clear = true, -- Automatically clear highlights when cursor moves }, + counter = { + enable = true, -- Show [X/Y] virtual text counter at end of line + }, integrations = { demicolon = { enable = true, -- Make `]r`/`[r` repeatable with `;`/`,` using demicolon.nvim @@ -53,10 +56,36 @@ opts = { ### Highlights -Refjump highlights the references by default. It uses the highlight group `RefjumpReference`. To change the highlight, see `:help nvim_set_hl()`. +Refjump uses the following highlight groups: + +- `RefjumpReference` - for highlighting the references (links to `LspReferenceText` by default) +- `RefjumpCounter` - for the virtual text counter (links to `WarningMsg` by default) + +To change the highlights, see `:help nvim_set_hl()`. ## Integrations +### Statusline + +Refjump exposes `get_reference_info()` for statusline integration. This returns `{ index = number|nil, total = number }` with the current reference position. + +Example for [lualine.nvim](https://github.com/nvim-lualine/lualine.nvim): + +```lua +lualine_x = { + { + function() + local refjump = require("refjump") + local info = refjump.get_reference_info() + if info.index then + return string.format("[%d/%d]", info.index, info.total) + end + return "" + end, + }, +}, +``` + ### Demicolon This plugin integrates with [demicolon.nvim](https://github.com/mawkler/demicolon.nvim). Demicolon lets you repeat `]r`/`[r` jumps with `;`/`,` (you can also still repeat `t`/`f`/`T`/`F` like you would expect). Refjump will cache the list of LSP references which gives you super responsive jump repetitions. diff --git a/lua/refjump/counter.lua b/lua/refjump/counter.lua new file mode 100644 index 0000000..ecd6af3 --- /dev/null +++ b/lua/refjump/counter.lua @@ -0,0 +1,48 @@ +local M = {} + +---Namespace for counter virtual text extmarks +local counter_namespace = vim.api.nvim_create_namespace('RefjumpCounter') + +---Name of the highlight group for the counter +local counter_hl_name = 'RefjumpCounter' + +---Create highlight group linked to WarningMsg if it doesn't exist +function M.create_hl_group() + local hl = vim.api.nvim_get_hl(0, { name = counter_hl_name }) + + if vim.tbl_isempty(hl) then + vim.api.nvim_set_hl(0, counter_hl_name, { link = 'WarningMsg' }) + end +end + +---Show virtual text counter at the end of the current line +---@param current_index integer Current reference index (1-based) +---@param total_count integer Total number of references +---@param bufnr integer Buffer number +function M.show(current_index, total_count, bufnr) + -- Get current cursor position + local cursor = vim.api.nvim_win_get_cursor(0) + local line = cursor[1] - 1 -- Convert to 0-indexed + + -- Clear any existing counter in this buffer + M.clear(bufnr) + + -- Format the counter text + local text = string.format(' [%d/%d]', current_index, total_count) + + -- Add virtual text at end of line + vim.api.nvim_buf_set_extmark(bufnr, counter_namespace, line, 0, { + virt_text = { { text, counter_hl_name } }, + virt_text_pos = 'eol', + priority = 100, + }) +end + +---Clear counter virtual text from buffer +---@param bufnr integer Buffer number (0 for current buffer) +function M.clear(bufnr) + bufnr = bufnr or 0 + vim.api.nvim_buf_clear_namespace(bufnr, counter_namespace, 0, -1) +end + +return M diff --git a/lua/refjump/highlight.lua b/lua/refjump/highlight.lua index d302dca..f2b209d 100644 --- a/lua/refjump/highlight.lua +++ b/lua/refjump/highlight.lua @@ -41,6 +41,12 @@ function M.enable(references, bufnr) highlight_references = true end +---Check if reference highlights are currently active +---@return boolean +function M.is_active() + return highlight_references +end + ---@deprecated Use `disable()` instead function M.disable_reference_highlights() local message = 'refjump.nvim: `disable_reference_highlights()` has been renamed to `disable()`' @@ -51,6 +57,8 @@ end function M.disable() if not highlight_references then vim.api.nvim_buf_clear_namespace(0, highlight_namespace, 0, -1) + require('refjump.counter').clear(0) + require('refjump.state').clear() else highlight_references = false end diff --git a/lua/refjump/init.lua b/lua/refjump/init.lua index cffc23a..583905b 100644 --- a/lua/refjump/init.lua +++ b/lua/refjump/init.lua @@ -9,12 +9,16 @@ local M = {} ---@field enable? boolean Highlight the LSP references on jump ---@field auto_clear boolean Automatically clear highlights when cursor moves +---@class RefjumpCounterOptions +---@field enable? boolean Show virtual text counter at end of line + ---@class RefjumpIntegrationOptions ---@field demicolon? { enable?: boolean } Make `]r`/`[r` repeatable with `;`/`,` using demicolon.nvim ---@class RefjumpOptions ---@field keymaps? RefjumpKeymapOptions ---@field highlights? RefjumpHighlightOptions +---@field counter? RefjumpCounterOptions ---@field integrations? RefjumpIntegrationOptions ---@field verbose? boolean Print message if no reference is found local options = { @@ -27,6 +31,9 @@ local options = { enable = true, auto_clear = true, }, + counter = { + enable = true, + }, integrations = { demicolon = { enable = true, @@ -55,8 +62,17 @@ function M.setup(opts) require('refjump.highlight').auto_clear_reference_highlights() end end + + if options.counter.enable then + require('refjump.counter').create_hl_group() + end end M.reference_jump = require('refjump.jump').reference_jump +---Get info about current reference position (for statusline use) +---@param bufnr? integer Buffer number (defaults to current buffer) +---@return { index: integer|nil, total: integer } +M.get_reference_info = require('refjump.state').get_reference_info + return M diff --git a/lua/refjump/jump.lua b/lua/refjump/jump.lua index 66c4eb6..b966700 100644 --- a/lua/refjump/jump.lua +++ b/lua/refjump/jump.lua @@ -63,6 +63,29 @@ local function jump_to(next_reference) vim.cmd('normal! zv') end +---Find the index of a reference in the references list +---@param reference RefjumpReference +---@param references RefjumpReference[] +---@return integer|nil +local function find_reference_index(reference, references) + local idx, _ = vim.iter(references):enumerate():find(function(_, ref) + return ref.range.start.line == reference.range.start.line + and ref.range.start.character == reference.range.start.character + end) + return idx +end + +---Display the reference counter if enabled +---@param current_index integer +---@param total_count integer +---@param bufnr integer +local function show_counter(current_index, total_count, bufnr) + if not require('refjump').get_options().counter.enable then + return + end + require('refjump.counter').show(current_index, total_count, bufnr) +end + ---@param next_reference RefjumpReference ---@param forward boolean ---@param references RefjumpReference[] @@ -74,6 +97,15 @@ local function jump_to_next_reference(next_reference, forward, references) if next_reference then jump_to(next_reference) + + -- Find current index and update state + local current_index = find_reference_index(next_reference, references) + local bufnr = vim.api.nvim_get_current_buf() + + if current_index then + require('refjump.state').set(references, current_index, bufnr) + show_counter(current_index, #references, bufnr) + end else vim.notify('refjump.nvim: Could not find the next reference', vim.log.levels.WARN) end diff --git a/lua/refjump/state.lua b/lua/refjump/state.lua new file mode 100644 index 0000000..9c7f0cd --- /dev/null +++ b/lua/refjump/state.lua @@ -0,0 +1,72 @@ +local M = {} + +---@class RefjumpBufferState +---@field references RefjumpReference[] +---@field current_index integer|nil + +---Per-buffer state storage +---@type table +local buffer_states = {} + +---Get or create state for a buffer +---@param bufnr integer +---@return RefjumpBufferState +local function get_buffer_state(bufnr) + if not buffer_states[bufnr] then + buffer_states[bufnr] = { + references = {}, + current_index = nil, + } + end + return buffer_states[bufnr] +end + +---Get info about current reference position (for statusline use) +---@param bufnr? integer Buffer number (defaults to current buffer) +---@return { index: integer|nil, total: integer } +function M.get_reference_info(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + local state = get_buffer_state(bufnr) + return { + index = state.current_index, + total = #state.references, + } +end + +---Check if reference navigation is currently active +---@return boolean +function M.is_active() + return require('refjump.highlight').is_active() +end + +---Update state after jumping to a reference (internal use) +---@param references RefjumpReference[] +---@param current_index integer|nil +---@param bufnr? integer Buffer number (defaults to current buffer) +function M.set(references, current_index, bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + local state = get_buffer_state(bufnr) + state.references = references or {} + state.current_index = current_index +end + +---Clear state for a buffer (internal use) +---@param bufnr? integer Buffer number (defaults to current buffer) +function M.clear(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + buffer_states[bufnr] = nil +end + +---Clean up state when buffer is deleted +local function setup_buffer_cleanup() + vim.api.nvim_create_autocmd('BufDelete', { + group = vim.api.nvim_create_augroup('refjump_state_cleanup', { clear = true }), + callback = function(event) + buffer_states[event.buf] = nil + end, + }) +end + +setup_buffer_cleanup() + +return M diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 0000000..1abf295 --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,17 @@ +-- Minimal init for running tests +local plenary_path = vim.fn.stdpath('data') .. '/lazy/plenary.nvim' + +if vim.fn.isdirectory(plenary_path) == 0 then + vim.fn.system({ + 'git', + 'clone', + '--depth=1', + 'https://github.com/nvim-lua/plenary.nvim', + plenary_path, + }) +end + +vim.opt.rtp:prepend(plenary_path) +vim.opt.rtp:prepend(vim.fn.getcwd()) + +vim.cmd('runtime plugin/plenary.vim') diff --git a/tests/state_spec.lua b/tests/state_spec.lua new file mode 100644 index 0000000..f7c03a4 --- /dev/null +++ b/tests/state_spec.lua @@ -0,0 +1,179 @@ +local state = require('refjump.state') +local highlight = require('refjump.highlight') + +describe('refjump.state', function() + local bufnr + + before_each(function() + -- Create a fresh buffer for each test + bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_current_buf(bufnr) + -- Clear any existing state + state.clear(bufnr) + end) + + after_each(function() + -- Clean up buffer + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end) + + describe('get_reference_info', function() + it('returns empty state initially', function() + local info = state.get_reference_info(bufnr) + assert.is_nil(info.index) + assert.equals(0, info.total) + end) + + it('returns correct info after set()', function() + local refs = { + { range = { start = { line = 0, character = 0 }, ['end'] = { line = 0, character = 5 } } }, + { range = { start = { line = 5, character = 0 }, ['end'] = { line = 5, character = 5 } } }, + { range = { start = { line = 10, character = 0 }, ['end'] = { line = 10, character = 5 } } }, + } + state.set(refs, 2, bufnr) + + local info = state.get_reference_info(bufnr) + assert.equals(2, info.index) + assert.equals(3, info.total) + end) + + it('uses current buffer when bufnr not provided', function() + local refs = { + { range = { start = { line = 0, character = 0 }, ['end'] = { line = 0, character = 5 } } }, + } + state.set(refs, 1, bufnr) + + -- Should work without explicit bufnr since we set current buf in before_each + local info = state.get_reference_info() + assert.equals(1, info.index) + assert.equals(1, info.total) + end) + end) + + describe('is_active', function() + it('returns false initially', function() + assert.is_false(state.is_active()) + end) + + it('returns true when highlights are enabled', function() + local refs = { + { range = { start = { line = 0, character = 0 }, ['end'] = { line = 0, character = 5 } } }, + } + -- Enable highlights to make is_active() return true + highlight.enable(refs, bufnr) + + assert.is_true(state.is_active()) + + -- Cleanup: disable highlights (need to call twice due to toggle behavior) + highlight.disable() + highlight.disable() + end) + + it('returns false after highlights are disabled', function() + local refs = { + { range = { start = { line = 0, character = 0 }, ['end'] = { line = 0, character = 5 } } }, + } + highlight.enable(refs, bufnr) + -- First disable flips flag to false + highlight.disable() + -- Second disable clears highlights + highlight.disable() + + assert.is_false(state.is_active()) + end) + end) + + describe('set', function() + it('stores references and index', function() + local refs = { + { range = { start = { line = 0, character = 0 }, ['end'] = { line = 0, character = 5 } } }, + { range = { start = { line = 5, character = 0 }, ['end'] = { line = 5, character = 5 } } }, + } + state.set(refs, 1, bufnr) + + local info = state.get_reference_info(bufnr) + assert.equals(1, info.index) + assert.equals(2, info.total) + end) + + it('handles nil references gracefully', function() + state.set(nil, 1, bufnr) + + local info = state.get_reference_info(bufnr) + assert.equals(1, info.index) + assert.equals(0, info.total) + end) + end) + + describe('clear', function() + it('clears state for buffer', function() + local refs = { + { range = { start = { line = 0, character = 0 }, ['end'] = { line = 0, character = 5 } } }, + } + state.set(refs, 1, bufnr) + + state.clear(bufnr) + + local info = state.get_reference_info(bufnr) + assert.is_nil(info.index) + assert.equals(0, info.total) + end) + end) + + describe('per-buffer isolation', function() + it('maintains separate state for different buffers', function() + local bufnr2 = vim.api.nvim_create_buf(false, true) + + local refs1 = { + { range = { start = { line = 0, character = 0 }, ['end'] = { line = 0, character = 5 } } }, + } + local refs2 = { + { range = { start = { line = 0, character = 0 }, ['end'] = { line = 0, character = 5 } } }, + { range = { start = { line = 5, character = 0 }, ['end'] = { line = 5, character = 5 } } }, + { range = { start = { line = 10, character = 0 }, ['end'] = { line = 10, character = 5 } } }, + } + + state.set(refs1, 1, bufnr) + state.set(refs2, 3, bufnr2) + + local info1 = state.get_reference_info(bufnr) + local info2 = state.get_reference_info(bufnr2) + + assert.equals(1, info1.index) + assert.equals(1, info1.total) + assert.equals(3, info2.index) + assert.equals(3, info2.total) + + -- Cleanup + vim.api.nvim_buf_delete(bufnr2, { force = true }) + end) + + it('clearing one buffer does not affect others', function() + local bufnr2 = vim.api.nvim_create_buf(false, true) + + local refs = { + { range = { start = { line = 0, character = 0 }, ['end'] = { line = 0, character = 5 } } }, + } + + state.set(refs, 1, bufnr) + state.set(refs, 1, bufnr2) + + state.clear(bufnr) + + -- State data for bufnr should be cleared + local info1 = state.get_reference_info(bufnr) + assert.is_nil(info1.index) + assert.equals(0, info1.total) + + -- State data for bufnr2 should still exist + local info2 = state.get_reference_info(bufnr2) + assert.equals(1, info2.index) + assert.equals(1, info2.total) + + -- Cleanup + vim.api.nvim_buf_delete(bufnr2, { force = true }) + end) + end) +end)