From f9ea5b0658500961ee5e187b61f0759886cc76bd Mon Sep 17 00:00:00 2001 From: Jonathan Apodaca Date: Tue, 14 Apr 2026 18:37:13 -0600 Subject: [PATCH] Range.from_motion: preserve jumplist --- AGENTS.md | 34 +++++++++++++++++++++ lua/u.lua | 10 +++---- spec/u_spec.lua | 79 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cec8639 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# AGENTS.md + +## Project + +Single-file Neovim Lua micro-library (`lua/u.lua`) for range-based text operations, positions, operator-pending mappings, and text objects. Not a plugin — meant to be vendored by other plugins. + +## Commands + +All tasks use `mise`: + +- `mise run fmt` — format (stylua) +- `mise run fmt:check` — check formatting +- `mise run lint` — type-check with `emmylua_check` (ignores `.prefix/`) +- `mise run test` — run busted against Neovim 0.12.1 (includes `test:prepare`) +- `mise run test:all` — test against 0.11.5, 0.12.1, and nightly +- `mise run test:coverage` — test with luacov (only covers `lua/u$`) +- `mise run ci` — `fmt:check` → `lint` → `test:all` + +Run a single spec file: `busted spec/u_spec.lua` (after `mise run test:prepare`). + +## Architecture + +- **`lua/u.lua`** — the entire library (~1300 lines): `Pos`, `Range`, `opkeymap`, `define_txtobj`, `ucmd`, `repeat_` +- **`spec/u_spec.lua`** — all tests in one file +- **`library/`** — type stubs for busted/luv (stylua-ignored, used by emmylua) +- **`.prefix/`** — neovim version installs managed by `nvimv` (git-ignored, emmylua-ignored) +- **`examples/`** — usage examples (surround, splitjoin, text-objects, matcher) + +## Conventions + +- **1-based indexing** everywhere (v2+). `Pos.from00()` / `Range.from00()` convert from 0-based Neovim API values. +- Stylua: LuaJIT syntax, single quotes, no call parens, 2-space indent, sort requires, 100 col width +- Tests run inside Neovim via busted's `lua = "nvim -u NONE -i NONE -l"` (set in `.busted`) +- `test:prepare` installs busted rocks via `luarocks test --prepare` and manages nightly Neovim via `nvimv` diff --git a/lua/u.lua b/lua/u.lua index e289b47..79990bc 100644 --- a/lua/u.lua +++ b/lua/u.lua @@ -552,12 +552,10 @@ function Range.from_motion(motion, opts) local old_eventignore = vim.o.eventignore vim.o.eventignore = 'all' vim.go.operatorfunc = 'v:lua.Range__from_motion_opfunc' - vim.cmd { - cmd = 'normal', - bang = not opts.user_defined, - args = { ESC .. 'g@' .. motion }, - mods = { silent = true }, - } + vim.cmd( + 'keepjumps normal' .. (not opts.user_defined and '!' or '') .. ' ' .. ESC .. 'g@' .. motion, + { mods = { silent = true } } + ) vim.o.eventignore = old_eventignore end) local captured_range = _G.Range__from_motion_opfunc_captured_range diff --git a/spec/u_spec.lua b/spec/u_spec.lua index 9c8639c..3fc65bd 100644 --- a/spec/u_spec.lua +++ b/spec/u_spec.lua @@ -372,6 +372,85 @@ describe('Range', function() vim.api.nvim_buf_delete(buf2, { force = true }) end) + it('from_motion does not modify the jumplist', function() + withbuf({ + 'line1', + '(hello world)', + 'line3', + '{foo bar}', + 'line5', + }, function() + -- Build up a jumplist that includes line 2: + vim.cmd.normal { args = { '5G' }, bang = true } + vim.cmd.normal { args = { '2G' }, bang = true } + vim.cmd.normal { args = { '4G' }, bang = true } + vim.cmd.normal { args = { '5G' }, bang = true } + + -- Position cursor on line 2 (which is in the jumplist), inside the parens + vim.fn.setpos('.', { 0, 2, 3, 0 }) + + local jl_before = vim.fn.getjumplist() + local n_before = #jl_before[1] + local curjump_before = jl_before[2] + + -- g@a( corrupts the jumplist by deduplicating the entry for line 2. + -- This is a query-only operation and should NOT change the jumplist: + Range.from_motion('a(', { contains_cursor = true }) + + local jl_after = vim.fn.getjumplist() + + assert.are.same( + n_before, + #jl_after[1], + 'from_motion should not add or remove jumplist entries' + ) + assert.are.same( + curjump_before, + jl_after[2], + 'from_motion should not change the curjump pointer' + ) + end) + end) + + it('find_nearest_brackets does not modify the jumplist', function() + withbuf({ + 'line1', + '(hello world)', + 'line3', + '{foo bar}', + 'line5', + }, function() + -- Build up a jumplist: + vim.cmd.normal { args = { '5G' }, bang = true } + vim.cmd.normal { args = { '2G' }, bang = true } + vim.cmd.normal { args = { '4G' }, bang = true } + vim.cmd.normal { args = { '5G' }, bang = true } + + -- Move to a line near brackets + vim.fn.setpos('.', { 0, 2, 3, 0 }) + + local jl_before = vim.fn.getjumplist() + local n_before = #jl_before[1] + local curjump_before = jl_before[2] + + -- This calls from_motion internally and should NOT change the jumplist: + Range.find_nearest_brackets() + + local jl_after = vim.fn.getjumplist() + + assert.are.same( + n_before, + #jl_after[1], + 'find_nearest_brackets should not add or remove jumplist entries' + ) + assert.are.same( + curjump_before, + jl_after[2], + 'find_nearest_brackets should not change the curjump pointer' + ) + end) + end) + it('from_motion restores visual selection when started in visual mode', function() withbuf({ 'the quick brown fox' }, function() -- Enter visual mode first