Skip to content

Instantly share code, notes, and snippets.

@fira42073
Created June 28, 2025 00:17
Show Gist options
  • Save fira42073/d46969c8321f70536c63be01666ff0f7 to your computer and use it in GitHub Desktop.
Save fira42073/d46969c8321f70536c63be01666ff0f7 to your computer and use it in GitHub Desktop.
harpoon extensions
local Path = require("plenary.path")
local pathutil = {}
function pathutil.normalize_path(buf_name, root) return Path:new(buf_name):make_relative(root) end
-- Function to get the module path from go.mod
local function get_go_mod_path()
local cwd = vim.loop.cwd() -- Current working directory
local go_mod_path = Path:new(cwd, "go.mod")
-- Read the go.mod file
if go_mod_path:exists() then
local go_mod_contents = go_mod_path:read()
-- Find the module line
for line in go_mod_contents:gmatch("[^\r\n]+") do
if line:match("^module ") then
-- Extract the module path
return line:sub(8) -- Skip the 'module ' prefix
end
end
end
return nil
end
local function get_folder_path(buf_name)
local path = Path:new(buf_name) -- Create a Path object
return path:parent():absolute() -- Get the absolute path of the parent directory
end
-- Function to get the full path including module and file path
function pathutil.get_full_path_to_go_package()
local module_path = get_go_mod_path()
if not module_path then
print("Error: go.mod file not found or module path not defined")
return
end
local buf_name = vim.api.nvim_buf_get_name(0) -- Get the current buffer's file path
local root = vim.loop.cwd() -- Current working directory
local relative_path = pathutil.normalize_path(get_folder_path(buf_name), root)
-- Combine module path with relative path
local full_path = module_path .. "/" .. relative_path
return full_path
end
return pathutil
local stringutil = {}
function stringutil.split_filepath_with_num_and_prefix(input)
-- Match the optional bookmark comment and the filename:num
local bookmark_comment, filename, num = input:match("^(.-)%s*=>%s*(.-):(%d+)$")
-- If the match fails, it means there was no '=>', so try the simpler pattern
if not bookmark_comment then
filename, num = input:match("^(.-):(%d+)$")
if not num then return "", input, "" end
-- Convert the matched number to an actual number type
num = tonumber(num)
-- Return the parts
return "", filename, num
end
return bookmark_comment, filename, num
end
function stringutil.split_filepath_with_num(input)
-- Match the string and the number at the end
local str, num = input:match("^(.-):(%d+)$")
-- Convert the matched number to an actual number type
num = tonumber(num)
-- Return the parts
return str, num
end
function stringutil.construct_filepath_with_num(filepath, num)
-- If num is provided, concatenate it with a colon
if num then return filepath .. ":" .. tostring(num) end
return filepath
end
return stringutil
local tables = {}
function tables.merge(t1, t2)
local result = {}
for k, v in pairs(t1) do
result[k] = v
end
for k, v in pairs(t2) do
result[k] = v
end
return result
end
-- function to get deep clone of passed table
function tables.clone(obj, seen)
-- Handle non-tables and previously-seen tables.
if type(obj) ~= "table" then return obj end
if seen and seen[obj] then return seen[obj] end
-- New table; mark it as seen an copy recursively.
local s = seen or {}
local res = {}
s[obj] = res
for k, v in next, obj do
res[tables.clone(k, s)] = tables.clone(v, s)
end
return setmetatable(res, getmetatable(obj))
end
return tables
local harpoon = require("harpoon")
local tables = require("action.tables")
local stringutil = require("action.stringutil")
local pathutil = require("action.pathutil")
local opts = { noremap = true }
local DEFAULT_LIST = "bookmarks"
local BUFFERS_LIST = "buffers"
harpoon:setup({
settings = {
save_on_toggle = false,
sync_on_ui_close = true,
},
--------------------------------
-- --
-- Bookmarks --
-- --
--------------------------------
bookmarks = {
create_list_item = function(_, input)
local list_entry = ""
if input == nil then
-- Get the current file path and normalize to relative
local root_dir = vim.loop.cwd()
-- Get the current line idx
local idx = vim.fn.line(".")
local bufname = vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf())
local bufname_rel = pathutil.normalize_path(bufname, root_dir)
list_entry = stringutil.construct_filepath_with_num(bufname_rel, idx)
else
-- input is from the window commit
list_entry = input
end
-- if input is not nill ask for comment
if input == nil then
local comment = ""
local symbol_name = ""
local ft = vim.api.nvim_get_option_value("filetype", {})
if ft == "go" then
local fn = require("go.ts.go").get_func_method_node_at_pos()
if not (fn == nil or fn == "") then symbol_name = fn.name end
end
-- Ask for bookmark comment
comment = vim.fn.input("Bookmark comment (press <Enter> to leave empty): ", symbol_name)
if comment ~= "" then list_entry = comment .. " => " .. list_entry end
end
return {
value = list_entry, -- comment => filepath:linenum
context = {},
}
end,
select = function(list_item, _, _)
local bm_comment, bufname_rel, num = stringutil.split_filepath_with_num_and_prefix(list_item.value)
vim.print("Bookmark: " .. (bm_comment ~= "" and bm_comment or "<No comment>"))
-- Get the current buffer ID if the file is already open
local buf_id = vim.fn.bufnr(bufname_rel)
-- If the buffer is not loaded, load it with :badd, which doesn't require saving
if buf_id == -1 then
vim.cmd("badd " .. bufname_rel)
buf_id = vim.fn.bufnr(bufname_rel)
end
-- Navigate to the buffer
vim.cmd("buffer " .. buf_id)
-- Move to the desired line number, if provided
if num then vim.fn.setpos(".", { buf_id, tonumber(num), 0, 0 }) end
end,
},
--------------------------------
-- --
-- Buffer manager --
-- --
--------------------------------
buffers = {
select = function(list_item, _, _)
-- there are no comments in the buffer list, so first argument is empty
local _, bufname_rel, buf_id = stringutil.split_filepath_with_num_and_prefix(list_item.value)
vim.print("Buffer: " .. bufname_rel)
-- Navigate to the buffer
vim.cmd("buffer " .. buf_id)
end,
},
})
--------------------------------
-- --
-- Bookmarks --
-- --
--------------------------------
local bookmark_ui_toggle_opts = {
["title"] = "Bookmarks",
["height_in_lines"] = 20,
}
-- Harpoon a file
vim.keymap.set(
"n",
"<A-a>",
function() harpoon:list(DEFAULT_LIST):add() end,
tables.merge(opts, { desc = "Harpoon add" })
)
-- Toggle Harpoon menu
vim.keymap.set(
"n",
"<leader>m",
function() harpoon.ui:toggle_quick_menu(harpoon:list(DEFAULT_LIST), bookmark_ui_toggle_opts) end,
tables.merge(opts, { desc = "Harpoon toggle" })
)
-- set <leader>h1-9 to navigate to the corresponding file
for i = 1, 9 do
vim.keymap.set(
"n",
"<leader>h" .. i,
function() harpoon:list(DEFAULT_LIST):select(i) end,
tables.merge(opts, { desc = "Harpoon open file number" })
)
end
-- Toggle previous & next buffers stored within Harpoon list
vim.keymap.set(
"n",
"<C-S-O>",
function() harpoon:list(DEFAULT_LIST):prev() end,
tables.merge(opts, { desc = "Harpoon previous" })
)
vim.keymap.set(
"n",
"<C-S-I>",
function() harpoon:list(DEFAULT_LIST):next() end,
tables.merge(opts, { desc = "Harpoon next" })
)
--------------------------------
-- --
-- Buffer manager --
-- --
--------------------------------
local buffer_ui_toggle_opts = {
["title"] = "Buffer Manager",
["height_in_lines"] = 20,
}
-- Open menu and search
-- Alt + m
vim.keymap.set({ "t", "n" }, "<A-m>", function()
harpoon.ui:toggle_quick_menu(harpoon:list(BUFFERS_LIST), buffer_ui_toggle_opts)
-- wait for the menu to open
vim.defer_fn(function() vim.fn.feedkeys("/") end, 50)
end, tables.merge(opts, { desc = "Buffer manager menu search" }))
-- Toggle buffer manager menu
vim.keymap.set(
"n",
"<leader>bm",
function() harpoon.ui:toggle_quick_menu(harpoon:list(BUFFERS_LIST), buffer_ui_toggle_opts) end,
tables.merge(opts, { desc = "Buffer manager toggle" })
)
-- set <leader>h1-9 to navigate to the corresponding file
for i = 1, 9 do
vim.keymap.set(
"n",
"<leader>b" .. i,
function() harpoon:list(BUFFERS_LIST):select(i) end,
tables.merge(opts, { desc = "Buffer manager open file number" })
)
end
-- Toggle previous & next buffers stored within Harpoon list
vim.keymap.set(
"n",
"<A-[>",
function() harpoon:list(BUFFERS_LIST):prev() end,
tables.merge(opts, { desc = "Buffer manager previous" })
)
vim.keymap.set(
"n",
"<A-]>",
function() harpoon:list(BUFFERS_LIST):next() end,
tables.merge(opts, { desc = "Buffer manager next" })
)
-- Keep buffer manager up to date (incl autocmd)
local function add_open_buffers_to_harpoon()
-- List all buffer IDs
local bufs = vim.api.nvim_list_bufs()
local root_dir = vim.loop.cwd()
harpoon:list(BUFFERS_LIST):clear()
for _, buf in ipairs(bufs) do
-- Check if buffer is valid and listed
if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_get_option_value("buflisted", { buf = buf }) then
local bufname = vim.api.nvim_buf_get_name(buf)
local bufname_rel = ""
if bufname and bufname ~= "" then bufname_rel = pathutil.normalize_path(bufname, root_dir) end
-- Using buffer id as buf num
local buf_num = buf
local list_entry = stringutil.construct_filepath_with_num(bufname_rel, buf_num)
-- Refresh list completely
harpoon:list(BUFFERS_LIST):add({
value = list_entry,
context = {},
})
end
end
end
vim.api.nvim_create_autocmd({ "BufAdd", "BufDelete", "BufEnter", "BufLeave", "BufFilePost", "BufNew", "BufNewFile" }, {
pattern = "*",
callback = function() vim.defer_fn(add_open_buffers_to_harpoon, 100) end,
})
-- Make buffer manager writeable
local function update_open_buffers(_)
local harpoon_bufs = harpoon:list(BUFFERS_LIST):display()
local keep = {}
-- Extract buffer numbers from Harpoon entries like ":4" or "path/to/file.go:6"
for _, entry in ipairs(harpoon_bufs) do
-- Match :<number> or <path>:<number>
local buf_num = entry:match("^:%s*(%d+)$") or entry:match(":(%d+)$")
if buf_num then keep[tonumber(buf_num)] = true end
end
-- Go through all buffers and close any not in the keep list
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
local should_keep = keep[buf]
local is_valid = vim.api.nvim_buf_is_valid(buf)
local is_listed = vim.api.nvim_get_option_value("buflisted", { buf = buf })
local is_loaded = vim.api.nvim_buf_is_loaded(buf)
if is_valid and is_listed and is_loaded and not should_keep then vim.api.nvim_buf_delete(buf, { force = true }) end
end
end
harpoon:extend({
REMOVE = update_open_buffers,
LIST_CHANGE = update_open_buffers,
})
-- Sync and load harpoon files to cwd
local function hash_path(path)
local real = vim.fn.resolve(vim.fn.fnamemodify(path, ":p"))
return vim.fn.sha256(real)
end
local function get_harpoon_file()
local dir = vim.fn.stdpath("data") .. "/harpoon"
local file = dir .. "/" .. hash_path(vim.fn.getcwd()) .. ".json"
vim.fn.mkdir(vim.fn.fnamemodify(file, ":h"), "p")
return file
end
local function harpoonSave()
local src_filepath = get_harpoon_file()
local src = io.open(src_filepath, "r")
if not src then
vim.notify("Cannot open harpoon file: " .. src_filepath, vim.log.levels.ERROR)
return
end
local content = src:read("*a") -- read entire file
src:close()
local dest_filepath = vim.loop.cwd() .. "/.harpoon.json"
local dest = io.open(dest_filepath, "w")
if not dest then
vim.notify("Cannot open harpoon file: " .. dest_filepath, vim.log.levels.ERROR)
return
end
dest:write(content) -- read entire file
dest:close()
vim.notify("Harpoon saved: " .. dest_filepath)
end
local function harpoonLoad()
local filepath = vim.loop.cwd() .. "/.harpoon.json"
local file = io.open(filepath, "r")
if not file then
vim.notify("Cannot open harpoon file: " .. filepath, vim.log.levels.ERROR)
return
end
local content = file:read("*a") -- read entire file
file:close()
local parsed = vim.json.decode(content)
if not parsed then
vim.notify("Failed to decode harpoon JSON", vim.log.levels.ERROR)
return
end
local project = nil
for key, value in pairs(parsed) do
if key ~= "bookmarks" then
project = value
break
end
end
if not project then
vim.notify("No valid project entry found in harpoon file", vim.log.levels.WARN)
return
end
harpoon:list(DEFAULT_LIST):clear()
local entries = project[DEFAULT_LIST] or {}
for _, encoded in ipairs(entries) do
local decoded = vim.json.decode(encoded)
if decoded and decoded.value then
harpoon:list(DEFAULT_LIST):add({
value = decoded.value,
context = decoded.context or {},
})
end
end
vim.notify("Harpoon bookmarks and buffers loaded.")
end
vim.api.nvim_create_user_command("HarpoonSave", function() harpoonSave() end, {})
vim.api.nvim_create_user_command("HarpoonLoad", function() harpoonLoad() end, {})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment