6 Commits

Author SHA1 Message Date
4ce60fce2e (Range) preserve visual selection orientation in from_motion
Some checks failed
ci / ci (push) Failing after 1m7s
2026-04-29 22:36:46 -06:00
8b7d7dc968 fix ci
Some checks failed
ci / ci (push) Failing after 1m6s
2026-04-28 23:51:32 -06:00
b7605b7976 (Range.from_motion) add max_lines and max_cols
Some checks failed
ci / ci (push) Failing after 34s
2026-04-28 23:45:32 -06:00
f9ea5b0658 Range.from_motion: preserve jumplist
Some checks failed
ci / ci (push) Failing after 1m5s
2026-04-14 18:37:13 -06:00
88b7a11efa (mise) use cargo-binstall
Some checks failed
ci / ci (push) Failing after 1m2s
2026-04-13 22:26:43 -06:00
b473ac3923 (chore) install nvimv via mise
Some checks failed
ci / ci (push) Has been cancelled
2026-04-13 22:24:19 -06:00
7 changed files with 354 additions and 47 deletions

8
.gitmodules vendored
View File

@@ -2,11 +2,3 @@
path = library/busted
url = https://github.com/LuaCATS/busted
branch = main
[submodule "library/luv"]
path = library/luv
url = https://github.com/LuaCATS/luv
branch = main
[submodule "nvimv"]
path = nvimv
url = https://github.com/jrop/nvimv
branch = main

34
AGENTS.md Normal file
View File

