jump.lua

Branches > main > f463412 > files / nvim / lua / jump.lua

local ns = vim.api.nvim_create_namespace("dev.grahp.nvim.jump")
local utils = require("utils")

--- Name meanings:
--- Jump target: Somewhere you can jump to - Union of Match and table of optional Disambiguator
--- Disambiguator: A third character used to differentiate jump targets when there are multiple
--- Match: Union Pos and a table of Char
--- Row: The number of lines into the buffer line is? (0-based?) TODO rename?
--- 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

--- ALL CAPS

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

local function smart_case_find_index(s, pattern, pos)
  if not pattern:match("%u") then
    s = s:lower()
  end

  local match_start, match_end = string.find(s, pattern, pos, true)
  if match_start and match_end then
    return match_end
  end
end

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


local function lines()
  local bufnr = vim.api.nvim_get_current_buf()
  return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
end

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

  local out = {}

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

  for row, line in ipairs(lines) do
    -- I miss filter lol
    if row >= start_line and row <= end_line then
      local i = 1

      while true do
        local pos = smart_case_find_index(line, char, i)
        if not pos then
          break
        end

        local char = string_get(line, pos + 1)
        if char then
          table.insert(out, {row = row,
          col = pos,
          char = char})
        end

        i = pos + 1
      end
    end
  end

  return out
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

--- 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 = utils.seq_iterator(disambiguators)

  local counts = char_counts(matches)

  local acc = {}
  for i, 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 paint_labels(matches)
  vim.api.nvim_buf_clear_namespace(0, ns, 0, -1)
  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

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

--- Adds the current pos to the jump list
--- See :h jumplist
local function add_to_jump_list()
  vim.cmd("normal! m'")
end

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

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

--- These are in order of how easy I find them to press :p
--- Disambiguators are taken in sequence
local 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", "[", "]", "-", "=", "`",
                        "\"",
                        "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "{", "}", "_", "+", "~",
                      }

--- Reads the 2 or 3 inputted characters
local function jump()
  local char = read_char()
  if not char then return end

  --- TODO 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, 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

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

  clear_labels()
  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
      jump_to(m)
      break
    end
  end

  clear_labels()
end

return {jump = jump}