local M = {} -- Ensure options are sound. -- -- Some options cannot be used together. For instance, multi_windows and current_line_only don’t really make sense used -- together. This function will notify the user of such ill-formed configurations. local function check_opts(opts) if not opts then return end if opts.multi_windows and opts.current_line_only then vim.notify('Cannot use current_line_only across multiple windows', 3) end if vim.api.nvim_get_mode().mode ~= 'n' then opts.multi_windows = false end end -- Allows to override global options with user local overrides. local function override_opts(opts) check_opts(opts) return setmetatable(opts or {}, {__index = M.opts}) end -- Display error messages. local function eprintln(msg, teasing) if teasing then vim.api.nvim_echo({{msg, 'Error'}}, true, {}) end end -- Create hint state -- -- { -- all_ctxs: All windows's context -- buf_list: All buffers displayed in all windows -- _ns: Required namespaces -- } local function create_hint_state(opts) local window = require'hop.window' local hint_state = {} -- get all window's context and buffer list hint_state.all_ctxs = window.get_window_context(opts.multi_windows) hint_state.buf_list = {} for _, bctx in ipairs(hint_state.all_ctxs) do hint_state.buf_list[#hint_state.buf_list + 1] = bctx.hbuf for _, wctx in ipairs(bctx.contexts) do window.clip_window_context(wctx, opts.direction) end end -- create the highlight groups; the highlight groups will allow us to clean everything at once when Hop quits hint_state.hl_ns = vim.api.nvim_create_namespace('hop_hl') hint_state.dim_ns = vim.api.nvim_create_namespace('hop_dim') -- backup namespaces of diagnostic if vim.fn.has("nvim-0.6") == 1 then hint_state.diag_ns = vim.diagnostic.get_namespaces() end -- Store users cursorline state hint_state.cursorline = vim.api.nvim_win_get_option(vim.api.nvim_get_current_win(), 'cursorline') return hint_state end -- A hack to prevent #57 by deleting twice the namespace (it’s super weird). local function clear_namespace(buf_list, hl_ns) for _, buf in ipairs(buf_list) do if vim.api.nvim_buf_is_valid(buf) then vim.api.nvim_buf_clear_namespace(buf, hl_ns, 0, -1) vim.api.nvim_buf_clear_namespace(buf, hl_ns, 0, -1) end end end -- Set the highlight of unmatched lines of the buffer. -- -- - hl_ns is the highlight namespace. -- - top_line is the top line in the buffer to start highlighting at -- - bottom_line is the bottom line in the buffer to stop highlighting at local function set_unmatched_lines(buf_handle, hl_ns, top_line, bottom_line, cursor_pos, direction, current_line_only) local hint = require'hop.hint' local prio = require'hop.priority' local start_line = top_line local end_line = bottom_line local start_col = 0 local end_col = nil if direction == hint.HintDirection.AFTER_CURSOR then start_col = cursor_pos[2] elseif direction == hint.HintDirection.BEFORE_CURSOR then end_line = bottom_line - 1 if cursor_pos[2] ~= 0 then end_col = cursor_pos[2] end end if current_line_only then if direction == hint.HintDirection.BEFORE_CURSOR then start_line = cursor_pos[1] - 1 end_line = cursor_pos[1] - 1 else start_line = cursor_pos[1] - 1 end_line = cursor_pos[1] end end local extmark_options = { end_line = end_line, hl_group = 'HopUnmatched', hl_eol = true, priority = prio.DIM_PRIO } if end_col then local current_line = vim.api.nvim_buf_get_lines(buf_handle, cursor_pos[1] - 1, cursor_pos[1], true)[1] local current_width = vim.fn.strdisplaywidth(current_line) if end_col > current_width then end_col = current_width - 1 end extmark_options.end_col = end_col end vim.api.nvim_buf_set_extmark(buf_handle, hl_ns, start_line, start_col, extmark_options) end -- Dim everything out to prepare the Hop session for all windows. local function apply_dimming(hint_state, opts) local window = require'hop.window' for _, bctx in ipairs(hint_state.all_ctxs) do for _, wctx in ipairs(bctx.contexts) do window.clip_window_context(wctx, opts.direction) -- dim everything out, add the virtual cursor and hide diagnostics set_unmatched_lines(bctx.hbuf, hint_state.dim_ns, wctx.top_line, wctx.bot_line, wctx.cursor_pos, opts.direction, opts.current_line_only) end if vim.fn.has("nvim-0.6") == 1 then for ns in pairs(hint_state.diag_ns) do vim.diagnostic.show(ns, bctx.hbuf, nil, { virtual_text = false }) end end end end -- Add the virtual cursor, taking care to handle the cases where: -- - the virtualedit option is being used and the cursor is in a -- tab character or past the end of the line -- - the current line is empty -- - there are multibyte characters on the line local function add_virt_cur(ns) local prio = require'hop.priority' local cur_info = vim.fn.getcurpos() local cur_row = cur_info[2] - 1 local cur_col = cur_info[3] - 1 -- this gives cursor column location, in bytes local cur_offset = cur_info[4] local virt_col = cur_info[5] - 1 local cur_line = vim.api.nvim_get_current_line() -- toggle cursorline off if currently set local cursorline_info = vim.api.nvim_win_get_option(vim.api.nvim_get_current_win(), 'cursorline') if cursorline_info == true then vim.api.nvim_win_set_option(vim.api.nvim_get_current_win(), 'cursorline', false) end -- first check to see if cursor is in a tab char or past end of line if cur_offset ~= 0 then vim.api.nvim_buf_set_extmark(0, ns, cur_row, cur_col, { virt_text = {{'█', 'Normal'}}, virt_text_win_col = virt_col, priority = prio.CURSOR_PRIO }) -- otherwise check to see if cursor is at end of line or on empty line elseif #cur_line == cur_col then vim.api.nvim_buf_set_extmark(0, ns, cur_row, cur_col, { virt_text = {{'█', 'Normal'}}, virt_text_pos = 'overlay', priority = prio.CURSOR_PRIO }) else vim.api.nvim_buf_set_extmark(0, ns, cur_row, cur_col, { -- end_col must be column of next character, in bytes end_col = vim.fn.byteidx(cur_line, vim.fn.charidx(cur_line, cur_col) + 1), hl_group = 'HopCursor', priority = prio.CURSOR_PRIO }) end end -- Get pattern from input for hint and preview function M.get_input_pattern(prompt, maxchar, opts) local hint = require'hop.hint' local jump_target = require'hop.jump_target' local hs = {} if opts then hs = create_hint_state(opts) hs.preview_ns = vim.api.nvim_create_namespace('hop_preview') apply_dimming(hs, opts) add_virt_cur(hs.hl_ns) end local K_Esc = vim.api.nvim_replace_termcodes('', true, false, true) local K_BS = vim.api.nvim_replace_termcodes('', true, false, true) local K_C_H = vim.api.nvim_replace_termcodes('', true, false, true) local K_CR = vim.api.nvim_replace_termcodes('', true, false, true) local K_NL = vim.api.nvim_replace_termcodes('', true, false, true) local pat_keys = {} local pat = '' while (true) do pat = vim.fn.join(pat_keys, '') if opts then clear_namespace(hs.buf_list, hs.preview_ns) if #pat > 0 then local ok, re = pcall(jump_target.regex_by_case_searching, pat, false, opts) if ok then local jump_target_gtr = jump_target.jump_targets_by_scanning_lines(re) local generated = jump_target_gtr(opts) hint.set_hint_preview(hs.preview_ns, generated.jump_targets) end end end vim.api.nvim_echo({}, false, {}) vim.cmd('redraw') vim.api.nvim_echo({{prompt, 'Question'}, {pat}}, false, {}) local ok, key = pcall(vim.fn.getchar) if not ok then -- Interrupted by pat = nil break end if type(key) == 'number' then key = vim.fn.nr2char(key) elseif key:byte() == 128 then -- It's a special key in string end if key == K_Esc then pat = nil break elseif key == K_CR or key == K_NL then break elseif key == K_BS or key == K_C_H then pat_keys[#pat_keys] = nil else pat_keys[#pat_keys + 1] = key end if maxchar and #pat_keys >= maxchar then pat = vim.fn.join(pat_keys, '') break end end if opts then clear_namespace(hs.buf_list, hs.preview_ns) -- quit only when got nothin for pattern to avoid blink of highlight if not pat then M.quit(hs) end end vim.api.nvim_echo({}, false, {}) vim.cmd('redraw') return pat end -- Move the cursor at a given location. -- -- Add option to shift cursor by column offset -- -- This function will update the jump list. function M.move_cursor_to(w, line, column, hint_offset, direction) -- If we do not ask for an offset jump, we don’t have to retrieve any additional lines because we will jump to the -- actual jump target. If we do want a jump with an offset, we need to retrieve the line the jump target lies in so -- that we can compute the offset correctly. This is linked to the fact that currently, Neovim doesn’s have an API to -- « offset something by N visual columns. » -- If it is pending for operator shift column to the right by 1 if vim.api.nvim_get_mode().mode == 'no' and direction ~= 1 then column = column + 1 end if hint_offset ~= nil and not (hint_offset == 0) then -- Add `hint_offset` based on `charidx`. local buf_line = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(w), line - 1, line, false)[1] -- Since `charidx` returns -1 when `column` is the tail, subtract 1 and add 1 to the return value to get -- the correct value. local char_idx = vim.fn.charidx(buf_line, column - 1) + 1 + hint_offset column = vim.fn.byteidx(buf_line, char_idx) end -- update the jump list vim.cmd("normal! m'") vim.api.nvim_set_current_win(w) vim.api.nvim_win_set_cursor(w, { line, column }) end function M.hint_with(jump_target_gtr, opts) if opts == nil then opts = override_opts(opts) end M.hint_with_callback(jump_target_gtr, opts, function(jt) M.move_cursor_to(jt.window, jt.line + 1, jt.column - 1, opts.hint_offset, opts.direction) end) end function M.hint_with_callback(jump_target_gtr, opts, callback) local hint = require'hop.hint' if opts == nil then opts = override_opts(opts) end if not M.initialized then vim.notify('Hop is not initialized; please call the setup function', 4) return end -- create hint state local hs = create_hint_state(opts) -- create jump targets local generated = jump_target_gtr(opts) local jump_target_count = #generated.jump_targets local target_idx = nil if jump_target_count == 0 then target_idx = 0 elseif vim.v.count > 0 then target_idx = vim.v.count elseif jump_target_count == 1 and opts.jump_on_sole_occurrence then target_idx = 1 end if target_idx ~= nil then local jt = generated.jump_targets[target_idx] if jt then callback(jt) else eprintln(' -> there’s no such thing we can see…', opts.teasing) end clear_namespace(hs.buf_list, hs.hl_ns) clear_namespace(hs.buf_list, hs.dim_ns) return end -- we have at least two targets, so generate hints to display hs.hints = hint.create_hints(generated.jump_targets, generated.indirect_jump_targets, opts) -- dim everything out, add the virtual cursor and hide diagnostics apply_dimming(hs, opts) add_virt_cur(hs.hl_ns) hint.set_hint_extmarks(hs.hl_ns, hs.hints, opts) vim.cmd('redraw') local h = nil while h == nil do local ok, key = pcall(vim.fn.getchar) if not ok then M.quit(hs) break end local not_special_key = true -- :h getchar(): "If the result of expr is a single character, it returns a -- number. Use nr2char() to convert it to a String." Also the result is a -- special key if it's a string and its first byte is 128. -- -- Note of caution: Even though the result of `getchar()` might be a single -- character, that character might still be multiple bytes. if type(key) == 'number' then key = vim.fn.nr2char(key) elseif key:byte() == 128 then not_special_key = false end if not_special_key and opts.keys:find(key, 1, true) then -- If this is a key used in Hop (via opts.keys), deal with it in Hop h = M.refine_hints(key, hs, callback, opts) vim.cmd('redraw') else -- If it's not, quit Hop M.quit(hs) -- If the key captured via getchar() is not the quit_key, pass it through -- to nvim to be handled normally (including mappings) if key ~= vim.api.nvim_replace_termcodes(opts.quit_key, true, false, true) then vim.api.nvim_feedkeys(key, '', true) end break end end end -- Refine hints in the given buffer. -- -- Refining hints allows to advance the state machine by one step. If a terminal step is reached, this function jumps to -- the location. Otherwise, it stores the new state machine. function M.refine_hints(key, hint_state, callback, opts) local hint = require'hop.hint' local h, hints = hint.reduce_hints(hint_state.hints, key) if h == nil then if #hints == 0 then eprintln('no remaining sequence starts with ' .. key, opts.teasing) return end hint_state.hints = hints clear_namespace(hint_state.buf_list, hint_state.hl_ns) hint.set_hint_extmarks(hint_state.hl_ns, hints, opts) else M.quit(hint_state) -- prior to jump, register the current position into the jump list vim.cmd("normal! m'") callback(h.jump_target) return h end end -- Quit Hop and delete its resources. function M.quit(hint_state) clear_namespace(hint_state.buf_list, hint_state.hl_ns) clear_namespace(hint_state.buf_list, hint_state.dim_ns) -- Restore users cursorline setting if hint_state.cursorline == true then vim.api.nvim_win_set_option(vim.api.nvim_get_current_win(), 'cursorline', true) end for _, buf in ipairs(hint_state.buf_list) do -- sometimes, buffers might be unloaded; that’s the case with floats for instance (we can invoke Hop from them but -- then they disappear); we need to check whether the buffer is still valid before trying to do anything else with -- it if vim.api.nvim_buf_is_valid(buf) and vim.fn.has("nvim-0.6") == 1 then for ns in pairs(hint_state.diag_ns) do vim.diagnostic.show(ns, buf) end end end end function M.hint_words(opts) local jump_target = require'hop.jump_target' opts = override_opts(opts) local generator if opts.current_line_only then generator = jump_target.jump_targets_for_current_line else generator = jump_target.jump_targets_by_scanning_lines end M.hint_with( generator(jump_target.regex_by_word_start()), opts ) end function M.hint_patterns(opts, pattern) local jump_target = require'hop.jump_target' opts = override_opts(opts) -- The pattern to search is either retrieved from the (optional) argument -- or directly from user input. local pat if pattern then pat = pattern else vim.cmd('redraw') vim.fn.inputsave() pat = M.get_input_pattern('Hop pattern: ', nil, opts) vim.fn.inputrestore() if not pat then return end end if #pat == 0 then eprintln('-> empty pattern', opts.teasing) return end local generator if opts.current_line_only then generator = jump_target.jump_targets_for_current_line else generator = jump_target.jump_targets_by_scanning_lines end M.hint_with( generator(jump_target.regex_by_case_searching(pat, false, opts)), opts ) end function M.hint_char1(opts) local jump_target = require'hop.jump_target' opts = override_opts(opts) local c = M.get_input_pattern('Hop 1 char: ', 1) if not c then return end local generator if opts.current_line_only then generator = jump_target.jump_targets_for_current_line else generator = jump_target.jump_targets_by_scanning_lines end M.hint_with( generator(jump_target.regex_by_case_searching(c, true, opts)), opts ) end function M.hint_char2(opts) local jump_target = require'hop.jump_target' opts = override_opts(opts) local c = M.get_input_pattern('Hop 2 char: ', 2) if not c then return end local generator if opts.current_line_only then generator = jump_target.jump_targets_for_current_line else generator = jump_target.jump_targets_by_scanning_lines end M.hint_with( generator(jump_target.regex_by_case_searching(c, true, opts)), opts ) end function M.hint_lines(opts) local jump_target = require'hop.jump_target' opts = override_opts(opts) local generator if opts.current_line_only then generator = jump_target.jump_targets_for_current_line else generator = jump_target.jump_targets_by_scanning_lines end M.hint_with( generator(jump_target.by_line_start()), opts ) end function M.hint_vertical(opts) local hint = require'hop.hint' local jump_target = require'hop.jump_target' opts = override_opts(opts) -- only makes sense as end position given movement goal. opts.hint_position = hint.HintPosition.END local generator if opts.current_line_only then generator = jump_target.jump_targets_for_current_line else generator = jump_target.jump_targets_by_scanning_lines end -- FIXME: need to exclude current and include empty lines. M.hint_with( generator(jump_target.regex_by_vertical()), opts ) end function M.hint_lines_skip_whitespace(opts) local jump_target = require'hop.jump_target' opts = override_opts(opts) local generator if opts.current_line_only then generator = jump_target.jump_targets_for_current_line else generator = jump_target.jump_targets_by_scanning_lines end M.hint_with( generator(jump_target.regex_by_line_start_skip_whitespace()), opts ) end function M.hint_anywhere(opts) local jump_target = require'hop.jump_target' opts = override_opts(opts) local generator if opts.current_line_only then generator = jump_target.jump_targets_for_current_line else generator = jump_target.jump_targets_by_scanning_lines end M.hint_with( generator(jump_target.regex_by_anywhere()), opts ) end -- Setup user settings. function M.setup(opts) -- Look up keys in user-defined table with fallback to defaults. M.opts = setmetatable(opts or {}, {__index = require'hop.defaults'}) M.initialized = true -- Insert the highlights and register the autocommand if asked to. local highlight = require'hop.highlight' highlight.insert_highlights() if M.opts.create_hl_autocmd then highlight.create_autocmd() end -- register Hop extensions, if any if M.opts.extensions ~= nil then for _, ext_name in pairs(opts.extensions) do local ok, extension = pcall(require, ext_name) if not ok then -- 4 is error; thanks Neovim… :( vim.notify(string.format('extension %s wasn’t correctly loaded', ext_name), 4) else if extension.register == nil then vim.notify(string.format('extension %s lacks the register function', ext_name), 4) else extension.register(opts) end end end end end return M