@@ -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`

Submodule library/luv deleted from 3615eb12c9

101
lua/u.lua
View File

@@ -511,8 +511,21 @@ function Range.from_lines(bufnr, start_line, stop_line)
return Range.new(Pos.new(bufnr, start_line, 1), Pos.new(bufnr, stop_line, vim.v.maxcol), 'V')
end
local BRACKET_MAP = {
['('] = '(',
[')'] = '(',
['b'] = '(',
['{'] = '{',
['}'] = '{',
['B'] = '{',
['['] = '[',
[']'] = '[',
['<'] = '<',
['>'] = '<',
}
--- @param motion string
--- @param opts? { bufnr?: number, contains_cursor?: boolean, pos?: u.Pos, user_defined?: boolean }
--- @param opts? { bufnr?: number, contains_cursor?: boolean, pos?: u.Pos, user_defined?: boolean, max_lines?: number, max_col?: number }
--- @return u.Range|nil
function Range.from_motion(motion, opts)
-- SECTION: Normalize options
@@ -526,8 +539,10 @@ function Range.from_motion(motion, opts)
local scope, motion_rest = motion:sub(1, 1), motion:sub(2)
local is_txtobj = scope == 'a' or scope == 'i'
local is_quote_txtobj = is_txtobj and vim.tbl_contains({ "'", '"', '`' }, motion_rest)
local is_bracket_txtobj = is_txtobj and BRACKET_MAP[motion_rest] ~= nil
-- SECTION: Capture original state for restoration
local vinf, vinf_inverted = Range.from_vtext()
local original_state = {
winview = vim.fn.winsaveview(),
unnamed_register = vim.fn.getreg '"',
@@ -537,7 +552,8 @@ function Range.from_motion(motion, opts)
opfunc = vim.go.operatorfunc,
prev_captured_range = _G.Range__from_motion_opfunc_captured_range,
prev_mode = vim.fn.mode(),
vinf = Range.from_vtext(),
vinf = vinf,
vinf_inverted = vinf_inverted,
}
--- @type u.Range|nil
_G.Range__from_motion_opfunc_captured_range = nil
@@ -546,18 +562,39 @@ function Range.from_motion(motion, opts)
vim.api.nvim_buf_call(opts.bufnr, function()
if opts.pos ~= nil then opts.pos:save_to_pos '.' end
-- Pre-check: skip expensive g@ when target char not found within bounds
if
(is_bracket_txtobj or is_quote_txtobj)
and type(opts.max_lines) == 'number'
and opts.max_lines > 0
then
local cur = vim.fn.line '.'
local line_start = math.max(1, cur - opts.max_lines)
local line_end = math.min(vim.api.nvim_buf_line_count(opts.bufnr), cur + opts.max_lines)
local chunk = vim.api.nvim_buf_get_lines(opts.bufnr, line_start - 1, line_end, false)
local target = is_bracket_txtobj and BRACKET_MAP[motion_rest] or motion_rest
local max_col = opts.max_col
local found = false
for _, line in ipairs(chunk) do
local pos = line:find(target, 1, true)
if pos and (max_col == nil or pos <= max_col) then
found = true
break
end
end
if not found then return end
end
_G.Range__from_motion_opfunc = function(ty)
_G.Range__from_motion_opfunc_captured_range = Range.from_op_func(ty)
end
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
@@ -568,7 +605,9 @@ function Range.from_motion(motion, opts)
vim.fn.setpos('.', original_state.cursor)
vim.fn.setpos("'[", original_state.mark_lbracket)
vim.fn.setpos("']", original_state.mark_rbracket)
if original_state.prev_mode ~= 'n' then original_state.vinf:set_visual_selection() end
if original_state.prev_mode ~= 'n' then
original_state.vinf:set_visual_selection(original_state.vinf_inverted)
end
vim.go.operatorfunc = original_state.opfunc
_G.Range__from_motion_opfunc_captured_range = original_state.prev_captured_range
@@ -628,7 +667,8 @@ end
function Range.from_vtext()
local r = Range.from_marks('v', '.')
if vim.fn.mode() == 'V' then r = r:to_linewise() end
return r
local inverted = Pos.from_pos '.' ~= r.stop
return r, inverted
end
--- Get range information from the current text range being operated on
@@ -665,20 +705,28 @@ function Range.from_cmd_args(args)
end
end
function Range.find_nearest_brackets()
--- @param opts? { max_lines?: number, max_col?: number }
--- @return u.Range|nil
function Range.find_nearest_brackets(opts)
opts = opts or {}
local copts = { contains_cursor = true, max_lines = opts.max_lines, max_col = opts.max_col }
return Range.smallest {
Range.from_motion('a<', { contains_cursor = true }),
Range.from_motion('a[', { contains_cursor = true }),
Range.from_motion('a(', { contains_cursor = true }),
Range.from_motion('a{', { contains_cursor = true }),
Range.from_motion('a<', copts),
Range.from_motion('a[', copts),
Range.from_motion('a(', copts),
Range.from_motion('a{', copts),
}
end
function Range.find_nearest_quotes()
--- @param opts? { max_lines?: number, max_col?: number }
--- @return u.Range|nil
function Range.find_nearest_quotes(opts)
opts = opts or {}
local copts = { contains_cursor = true, max_lines = opts.max_lines, max_col = opts.max_col }
return Range.smallest {
Range.from_motion([[a']], { contains_cursor = true }),
Range.from_motion([[a"]], { contains_cursor = true }),
Range.from_motion([[a`]], { contains_cursor = true }),
Range.from_motion([[a']], copts),
Range.from_motion([[a"]], copts),
Range.from_motion([[a`]], copts),
}
end
@@ -763,18 +811,23 @@ end
function Range:save_to_extmark() return Extmark.from_range(self, NS) end
function Range:set_visual_selection()
--- @param inverted boolean?
function Range:set_visual_selection(inverted)
if self:is_empty() then return end
if vim.api.nvim_get_current_buf() ~= self.start.bufnr then
error 'Range:set_visual_selection() called on a buffer other than the current buffer'
end
local curr_mode = vim.fn.mode()
if curr_mode ~= self.mode then vim.cmd.normal { args = { self.mode }, bang = true } end
local curr_mode = vim.fn.mode():sub(1, 1)
if curr_mode ~= self.mode then vim.cmd.normal { args = { ESC .. self.mode }, bang = true } end
self.start:save_to_pos '.'
local start, stop = self.start, self.stop
if inverted then
start, stop = stop, start
end
start:save_to_pos '.'
vim.cmd.normal { args = { 'o' }, bang = true }
self.stop:save_to_pos '.'
stop:save_to_pos '.'
end
--------------------------------------------------------------------------------

View File

@@ -14,10 +14,12 @@ jq = "1.8.1"
# installed. In the tests, we will override with `eval $(nvimv env VERSION)`,
# but to avoid having to litter a bunch of commands with that environment
# initialization, this just makes things simpler:
neovim = "0.12.1"
neovim = "0.12.2"
"http:nvimv" = { version = "2d018f5", url = "https://raw.githubusercontent.com/jrop/nvimv/2d018f55173bb88e6fb41e6f07a4908e5619c136/nvimv" }
stylua = "2.3.1"
"cargo:emmylua_ls" = "0.20.0"
"cargo:emmylua_check" = "0.20.0"
cargo-binstall = "1.18.1"
"cargo:emmylua_ls" = { version = "0.20.0", depends=["cargo-binstall"] }
"cargo:emmylua_check" = { version = "0.20.0", depends=["cargo-binstall"] }
################################################################################
# Env
@@ -44,10 +46,10 @@ echo
if find .prefix -path '**/nightly/**/nvim' -mtime -1 2>/dev/null | grep -q .; then
echo "Neovim Nightly is up-to-date"
else
if ./nvimv/nvimv ls | grep nightly >/dev/null; then
./nvimv/nvimv upgrade nightly
if nvimv ls | grep nightly >/dev/null; then
nvimv upgrade nightly
else
./nvimv/nvimv install nightly
nvimv install nightly
fi
fi
echo
@@ -66,8 +68,8 @@ echo Neovim version=$usage_version
echo -----------------------------
echo -----------------------------
echo
./nvimv/nvimv install $usage_version
eval $(./nvimv/nvimv env $usage_version)
nvimv install $usage_version
eval $(nvimv env $usage_version)
busted --verbose
'''

1
nvimv

Submodule nvimv deleted from bd5c243b96

View File

@@ -1,6 +1,10 @@
require 'luacov'
--- @diagnostic disable: undefined-field, need-check-nil
--- @diagnostic disable: need-check-nil
--- @diagnostic disable: param-type-mismatch
--- @diagnostic disable: undefined-field
--- @diagnostic disable: unnecessary-assert
local Pos = require('u').Pos
local Range = require('u').Range
local function withbuf(lines, f)
@@ -368,15 +372,91 @@ 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
vim.fn.setpos('.', { 0, 1, 1, 0 })
vim.cmd.normal 'vll' -- select 'the' (3 chars)
-- Record initial visual marks
local initial_v = vim.fn.getpos 'v'
-- Call from_motion (should save and restore visual selection)
local range = Range.from_motion 'aw'
assert.is_not_nil(range)
@@ -388,6 +468,154 @@ describe('Range', function()
end)
end)
it('from_motion preserves visual selection orientation (cursor at start)', function()
withbuf({ 'the quick brown fox' }, function()
vim.fn.setpos('.', { 0, 1, 5, 0 })
vim.cmd.normal 'v'
vim.cmd.normal { args = { 'o' }, bang = true }
vim.fn.setpos('.', { 0, 1, 3, 0 })
vim.cmd.normal { args = { 'o' }, bang = true }
vim.cmd.normal { args = { 'o' }, bang = true }
local cursor_before = vim.fn.getpos('.')[3]
Range.from_motion 'aw'
vim.cmd.normal { args = { 'o' }, bang = true }
local cursor_after = vim.fn.getpos('.')[3]
assert.are_not_same(cursor_before, cursor_after)
end)
end)
it('from_motion preserves visual selection orientation (cursor at stop)', function()
withbuf({ 'the quick brown fox' }, function()
vim.fn.setpos('.', { 0, 1, 3, 0 })
vim.cmd.normal 'vll'
local cursor_before = vim.fn.getpos('.')[3]
Range.from_motion 'aw'
vim.cmd.normal { args = { 'o' }, bang = true }
local cursor_after = vim.fn.getpos('.')[3]
assert.are_not_same(cursor_before, cursor_after)
end)
end)
describe('from_motion with max_lines', function()
it('returns nil when bracket chars are beyond max_lines', function()
-- A large block where { and } are far from cursor
local lines = {}
lines[1] = '{'
for i = 2, 200 do
lines[i] = 'content'
end
lines[201] = '}'
withbuf(lines, function()
vim.fn.setpos('.', { 0, 100, 1, 0 }) -- cursor inside the block, 99 lines from {
-- max_lines=50: { is 99 lines above, } is 101 lines below → pre-check fails
local range = Range.from_motion('a{', { max_lines = 50 })
assert.is_nil(range)
end)
end)
it('finds brackets within max_lines range', function()
withbuf({ 'this is a {', 'block', '} here' }, function()
vim.fn.setpos('.', { 0, 2, 1, 0 })
local range = Range.from_motion('a{', { max_lines = 10 })
assert.is_not_nil(range)
assert.are.same('{\nblock\n}', range:text())
end)
end)
it('works for closing bracket chars ) ] } >', function()
withbuf({ 'this is a (', 'block', ') here' }, function()
vim.fn.setpos('.', { 0, 2, 1, 0 })
local range = Range.from_motion('a)', { max_lines = 10 })
assert.is_not_nil(range)
assert.are.same('(\nblock\n)', range:text())
end)
end)
it('works for b and B aliases', function()
withbuf({ 'this is a {', 'block', '} here' }, function()
vim.fn.setpos('.', { 0, 2, 1, 0 })
local range = Range.from_motion('aB', { max_lines = 10 })
assert.is_not_nil(range)
assert.are.same('{\nblock\n}', range:text())
end)
end)
it('returns nil when quote chars are beyond max_lines', function()
local lines = {}
for i = 1, 200 do
lines[i] = 'no quotes here'
end
lines[1] = '"quoted"'
withbuf(lines, function()
vim.fn.setpos('.', { 0, 100, 1, 0 }) -- cursor 99 lines from quote char
local range = Range.from_motion('a"', { max_lines = 50 })
assert.is_nil(range)
end)
end)
it('finds quotes within max_lines range', function()
withbuf({ [[the "quick" brown fox]] }, function()
vim.fn.setpos('.', { 0, 1, 5, 0 })
local range = Range.from_motion('a"', { max_lines = 10 })
assert.is_not_nil(range)
assert.are.same('"quick"', range:text())
end)
end)
it('max_lines nil (default) does not pre-check', function()
local lines = {}
lines[1] = '{'
for i = 2, 200 do
lines[i] = 'content'
end
lines[201] = '}'
withbuf(lines, function()
vim.fn.setpos('.', { 0, 100, 1, 0 })
-- Without max_lines, g@ runs and finds the block
local range = Range.from_motion 'a{'
assert.is_not_nil(range)
end)
end)
it('skips brackets in lines exceeding max_col', function()
local long_line = string.rep('x', 10240) .. '{block}'
withbuf({ long_line }, function()
vim.fn.setpos('.', { 0, 1, 10245, 0 }) -- cursor inside {block}
-- { is at col 10241, beyond max_col=10240
local range = Range.from_motion('a{', { max_lines = 10, max_col = 10240 })
assert.is_nil(range)
end)
end)
it('finds brackets within max_col on a long line', function()
local long_line = string.rep('x', 100) .. '{block}'
withbuf({ long_line }, function()
vim.fn.setpos('.', { 0, 1, 105, 0 }) -- cursor inside 'block'
local range = Range.from_motion('a{', { max_lines = 10, max_col = 10240 })
assert.is_not_nil(range)
assert.are.same('{block}', range:text())
end)
end)
it('max_col nil (default) does not limit column search', function()
local long_line = string.rep('x', 10240) .. '{block}'
withbuf({ long_line }, function()
vim.fn.setpos('.', { 0, 1, 10245, 0 }) -- cursor inside {block}
-- { at col 10241, but no max_col limit
local range = Range.from_motion('a{', { max_lines = 10 })
assert.is_not_nil(range)
end)
end)
end)
it('from_tsquery_caps', function()
withbuf({
'-- a comment',
@@ -661,7 +889,7 @@ describe('Range', function()
-- histget()
local orig_histget = vim.fn.histget
--- @diagnostic disable-next-line: duplicate-set-field
function vim.fn.histget(x, y) return [['<,'>]] end
function vim.fn.histget() return [['<,'>]] end
-- Now run the test:
local range = Range.from_cmd_args(args) --[[@as u.Range]]