Files
tiny-jump.nvim/lua/tiny-jump/init.lua
T
swaits 16a0eeda94 docs: note the intentional density + verbose-comments tradeoff
Add a 5-line style note at the top of lua/tiny-jump/init.lua and
mirror the same message in the README under the size-comparison
section. The comments in this file are load-bearing — they do the
explanatory work that would otherwise be done by helper functions
and verbose names. Prevents future-me (or a drive-by PR) from
'cleaning up' the comments as redundant.
2026-04-13 21:56:35 -06:00

200 lines
8.4 KiB
Lua

-- tiny-jump.nvim — a fork of https://github.com/yorickpeterse/nvim-jump
-- Original work: Copyright (c) Yorick Peterse
-- Modifications: Copyright (c) 2026 Stephen Waits
--
-- This Source Code Form is subject to the terms of the Mozilla Public
-- License, v. 2.0. If a copy of the MPL was not distributed with this
-- file, You can obtain one at http://mozilla.org/MPL/2.0/.
--
-- Style note: this file is intentionally dense (126 SLOC in one file).
-- Comments are thorough by design — they do the explanatory work normally
-- done by helper functions and verbose names. If you're editing here, read
-- the comments first.
local api, fn, M = vim.api, vim.fn, {}
M.config = {
labels = 'fjdkslahgeiruowmcnvptyqxzb',
search = 'Search',
label = 'IncSearch',
}
-- Termcode constants. BS and C_H cover both backspace variants terminals send.
local K = { CR = vim.keycode('<cr>'), BS = vim.keycode('<bs>'), C_H = vim.keycode('<c-h>'), ESC = vim.keycode('<esc>') }
local NS = api.nvim_create_namespace('tiny-jump')
-- Scan the visible lines for matches of `pattern` and, as a side product,
-- compute `avail[c]` = true for each label char `c` that is *safe* to use as a
-- label — i.e. `c` is never the character immediately following a match. If it
-- were, pressing `c` would be ambiguous: jump-to-label-c vs extend-pattern-by-c.
local function scan(pattern, lines, start_line)
-- smartcase: lowercase-only pattern → case-insensitive search
local lower = pattern == pattern:lower()
local matches, avail = {}, {}
for i = 1, #M.config.labels do
avail[M.config.labels:sub(i, i)] = true
end
for idx, line in ipairs(lines) do
local sline = lower and line:lower() or line
local col = 1
while true do
local s, e = sline:find(pattern, col, true)
if not s then break end
col = e + 1
-- start_line is 1-indexed (Vim convention), buffer rows are 0-indexed, and
-- ipairs's idx is also 1-indexed — the `- 2` reconciles both offsets.
matches[#matches + 1] = { line = start_line + idx - 2, start_col = s - 1, end_col = e }
-- Mark the char after this match as unavailable for labeling. Note this
-- is a *global* disqualification: any one match with 'e' next disables
-- the 'e' label for all matches.
avail[line:sub(e + 1, e + 1):lower()] = false
end
end
return matches, avail
end
function M.start()
local win = api.nvim_get_current_win()
local buf = api.nvim_win_get_buf(win)
local info = fn.getwininfo(win)[1]
local top = info.topline
local lines = api.nvim_buf_get_lines(buf, top - 1, info.botline, true)
-- State that persists across input keystrokes:
-- chars = accumulated search pattern typed so far
-- active = map from label-key (1 or 2 chars) to { row, col } target
-- prefixes = set of chars that are the first char of some 2-char label;
-- input loop checks this to decide whether to buffer a 2nd key
local chars, active, prefixes = '', {}, {}
while true do
api.nvim_echo({ { '/' .. chars, '' } }, false, {})
local ch = fn.getcharstr(-1)
-- Two-char label mode: if `ch` is a known prefix, wait for a 2nd keystroke
-- and treat the pair as the label key.
if prefixes[ch] then ch = ch .. fn.getcharstr(-1) end
local jump_to = active[ch]
if ch == K.ESC then
break
elseif ch == K.CR then
-- <CR> jumps to whichever labeled match has the strongest label — i.e.
-- the one that appears earliest in the label-preference string.
for i = 1, #M.config.labels do
jump_to = active[M.config.labels:sub(i, i)]
if jump_to then break end
end
if jump_to then api.nvim_win_set_cursor(win, jump_to) end
break
elseif ch == K.BS or ch == K.C_H then
chars = chars:sub(1, -2)
elseif jump_to then
api.nvim_win_set_cursor(win, jump_to)
break
else
chars = chars .. ch
end
-- Fresh scan per keystroke: clear everything that was drawn, then redraw
-- from the new `chars` value. `active` and `prefixes` must be regenerated
-- because label-to-position mapping depends on the current match set.
active, prefixes = {}, {}
api.nvim_buf_clear_namespace(buf, NS, 0, -1)
if #chars > 0 then
local matches, avail = scan(chars, lines, top)
-- Sort matches by cursor proximity so the N closest get the N strongest
-- labels. Primary: line distance. Secondary: column distance.
local cr = api.nvim_win_get_cursor(win)
local cl, cc = cr[1] - 1, cr[2]
table.sort(matches, function(a, b)
local da, db = math.abs(a.line - cl), math.abs(b.line - cl)
if da ~= db then return da < db end
return math.abs(a.start_col - cc) < math.abs(b.start_col - cc)
end)
local L = M.config.labels
-- Switch to two-char labels when there are more matches than single-char
-- labels. Pair labels expand capacity to up to |L|^2 (676 with defaults),
-- at the cost of requiring two keystrokes per jump while `two` is true.
local two, ivs_by_line, li = #matches > #L, {}, 1
for _, m in ipairs(matches) do
vim.hl.range(buf, NS, M.config.search, { m.line, m.start_col }, { m.line, m.end_col }, { priority = 200 })
local label
if two and li <= #L * #L then
-- Pair encoding: `a` (slow-varying) is the first char, `b` (fast-
-- varying) is the second. So labels li=1..#L all share the same
-- first char, keeping `prefixes` minimal for as long as possible —
-- important because every char in `prefixes` is a char the user
-- can no longer use to extend the search pattern in one keystroke.
local a, b = math.floor((li - 1) / #L) + 1, (li - 1) % #L + 1
label = L:sub(a, a) .. L:sub(b, b)
elseif not two then
-- Single-char: walk the cursor past any label that's unavailable
-- (would conflict with pattern extension).
while li <= #L and not avail[L:sub(li, li)] do
li = li + 1
end
if li <= #L then label = L:sub(li, li) end
end
if label then
-- Skip this match's label if it would visually overlap (or, in 2-char
-- mode, touch within 1 column of) any label already placed on this
-- line. The match still gets a search highlight; the unused label
-- carries over to the next non-conflicting match.
local gap = two and 1 or 0
local ivs = ivs_by_line[m.line] or {}
local fits = true
for _, iv in ipairs(ivs) do
-- Standard [a, b) interval-overlap test with `gap` padding on each side.
if m.start_col < iv[2] + gap and iv[1] < m.start_col + #label + gap then
fits = false
break
end
end
if fits then
-- Record the 2-char label's first char so the input loop knows
-- to buffer a second keystroke when it sees that char.
if two then prefixes[label:sub(1, 1)] = true end
active[label] = { m.line + 1, m.start_col }
api.nvim_buf_set_extmark(buf, NS, m.line, m.start_col, {
virt_text = { { label, M.config.label } },
virt_text_pos = 'overlay',
priority = 201,
})
ivs[#ivs + 1] = { m.start_col, m.start_col + #label }
ivs_by_line[m.line] = ivs
li = li + 1
end
end
end
end
vim.cmd.redraw()
end
api.nvim_buf_clear_namespace(buf, NS, 0, -1)
api.nvim_echo({ { '', '' } }, false, {})
vim.cmd.redraw()
end
function M.setup(opts)
M.config = vim.tbl_extend('force', M.config, opts or {})
-- Recreate the augroup on every setup() call (clear = true drops any prior
-- autocmds). Without this, repeated setup calls with different label configs
-- would accumulate stale ColorScheme autocmds.
local group = api.nvim_create_augroup('tiny-jump.highlights', { clear = true })
if type(M.config.label) == 'table' then
-- User passed highlight attrs directly. Own a dedicated highlight group
-- (TinyJumpLabel) and re-apply it on ColorScheme so their colors survive
-- theme changes — colorschemes clear user-defined highlights.
local attrs = M.config.label
M.config.label = 'TinyJumpLabel'
api.nvim_set_hl(0, 'TinyJumpLabel', attrs)
api.nvim_create_autocmd('ColorScheme', {
group = group,
callback = function() api.nvim_set_hl(0, 'TinyJumpLabel', attrs) end,
})
end
end
return M