jump.lua

Branches > main > 74c2f33 > files / nvim / lua / jump.lua

--- Jump script
--- License: 0BSD
local ns = vim.api.nvim_create_namespace("https://grahp.dev/jump.nvim")

--- Name meanings:
--- Jump target: Somewhere you can jump to - row, col (pos), char, and optional disambiguator
--- Disambiguator: A third character used to differentiate jump targets when there are multiple
--- Match: row, col (pos) and char
--- Row: The number of lines into the buffer line is? (0-based?)
--- Col: The number of characters into the line? (1-based?)
--- Pos: table of row, col
--- Char: A character used for jumping, entered by the user
--- Label: A hint painted to the screen to show where jump targets are, and what disambiguator you might need to press

--- Blocks and reads one char of user input.
--- Returns the char or nil
local function read_char()
  local char = vim.fn.getcharstr()
  return char == "" and nil or char
end

local function is_all_lower_case(s)
  return not s:match("%u")
end

--- Returns a sequential table of the indecies of all instances of 'subs' in 's'.
--- If 'subs' is all lower case, 's' will be search case insensitive, otherwise it will be case sensitive
local function smart_case_find_all(s, subs)
  local pos = 1
  local acc = {}

  if is_all_lower_case(subs) then
    s = s:lower()
  end

  while true do
    local match_pos = string.find(s, subs, pos, true)
    if not match_pos then break end
    table.insert(acc, match_pos)
    pos = match_pos + 1
  end
  return acc
end

local function string_get(s, i)
  return s:sub(i, i)
end

local function get_all_buffer_lines()
  return vim.api.nvim_buf_get_lines(0, 0, -1, false)
end

--- Returns all matches on the screen that start with "char".
local function find_matches(char)
  local lines = get_all_buffer_lines()

  local matches = {}

  local start_line = vim.fn.line("w0")
  local end_line   = vim.fn.line("w$")

  --- Because jump_to() and paint_labels() are buffer-based and non screen-based when it comes to lines,
  ---  this instead gets all the lines in the buffer and filters down the lines that are actually on the screen
  for row, line in ipairs(lines) do
    if row >= start_line and row <= end_line then
      for _, pos in ipairs(smart_case_find_all(line, char)) do
        local char = string_get(line, pos + 1)
        if char then
          table.insert(matches, {row = row, col = pos, char = char})
        end
      end
    end
  end

  return matches
end

--- Returns a table of chars to the number of times those chars appear in the entire table of matches
--- All matches will map to at least 1 and will not be nil.
local function char_counts(matches)
  local counts = {}
  for _, m in ipairs(matches) do
    local char = m.char:lower()
    counts[char] = (counts[char] or 0) + 1
  end
  return counts
end

--- Returns a stateful iterator of the elements in 't'
local function seq_iterator(t)
  local i = 0
  return function()
    i = i + 1
    return t[i]
  end
end

--- Takes a table of matches and adds a unique disambiguator to each match
--- If a match's char is unique across the entire matches table,
---  the match will have no diambiguator, otherwise it will be picked from 'disambiguators' (a table of disambiguators)
--- May return fewer matches than provided if all disambiguators are exhausted
local function assign_disambiguators(matches, disambiguators)
  local next_disambiguator = seq_iterator(disambiguators)

  local counts = char_counts(matches)

  local acc = {}
  for _, match in ipairs(matches) do
    local disambiguator = next_disambiguator()
    if not disambiguator then
      break
    end

    local char = match.char:lower()

    if counts[char] > 1 then
      match.disambiguator = disambiguator
    end
    table.insert(acc, match)
  end
  return acc
end

local function clear_labels()
  vim.api.nvim_buf_clear_namespace(0, ns, 0, -1)
end

local function paint_labels(matches)
  clear_labels()
  for _, m in ipairs(matches) do
    vim.api.nvim_buf_set_extmark(0, ns, m.row - 1, m.col,
                                 {virt_text = {{m.char, "Jump"},
                                               m.disambiguator and {m.disambiguator, "JumpDisambiguator"}},
                                  virt_text_pos = "overlay",
                                  hl_mode = "combine"})
  end
  vim.cmd("redraw")
end

--- These are in order of how easy I find them to press :p
--- Disambiguators are taken in sequence
local close_disambiguators = {
                              "a", "s", "d", "f", "j", "k", "l",
                                   "r", "u", "e", "i", "w", "o",
                                   "v", "m", "c", ",", "x", ".",

                                   "/", "p", "q", "z", "g", "h", "t", "y", "b", "n",

                                   "S", "D", "F", "J", "K", "L", ":",
                                   "R", "U", "E", "I", "W", "O",
                                        "M",      "<",      ">",

                              "?", "P", "Q", "Z", "G", "H", "T", "Y", "B", "N",
                              "'",
                              "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "[", "]", "-", "=", "`",
                              "\"",
                              "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "{", "}", "_", "+", "~",
                             }

--- Also safe disambiguators :)

--- Returns as many matches as it can (bounded by disambiguators)
local function get_jump_targets(char, disambiguators)
  local matches = find_matches(char)
  return assign_disambiguators(matches, disambiguators)
end

local function jump_to(pos)
  if pos then
    vim.api.nvim_win_set_cursor(0, {pos.row, pos.col - 1})
  end
end

--- Reads the 2 or 3 inputted characters
--- Returns a pos to jump to or nil
local function get_jump_pos(opts)
  --- Should be a merge function :p
  local char = read_char()
  if not char then return end

  --- would be cool to do f/t jumping
  --- Works across lines and uses the safe disambiguators until they run out

  local matches = get_jump_targets(char, opts.disambiguators)

  if #matches == 0 then return end

  paint_labels(matches)

  local char2 = read_char()
  if not char2 then
    clear_labels()
    return
  end

  local char2_matches = {}

  for _, m in ipairs(matches) do
    if m.char == char2 or m.char:lower() == char2 then
      table.insert(char2_matches, m)
    end
  end

  clear_labels()
  if #char2_matches == 0 then
    return
  elseif #char2_matches == 1 and not char2_matches[1].disambiguator then
    return char2_matches[1]
  end

  paint_labels(char2_matches)

  local char3 = read_char()
  if not char3 then
    clear_labels()
    return
  end

  for _, m in ipairs(char2_matches) do
    if m.disambiguator == char3 then
      clear_labels()
      return m
    end
  end

  clear_labels()
end

local opts = {disambiguators = close_disambiguators}

--- Reads 2 or 3 characters of user input, and jumps to the point returned by get_jump_pos()
local function jump()
  local pos = get_jump_pos(opts)
  jump_to(pos)
  return pos
end

--- Example jump bindings:
-- vim.keymap.set({"n", "v", "o"}, ";", jump)
-- vim.keymap.set({"n", "v", "o"}, "s", jump)

--- Or loaded lazily from another file:
-- vim.keymap.set({"n", "v", "o"}, "s", function() require("jump").jump() end)
-- vim.keymap.set({"n", "v", "o"}, ";", function() require("jump").jump() end)

--- Highlight Groups:

local visual = vim.api.nvim_get_hl(0, {name = "Visual"})

vim.api.nvim_set_hl(0, "Jump", visual)
vim.api.nvim_set_hl(0, "JumpDisambiguator", {bg = visual.bg, bold = true, fg = "#2ad6d6"}) -- Just a standout color :p

return {jump = jump}