mirror of
https://github.com/smoka7/hop.nvim.git
synced 2026-07-21 23:26:58 -04:00
461 lines
15 KiB
Lua
461 lines
15 KiB
Lua
-- Jump targets are locations in buffers where users might jump to. They are wrapped in a table and provide the
|
||
-- required information so that Hop can associate label and display the hints.
|
||
---@class Locations
|
||
---@field jump_targets JumpTarget[]
|
||
---@field indirect_jump_targets IndirectJumpTarget[]
|
||
|
||
-- A single jump target is simply a location in a given buffer.
|
||
-- So you can picture a jump target as a triple (line, column, window).
|
||
---@class JumpTarget
|
||
---@field buffer number
|
||
---@field line number
|
||
---@field column number
|
||
---@field length number
|
||
---@field window number
|
||
|
||
-- Indirect jump targets are encoded as a flat list-table of pairs (index, score). This table allows to quickly score
|
||
-- and sort jump targets. The `index` field gives the index in the `jump_targets` list. The `score` is any number. The
|
||
-- rule is that the lower the score is, the less prioritized the jump target will be.
|
||
---@class IndirectJumpTarget
|
||
---@field index number
|
||
---@field score number
|
||
|
||
---@class DirectionMode
|
||
---@field direction HintDirection
|
||
---@field cursor_col number
|
||
|
||
---@class JumpContext
|
||
---@field buf_handle number
|
||
---@field win_handle number
|
||
---@field regex Regex
|
||
---@field x_bias number
|
||
---@field line_context LineContext
|
||
---@field col_offset number
|
||
---@field cursor_pos any[]
|
||
---@field win_width number
|
||
---@field direction_mode DirectionMode
|
||
---@field hint_position HintPosition
|
||
|
||
---@class Regex
|
||
---@field oneshot boolean
|
||
---@field match function
|
||
---@field linewise boolean determines if regex considers whole lines
|
||
|
||
local hint = require('hop.hint')
|
||
local window = require('hop.window')
|
||
|
||
---@class JumpTargetModule
|
||
local M = {}
|
||
|
||
-- Manhattan distance with column and row, weighted on x so that results are more packed on y.
|
||
---@param a number[]
|
||
---@param b number[]
|
||
---@param x_bias number
|
||
---@return number
|
||
local function manh_dist(a, b, x_bias)
|
||
return (x_bias * math.abs(b[1] - a[1])) + math.abs(b[2] - a[2])
|
||
end
|
||
|
||
-- Mark the current line with jump targets.
|
||
--- @param ctx JumpContext
|
||
---@return JumpTarget[]
|
||
local function mark_jump_targets_line(ctx)
|
||
---@type JumpTarget[]
|
||
local jump_targets = {}
|
||
|
||
local end_index = vim.fn.strdisplaywidth(ctx.line_context.line)
|
||
if ctx.win_width ~= nil then
|
||
end_index = ctx.col_offset + ctx.win_width
|
||
end
|
||
|
||
local shifted_line = ctx.line_context.line:sub(1 + ctx.col_offset, vim.fn.byteidx(ctx.line_context.line, end_index))
|
||
|
||
-- modify the shifted line to take the direction mode into account, if any
|
||
-- FIXME: we also need to do that for the cursor
|
||
local col_bias = 0
|
||
if ctx.direction_mode ~= nil then
|
||
local col = vim.fn.byteidx(ctx.line_context.line, ctx.direction_mode.cursor_col + 1)
|
||
if ctx.direction_mode.direction == hint.HintDirection.AFTER_CURSOR then
|
||
-- we want to change the start offset so that we ignore everything before the cursor
|
||
shifted_line = shifted_line:sub(col - ctx.col_offset)
|
||
col_bias = col - 1
|
||
elseif ctx.direction_mode.direction == hint.HintDirection.BEFORE_CURSOR then
|
||
-- we want to change the end
|
||
shifted_line = shifted_line:sub(1, col - ctx.col_offset)
|
||
end
|
||
end
|
||
|
||
local col = 1
|
||
while true do
|
||
local s = shifted_line:sub(col)
|
||
local b, e = ctx.regex.match(s, {
|
||
line = ctx.line_context.line_nr,
|
||
column = math.max(1, col + ctx.col_offset + col_bias),
|
||
buffer = ctx.buf_handle,
|
||
window = ctx.win_handle,
|
||
})
|
||
|
||
-- match empty lines only in linewise regexes
|
||
if b == nil or ((b == 0 and e == 0) and not ctx.regex.linewise) then
|
||
break
|
||
end
|
||
-- Preview need a length to highlight the matched string. Zero means nothing to highlight.
|
||
local matched_length = e - b
|
||
-- As the make for jump target must be placed at a cell (but some pattern like '^' is
|
||
-- placed between cells), we should make sure e > b
|
||
if b == e then
|
||
e = e + 1
|
||
end
|
||
|
||
local colp = col + b
|
||
if ctx.hint_position == hint.HintPosition.MIDDLE then
|
||
colp = col + math.floor((b + e) / 2)
|
||
elseif ctx.hint_position == hint.HintPosition.END then
|
||
colp = col + e - 1
|
||
end
|
||
jump_targets[#jump_targets + 1] = {
|
||
line = ctx.line_context.line_nr,
|
||
column = math.max(1, colp + ctx.col_offset + col_bias),
|
||
length = math.max(0, matched_length),
|
||
buffer = ctx.buf_handle,
|
||
window = ctx.win_handle,
|
||
}
|
||
|
||
-- do not search further if regex is oneshot or if there is nothing more to search
|
||
if ctx.regex.oneshot or s == '' then
|
||
break
|
||
end
|
||
col = col + e
|
||
end
|
||
|
||
return jump_targets
|
||
end
|
||
|
||
-- Create jump targets for a given indexed line.
|
||
-- This function creates the jump targets for the current (indexed) line and appends them to the input list of jump
|
||
-- targets `jump_targets`.
|
||
---@param ctx JumpContext
|
||
---@param locations Locations used later to sort jump targets by score and create hints.
|
||
local function create_jump_targets_for_line(ctx, locations)
|
||
-- first, create the jump targets for the ith line
|
||
local line_jump_targets = mark_jump_targets_line(ctx)
|
||
|
||
-- then, append those to the input jump target list and create the indexed jump targets
|
||
local win_bias = math.abs(vim.api.nvim_get_current_win() - ctx.win_handle) * 1000
|
||
for _, jump_target in pairs(line_jump_targets) do
|
||
locations.jump_targets[#locations.jump_targets + 1] = jump_target
|
||
|
||
locations.indirect_jump_targets[#locations.indirect_jump_targets + 1] = {
|
||
index = #locations.jump_targets,
|
||
score = manh_dist(ctx.cursor_pos, { jump_target.line, jump_target.column }, ctx.x_bias) + win_bias,
|
||
}
|
||
end
|
||
end
|
||
|
||
-- Create jump targets by scanning lines in the currently visible buffer.
|
||
--
|
||
-- This function takes a regex argument, which is an object containing a match function that must return the span
|
||
-- (inclusive beginning, exclusive end) of the match item, or nil when no more match is possible. This object also
|
||
-- contains the `oneshot` field, a boolean stating whether only the first match of a line should be taken into account.
|
||
--
|
||
-- This function returns the lined jump targets (an array of N lines, where N is the number of currently visible lines).
|
||
-- Lines without jump targets are assigned an empty table ({}). For lines with jump targets, a list-table contains the
|
||
-- jump targets as pair of { line, col }.
|
||
--
|
||
-- In addition the jump targets, this function returns the total number of jump targets (i.e. this is the same thing as
|
||
-- traversing the lined jump targets and summing the number of jump targets for all lines) as a courtesy, plus «
|
||
-- indirect jump targets. » Indirect jump targets are encoded as a flat list-table containing three values: i, for the
|
||
-- ith line, j, for the rank of the jump target, and dist, the score distance of the associated jump target. This list
|
||
-- is sorted according to that last dist parameter in order to know how to distribute the jump targets over the buffer.
|
||
---@param regex Regex
|
||
---@return function
|
||
function M.jump_targets_by_scanning_lines(regex)
|
||
---@param opts Options
|
||
---@return Locations
|
||
return function(opts)
|
||
-- get the window context; this is used to know which part of the visible buffer is to hint
|
||
local all_ctxs = window.get_window_context(opts)
|
||
|
||
---@type Locations
|
||
local Locations = {
|
||
jump_targets = {},
|
||
indirect_jump_targets = {},
|
||
}
|
||
---@type JumpContext
|
||
local Context = {
|
||
x_bias = opts.x_bias,
|
||
regex = regex,
|
||
hint_position = opts.hint_position,
|
||
}
|
||
|
||
-- Iterate all buffers
|
||
for _, bctx in ipairs(all_ctxs) do
|
||
-- Iterate all windows of a same buffer
|
||
Context.buf_handle = bctx.buffer_handle
|
||
for _, wctx in ipairs(bctx.contexts) do
|
||
window.clip_window_context(wctx, opts.direction)
|
||
|
||
Context.win_handle = wctx.hwin
|
||
Context.col_offset = wctx.col_offset
|
||
Context.win_width = wctx.win_width
|
||
Context.cursor_pos = wctx.cursor_pos
|
||
-- Get all lines' context
|
||
local lines = window.get_lines_context(bctx.buffer_handle, wctx)
|
||
-- in the case of a direction, we want to treat the first or last line (according to the direction) differently
|
||
if opts.direction == hint.HintDirection.AFTER_CURSOR then
|
||
-- the first line is to be checked first
|
||
if not Context.regex.linewise then
|
||
Context.direction_mode = { cursor_col = wctx.cursor_pos[2], direction = opts.direction }
|
||
Context.line_context = lines[1]
|
||
create_jump_targets_for_line(Context, Locations)
|
||
end
|
||
|
||
Context.direction_mode = nil
|
||
for i = 2, #lines do
|
||
Context.line_context = lines[i]
|
||
create_jump_targets_for_line(Context, Locations)
|
||
end
|
||
elseif opts.direction == hint.HintDirection.BEFORE_CURSOR then
|
||
-- the last line is to be checked last
|
||
Context.direction_mode = nil
|
||
for i = 1, #lines - 1 do
|
||
Context.line_context = lines[i]
|
||
create_jump_targets_for_line(Context, Locations)
|
||
end
|
||
|
||
if not Context.regex.linewise then
|
||
Context.direction_mode = { cursor_col = wctx.cursor_pos[2], direction = opts.direction }
|
||
Context.line_context = lines[#lines]
|
||
create_jump_targets_for_line(Context, Locations)
|
||
end
|
||
else
|
||
Context.direction_mode = nil
|
||
for i = 1, #lines do
|
||
Context.line_context = lines[i]
|
||
-- do not mark current line in active window
|
||
if
|
||
not (
|
||
Context.regex.linewise
|
||
and Context.line_context.line_nr == vim.api.nvim_win_get_cursor(Context.win_handle)[1] - 1
|
||
and vim.api.nvim_get_current_win() == Context.win_handle
|
||
)
|
||
then
|
||
create_jump_targets_for_line(Context, Locations)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
M.sort_indirect_jump_targets(Locations.indirect_jump_targets, opts)
|
||
|
||
return Locations
|
||
end
|
||
end
|
||
|
||
-- Jump target generator for regex applied only on the cursor line.
|
||
---@param regex Regex
|
||
---@return function
|
||
function M.jump_targets_for_current_line(regex)
|
||
---@param opts Options
|
||
---@return Locations
|
||
return function(opts)
|
||
local context = window.get_window_context(opts)[1].contexts[1]
|
||
local line_n = context.cursor_pos[1]
|
||
local line = vim.api.nvim_buf_get_lines(0, line_n - 1, line_n, false)
|
||
local Locations = {
|
||
jump_targets = {},
|
||
indirect_jump_targets = {},
|
||
}
|
||
|
||
create_jump_targets_for_line({
|
||
buf_handle = 0,
|
||
win_handle = 0,
|
||
regex = regex,
|
||
x_bias = opts.x_bias,
|
||
col_offset = context.col_offset,
|
||
win_width = context.win_width,
|
||
cursor_pos = context.cursor_pos,
|
||
direction_mode = { cursor_col = context.cursor_pos[2], direction = opts.direction },
|
||
hint_position = opts.hint_position,
|
||
line_context = { line_nr = line_n - 1, line = line[1] },
|
||
}, Locations)
|
||
|
||
M.sort_indirect_jump_targets(Locations.indirect_jump_targets, opts)
|
||
|
||
return Locations
|
||
end
|
||
end
|
||
|
||
-- Apply a score function based on the Manhattan distance to indirect jump targets.
|
||
---@param indirect_jump_targets IndirectJumpTarget[]
|
||
---@param opts Options
|
||
function M.sort_indirect_jump_targets(indirect_jump_targets, opts)
|
||
local score_comparison = function(a, b)
|
||
return a.score < b.score
|
||
end
|
||
if opts.reverse_distribution then
|
||
score_comparison = function(a, b)
|
||
return a.score > b.score
|
||
end
|
||
end
|
||
|
||
table.sort(indirect_jump_targets, score_comparison)
|
||
end
|
||
|
||
-- Regex modes for the buffer-driven generator.
|
||
---@param s string
|
||
---@return boolean
|
||
local function starts_with_uppercase(s)
|
||
if #s == 0 then
|
||
return false
|
||
end
|
||
|
||
local f = s:sub(1, vim.fn.byteidx(s, 1))
|
||
-- if it’s a space, we assume it’s not uppercase, even though Lua doesn’t agree with us; I mean, Lua is horrible, who
|
||
-- would like to argue with that creature, right?
|
||
if f == ' ' then
|
||
return false
|
||
end
|
||
|
||
return f:upper() == f
|
||
end
|
||
|
||
-- Regex by searching a pattern.
|
||
---@param pat string
|
||
---@param plain_search? boolean
|
||
---@return Regex
|
||
local function regex_by_searching(pat, plain_search)
|
||
if plain_search then
|
||
pat = vim.fn.escape(pat, '\\/.$^~[]')
|
||
end
|
||
|
||
local regex = vim.regex(pat)
|
||
|
||
return {
|
||
oneshot = false,
|
||
match = function(s)
|
||
return regex:match_str(s)
|
||
end,
|
||
}
|
||
end
|
||
|
||
-- Wrapper over M.regex_by_searching to add support for case sensitivity.
|
||
---@param pat string
|
||
---@param plain_search boolean
|
||
---@param opts Options
|
||
---@return Regex
|
||
function M.regex_by_case_searching(pat, plain_search, opts)
|
||
if plain_search then
|
||
pat = vim.fn.escape(pat, '\\/.$^~[]')
|
||
end
|
||
|
||
if vim.o.smartcase then
|
||
if not starts_with_uppercase(pat) then
|
||
pat = '\\c' .. pat
|
||
end
|
||
elseif opts.case_insensitive then
|
||
pat = '\\c' .. pat
|
||
end
|
||
|
||
local regex = vim.regex(pat)
|
||
|
||
return {
|
||
oneshot = false,
|
||
match = function(s)
|
||
return regex:match_str(s)
|
||
end,
|
||
}
|
||
end
|
||
|
||
-- Word regex.
|
||
---@return Regex
|
||
function M.regex_by_word_start()
|
||
return regex_by_searching('\\k\\+')
|
||
end
|
||
|
||
-- Camel case regex
|
||
---@return Regex
|
||
function M.regex_by_camel_case()
|
||
local camel = '\\u\\l\\+'
|
||
local acronyms = '\\u\\+\\ze\\u\\l'
|
||
local upper = '\\u\\+'
|
||
local lower = '\\l\\+'
|
||
local rgb = '#\\x\\+\\>'
|
||
local ox = '\\<0[xX]\\x\\+\\>'
|
||
local oo = '\\<0[oO][0-7]\\+\\>'
|
||
local ob = '\\<0[bB][01]\\+\\>'
|
||
local num = '\\d\\+'
|
||
|
||
local tab = { camel, acronyms, upper, lower, rgb, ox, oo, ob, num, '\\~', '!', '@', '#', '$' }
|
||
-- regex that matches camel or acronyms or upper ... or num ...
|
||
local patStr = '\\%(\\%(' .. table.concat(tab, '\\)\\|\\%(') .. '\\)\\)'
|
||
|
||
local pat = vim.regex(patStr)
|
||
return {
|
||
oneshot = false,
|
||
match = function(s)
|
||
return pat:match_str(s)
|
||
end,
|
||
}
|
||
end
|
||
|
||
-- Line regex.
|
||
---@return Regex
|
||
function M.by_line_start()
|
||
local c = vim.fn.winsaveview().leftcol
|
||
|
||
return {
|
||
oneshot = true,
|
||
linewise = true,
|
||
match = function(s)
|
||
local l = vim.fn.strdisplaywidth(s)
|
||
if c > 0 and l == 0 then
|
||
return nil
|
||
end
|
||
|
||
return 0, 1
|
||
end,
|
||
}
|
||
end
|
||
|
||
-- Line regex at cursor position.
|
||
---@return Regex
|
||
function M.regex_by_vertical()
|
||
local buf = vim.api.nvim_win_get_buf(0)
|
||
local cursor = vim.api.nvim_win_get_cursor(0)
|
||
local regex = vim.regex(string.format('^.\\{0,%d\\}\\(.\\|$\\)', cursor[2]))
|
||
return {
|
||
oneshot = true,
|
||
linewise = true,
|
||
match = function(s, ctx)
|
||
if ctx.buffer == buf and ctx.line == cursor[1] - 1 then
|
||
return nil
|
||
end
|
||
return regex:match_str(s)
|
||
end,
|
||
}
|
||
end
|
||
|
||
-- Line regex skipping finding the first non-whitespace character on each line.
|
||
---@return Regex
|
||
function M.regex_by_line_start_skip_whitespace()
|
||
local regex = vim.regex('\\S')
|
||
|
||
return {
|
||
oneshot = true,
|
||
linewise = true,
|
||
match = function(s)
|
||
return regex:match_str(s)
|
||
end,
|
||
}
|
||
end
|
||
|
||
-- Anywhere regex.
|
||
---@return Regex
|
||
function M.regex_by_anywhere()
|
||
return regex_by_searching('\\v(<.|^$)|(.>|^$)|(\\l)\\zs(\\u)|(_\\zs.)|(#\\zs.)')
|
||
end
|
||
|
||
return M
|