From b8e0dee9531ccf9afe6ccbbfdb849868f6e83a4a Mon Sep 17 00:00:00 2001 From: Kacper Marzecki Date: Tue, 4 Aug 2026 18:57:16 +0200 Subject: [PATCH] branch todos --- nvim/lua/config/keymaps.lua | 219 ++++++++++++++++++++++++++++++++++++ nvim/lua/plugins/lsp.lua | 5 + 2 files changed, 224 insertions(+) diff --git a/nvim/lua/config/keymaps.lua b/nvim/lua/config/keymaps.lua index c96db8f..75f9d94 100644 --- a/nvim/lua/config/keymaps.lua +++ b/nvim/lua/config/keymaps.lua @@ -143,6 +143,225 @@ end -- Keybind for inserting TODO comment vim.api.nvim_set_keymap("n", "t", "lua insert_todo_comment()", { noremap = true, silent = true }) +local function git_output(args) + local output = vim.fn.system(args) + if vim.v.shell_error ~= 0 then + return nil + end + return vim.split(output, "\n", { trimempty = true }) +end + +local function branch_todos_base() + if vim.g.branch_todos_base then + return vim.g.branch_todos_base + end + + for _, ref in ipairs({ "origin/main", "origin/master", "main", "master" }) do + if git_output({ "git", "rev-parse", "--verify", ref }) then + return ref + end + end +end + +local function branch_todos_entries() + local base = branch_todos_base() + if not base then + return { "No git base found. Set vim.g.branch_todos_base." } + end + + local merge_base = git_output({ "git", "merge-base", base, "HEAD" }) + if not merge_base or not merge_base[1] then + return { "Could not find merge-base against " .. base .. "." } + end + + local lines = git_output({ "git", "diff", "--unified=0", merge_base[1] }) + if not lines then + return { "Could not read git diff against " .. base .. "." } + end + + local added_lines = {} + local file + local new_line + local todo_keywords = { "TODO", "FIXME", "HACK", "XXX", "BUG", "BUGS" } + + for _, line in ipairs(lines) do + local diff_file = line:match("^%+%+%+ b/(.+)$") + if diff_file then + file = diff_file + new_line = nil + else + local hunk_start = line:match("^@@ %-%d+,?%d* %+(%d+),?%d* @@") + if hunk_start then + new_line = tonumber(hunk_start) + elseif new_line and line:sub(1, 1) == "+" and not line:match("^%+%+%+") then + local text = line:sub(2) + if file then + table.insert(added_lines, { file = file, line = new_line, text = text }) + end + new_line = new_line + 1 + elseif new_line and line:sub(1, 1) ~= "-" then + new_line = new_line + 1 + end + end + end + + local function has_todo_keyword(text) + local upper_text = text:upper() + for _, keyword in ipairs(todo_keywords) do + local start = 1 + while true do + local match_start, match_end = upper_text:find(keyword, start, true) + if not match_start then + break + end + + local before = match_start > 1 and upper_text:sub(match_start - 1, match_start - 1) or "" + local after = match_end < #upper_text and upper_text:sub(match_end + 1, match_end + 1) or "" + if not before:match("[%w_]") and not after:match("[%w_]") then + return true + end + + start = match_end + 1 + end + end + return false + end + + local function list_depth_delta(text) + local _, opens = text:gsub("%[", "") + local _, closes = text:gsub("%]", "") + return opens - closes + end + + local entries = {} + local index = 1 + while index <= #added_lines do + local item = added_lines[index] + if has_todo_keyword(item.text) then + table.insert(entries, string.format("%s:%d", item.file, item.line)) + table.insert(entries, item.text) + + if item.text:lower():match('todo:%s*"""') and not item.text:match('""".*"""') then + index = index + 1 + while index <= #added_lines do + local next_item = added_lines[index] + if next_item.file ~= item.file then + break + end + table.insert(entries, next_item.text) + if next_item.text:find('"""', 1, true) then + break + end + index = index + 1 + end + elseif item.text:match("[=:]%s*%[") and list_depth_delta(item.text) > 0 then + local depth = list_depth_delta(item.text) + index = index + 1 + while index <= #added_lines do + local next_item = added_lines[index] + if next_item.file ~= item.file then + break + end + + table.insert(entries, next_item.text) + depth = depth + list_depth_delta(next_item.text) + if depth <= 0 then + break + end + + index = index + 1 + end + elseif item.text:match("^%s*#") then + index = index + 1 + while index <= #added_lines do + local next_item = added_lines[index] + if next_item.file ~= item.file or not next_item.text:match("^%s*#") then + break + end + table.insert(entries, next_item.text) + index = index + 1 + end + index = index - 1 + end + + table.insert(entries, "") + end + + index = index + 1 + end + + if #entries == 0 then + return { "No TODOs added on this branch against " .. base .. "." } + end + + return entries +end + +local function jump_to_branch_todo() + local line = vim.api.nvim_get_current_line() + local file, linenum = line:match("([^:%s]+):(%d+)") + if not file then + local cursor = vim.api.nvim_win_get_cursor(0) + for row = cursor[1] - 1, 1, -1 do + local previous_line = vim.api.nvim_buf_get_lines(0, row - 1, row, false)[1] or "" + file, linenum = previous_line:match("([^:%s]+):(%d+)") + if file then + break + end + end + end + + if file and linenum then + local current_win = vim.api.nvim_get_current_win() + vim.cmd("wincmd h") + if current_win == vim.api.nvim_get_current_win() then + vim.api.nvim_set_current_win(current_win) + end + + vim.cmd("edit " .. vim.fn.fnameescape(file)) + vim.api.nvim_win_set_cursor(0, { tonumber(linenum), 0 }) + if vim.api.nvim_win_is_valid(current_win) and current_win ~= vim.api.nvim_get_current_win() then + vim.api.nvim_set_current_win(current_win) + end + end +end + +local function refresh_branch_todos(buf) + local cursor = vim.api.nvim_win_get_cursor(0) + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, 0, -1, false, branch_todos_entries()) + vim.bo[buf].modifiable = false + + local line_count = vim.api.nvim_buf_line_count(buf) + vim.api.nvim_win_set_cursor(0, { math.min(cursor[1], line_count), cursor[2] }) +end + +local function open_branch_todos() + local name = "branch-todos" + local buf = vim.fn.bufnr(name) + if buf == -1 then + buf = vim.api.nvim_create_buf(true, false) + vim.api.nvim_buf_set_name(buf, name) + end + + vim.api.nvim_set_current_buf(buf) + vim.bo[buf].buftype = "nofile" + vim.bo[buf].bufhidden = "hide" + vim.bo[buf].swapfile = false + vim.bo[buf].filetype = "branch-todos" + + refresh_branch_todos(buf) + + vim.keymap.set("n", "", jump_to_branch_todo, { buffer = buf, desc = "Jump to branch TODO" }) + vim.keymap.set("n", "r", function() + refresh_branch_todos(buf) + end, { buffer = buf, desc = "Refresh branch TODOs" }) + vim.keymap.set("n", "q", "bdelete", { buffer = buf, desc = "Close branch TODOs" }) +end + +vim.api.nvim_create_user_command("BranchTodos", open_branch_todos, {}) +vim.keymap.set("n", "xb", open_branch_todos, { desc = "Branch TODOs" }) + -- floating NOTES function open_notes() local notes_file = vim.env.HOME .. "/git/notes/todo.md" -- Path to your notes file diff --git a/nvim/lua/plugins/lsp.lua b/nvim/lua/plugins/lsp.lua index 08f00d2..38968e1 100644 --- a/nvim/lua/plugins/lsp.lua +++ b/nvim/lua/plugins/lsp.lua @@ -130,6 +130,11 @@ vim.api.nvim_create_autocmd("FileType", { return end + local rg = vim.system({ "rg", "-q", "--glob", "*.ex", "use ExOf", root }):wait() + if rg.code ~= 0 then + return + end + vim.lsp.start({ name = "exofx-lsp", cmd_env = { MIX_ENV = "test" },