summaryrefslogtreecommitdiffstats
path: root/.config
diff options
context:
space:
mode:
authorThomas Vanbesien <tvanbesi@proton.me>2026-06-07 19:37:41 +0200
committerThomas Vanbesien <tvanbesi@proton.me>2026-06-07 19:37:41 +0200
commita593c25d65b8806a31f0da994baddd35a8af5d44 (patch)
tree8f98397e08160d4a1bc8453d57a92de33e1d9d6b /.config
parent4f0d9413e6b6eeb7a6f9c56cc18ec63d8c085c93 (diff)
downloaddotfiles-a593c25d65b8806a31f0da994baddd35a8af5d44.tar.gz
dotfiles-a593c25d65b8806a31f0da994baddd35a8af5d44.zip
feat(nvim): add notes plugin with wiki-links
Index headings with ctags for native :tag and an fzf-lua picker (<Leader>nn), plus pickers for note filenames (<Leader>nf) and content (<Leader>ng). Complete [[wiki-links]] and their #headings, and follow them via <Leader>gg, creating missing notes and directories. Add :NotesRename and :NotesRenameSection, which rewrite every matching link across all notes.
Diffstat (limited to '.config')
-rw-r--r--.config/nvim/plugin/50-follow.lua63
-rw-r--r--.config/nvim/plugin/50-notes.lua322
2 files changed, 382 insertions, 3 deletions
diff --git a/.config/nvim/plugin/50-follow.lua b/.config/nvim/plugin/50-follow.lua
index 2999b12..6784ad6 100644
--- a/.config/nvim/plugin/50-follow.lua
+++ b/.config/nvim/plugin/50-follow.lua
@@ -115,12 +115,69 @@ local function edit_target(target, edit_cmd)
end
end
+-- Returns the inner text of a `[[wiki-link]]` under the cursor, or `nil`.
+local function get_wikilink_target()
+ local line = vim.api.nvim_get_current_line()
+ local col = vim.api.nvim_win_get_cursor(0)[2] + 1
+ local init = 1
+ while true do
+ local s, e, inner = line:find("%[%[(.-)%]%]", init)
+ if s == nil then
+ return nil
+ end
+ if col >= s and col <= e then
+ return inner
+ end
+ init = e + 1
+ end
+end
+
+-- Follow a `[[note]]` / `[[note#Heading]]` / `[[dir/]]` wiki-link relative to the
+-- notes dir, creating parent directories (and the note itself on save) as needed.
+local function follow_wikilink(inner, edit_cmd)
+ local name, heading = inner:match("^(.-)#(.*)$")
+ if name == nil then
+ name = inner
+ end
+ local target = vim.g.notes_dir .. "/" .. name
+ if name:sub(-1) == "/" then -- directory link: create and open it
+ vim.fn.mkdir(target, "p")
+ vim.cmd(edit_cmd .. " " .. vim.fn.fnameescape(target))
+ return
+ end
+ if not name:match("%.%w+$") then -- default to a Markdown note
+ target = target .. ".md"
+ end
+ vim.fn.mkdir(vim.fs.dirname(target), "p")
+ vim.cmd(edit_cmd .. " " .. vim.fn.fnameescape(target))
+ if heading ~= nil and heading ~= "" then
+ vim.fn.cursor(1, 1)
+ -- `\V` matches the heading literally; `\v` brackets the heading markers.
+ local pat = [[\v^#+\s+\V]] .. vim.fn.escape(heading, [[\]]) .. [[\v\s*$]]
+ if vim.fn.search(pat, "cW") == 0 then
+ vim.notify("No heading '" .. heading .. "' in " .. name, vim.log.levels.WARN)
+ end
+ end
+end
+
+-- Follow a wiki-link if the cursor is on one, else fall back to URL/file following.
+local function follow(edit_cmd)
+ if vim.fn.mode() == "n" and vim.bo.filetype == "markdown" then
+ local inner = get_wikilink_target()
+ if inner ~= nil then
+ follow_wikilink(inner, edit_cmd)
+ return
+ end
+ end
+ edit_target(get_target(), edit_cmd)
+end
+
vim.keymap.set({ "n", "x" }, "<Leader>gg", function()
- edit_target(get_target(), "edit")
+ follow("edit")
end, { desc = "Edit URL/file in current window" })
vim.keymap.set({ "n", "x" }, "<Leader>gs", function()
- edit_target(get_target(), "split")
+ follow("split")
end, { desc = "Edit URL/file in split window" })
vim.keymap.set({ "n", "x" }, "<Leader>gv", function()
- edit_target(get_target(), "vsplit")
+ follow("vsplit")
end, { desc = "Edit URL/file in vertically split window" })
diff --git a/.config/nvim/plugin/50-notes.lua b/.config/nvim/plugin/50-notes.lua
index 9ef0fc3..dee4da3 100644
--- a/.config/nvim/plugin/50-notes.lua
+++ b/.config/nvim/plugin/50-notes.lua
@@ -3,3 +3,325 @@ if vim.env.NOTES_DIR == nil then
return
end
vim.g.notes_dir = vim.env.NOTES_DIR:sub(-1) == "/" and vim.env.NOTES_DIR:sub(1, -2) or vim.env.NOTES_DIR
+
+local notes_dir = vim.g.notes_dir
+local tagfile = vim.fs.joinpath(vim.fn.stdpath("state"), "notes-tags")
+
+local function generate_tags()
+ vim.system({ "ctags", "--languages=Markdown", "--tag-relative=no", "--recurse", "-f", tagfile, notes_dir })
+end
+
+-- Completion of all `*.md` note names (relative to the notes dir, extension stripped).
+local function note_candidates()
+ local out = {}
+ local files = vim.fs.find(function(name)
+ return name:match("%.md$")
+ end, { path = notes_dir, type = "file", limit = math.huge })
+ for _, f in ipairs(files) do
+ out[#out + 1] = f:sub(#notes_dir + 2):gsub("%.md$", "")
+ end
+ return out
+end
+
+-- Headings of a note, read from the ctags tagfile so `#` comments inside fenced
+-- code blocks aren't mistaken for headings (hashtag `h` / footnote `n` kinds excluded).
+local function heading_candidates(note)
+ local out = {}
+ local target = notes_dir .. "/" .. note .. ".md"
+ local fd = io.open(tagfile, "r")
+ if fd == nil then
+ return out
+ end
+ for line in fd:lines() do
+ if line:sub(1, 1) ~= "!" then
+ local name, file, _, kind = line:match('^(.-)\t(.-)\t(.-);"\t(%a)')
+ if file == target and kind ~= "h" and kind ~= "n" then
+ out[#out + 1] = name
+ end
+ end
+ end
+ fd:close()
+ return out
+end
+
+-- 'omnifunc' for wiki-links: inside `[[`, complete note names; after `#`, complete
+-- the target note's headings. Wired per notes buffer in the autocmd below.
+function _G.dotfiles_wikilink_source(findstart, base)
+ local line = vim.api.nvim_get_current_line()
+ local col = vim.api.nvim_win_get_cursor(0)[2]
+ local before = line:sub(1, col)
+
+ -- Locate the innermost still-open `[[` to the left of the cursor.
+ local open, i = nil, 1
+ while true do
+ local s = before:find("%[%[", i)
+ if s == nil then
+ break
+ end
+ open, i = s, s + 2
+ end
+ local inner = open and before:sub(open + 2)
+ if inner == nil or inner:find("%]") then -- not inside an open `[[ ... ]]`
+ return findstart == 1 and -3 or {}
+ end
+
+ local hash = inner:find("#")
+ if findstart == 1 then
+ -- 0-based byte column where the completed text starts (after `[[` or after `#`).
+ return hash and (open + 1 + hash) or (open + 1)
+ end
+
+ local candidates = hash and heading_candidates(inner:sub(1, hash - 1)) or note_candidates()
+ if base ~= "" then
+ candidates = vim.fn.matchfuzzy(candidates, base)
+ end
+ return candidates
+end
+
+vim.api.nvim_create_autocmd({ "BufReadPost", "BufNewFile" }, {
+ desc = "Set up notes buffer (tagfile, wiki-link completion)",
+ group = vim.g.dotfiles.augroup,
+ pattern = notes_dir .. "/*",
+ callback = function()
+ if not vim.tbl_contains(vim.opt_local.tags:get(), tagfile) then
+ vim.opt_local.tags:append(tagfile)
+ end
+ vim.bo.omnifunc = "v:lua.dotfiles_wikilink_source"
+ end,
+})
+
+vim.api.nvim_create_autocmd("BufWritePost", {
+ desc = "Regenerate notes ctags",
+ group = vim.g.dotfiles.augroup,
+ pattern = notes_dir .. "/*",
+ callback = generate_tags,
+})
+
+generate_tags()
+
+vim.keymap.set("n", "<Leader>nn", function()
+ require("fzf-lua").tags({ ctags_file = tagfile, cwd = notes_dir })
+end, { desc = "Fuzzy-find notes" })
+
+vim.keymap.set("n", "<Leader>nf", function()
+ require("fzf-lua").files({ cwd = notes_dir })
+end, { desc = "Fuzzy-find notes file name" })
+
+vim.keymap.set("n", "<Leader>ng", function()
+ require("fzf-lua").live_grep({ cwd = notes_dir })
+end, { desc = "Fuzzy-find notes content" })
+
+-- A note's path relative to the notes dir, without `.md`, or nil if not a note.
+local function note_name(path)
+ local prefix = vim.fs.normalize(notes_dir) .. "/"
+ path = vim.fs.normalize(path)
+ if not vim.startswith(path, prefix) then
+ return nil
+ end
+ return (path:sub(#prefix + 1):gsub("%.md$", ""))
+end
+
+-- Rewrite `[[oldname]]` / `[[oldname#heading]]` targets to `newname`, matching the
+-- target exactly so `[[oldname_extra]]` is left alone. The gsub replacement is a
+-- function, so its return value is used literally (no `%` escaping needed).
+-- Returns the new text and the number of links rewritten.
+local function rewrite_links(text, oldname, newname)
+ local count = 0
+ local out = text:gsub("%[%[(.-)%]%]", function(inner)
+ local target, rest = inner:match("^([^#]*)(.*)$")
+ if target == oldname then
+ count = count + 1
+ return "[[" .. newname .. rest .. "]]"
+ end
+ end)
+ return out, count
+end
+
+-- Apply `rewriter(text) -> new_text, n` to every note, editing loaded buffers in
+-- place (preserving unsaved changes) and others on disk. Returns total links and files.
+local function update_all_notes(rewriter)
+ local loaded = {}
+ for _, b in ipairs(vim.api.nvim_list_bufs()) do
+ if vim.api.nvim_buf_is_loaded(b) then
+ loaded[vim.fs.normalize(vim.api.nvim_buf_get_name(b))] = b
+ end
+ end
+ local files = vim.fs.find(function(name)
+ return name:match("%.md$")
+ end, { path = notes_dir, type = "file", limit = math.huge })
+
+ local link_count, file_count = 0, 0
+ for _, file in ipairs(files) do
+ local b = loaded[vim.fs.normalize(file)]
+ local n = 0
+ if b then
+ local was_modified = vim.bo[b].modified
+ local text = table.concat(vim.api.nvim_buf_get_lines(b, 0, -1, false), "\n")
+ local new_text
+ new_text, n = rewriter(text)
+ if n > 0 then
+ vim.api.nvim_buf_set_lines(b, 0, -1, false, vim.split(new_text, "\n", { plain = true }))
+ if not was_modified then -- buffer matched disk; keep it that way
+ vim.api.nvim_buf_call(b, function()
+ vim.cmd("silent! write!")
+ end)
+ end
+ end
+ else
+ local fd = io.open(file, "r")
+ if fd then
+ local content = fd:read("*a")
+ fd:close()
+ local new_content
+ new_content, n = rewriter(content)
+ if n > 0 then
+ local wf = io.open(file, "w")
+ if wf then
+ wf:write(new_content)
+ wf:close()
+ end
+ end
+ end
+ end
+ if n > 0 then
+ link_count = link_count + n
+ file_count = file_count + 1
+ end
+ end
+ return link_count, file_count
+end
+
+-- Rename the current note to `new_rel` (relative to the notes dir, `.md` optional)
+-- and update every wiki-link pointing to it across all notes.
+local function rename_current_note(new_rel)
+ local buf = vim.api.nvim_get_current_buf()
+ local oldname = note_name(vim.api.nvim_buf_get_name(buf))
+ if oldname == nil then
+ vim.notify("Current buffer is not a note", vim.log.levels.ERROR)
+ return
+ end
+ new_rel = (new_rel or ""):gsub("%.md$", "")
+ if new_rel == "" then
+ return
+ end
+
+ local old_path = notes_dir .. "/" .. oldname .. ".md"
+ local new_path = notes_dir .. "/" .. new_rel .. ".md"
+ if vim.uv.fs_stat(new_path) then
+ vim.notify("Target already exists: " .. new_rel, vim.log.levels.ERROR)
+ return
+ end
+
+ if vim.bo[buf].modified then
+ vim.cmd.write()
+ end
+
+ -- Move the file on disk, then repoint the current buffer at it.
+ vim.fn.mkdir(vim.fs.dirname(new_path), "p")
+ local ok, err = vim.uv.fs_rename(old_path, new_path)
+ if not ok then
+ vim.notify("Rename failed: " .. tostring(err), vim.log.levels.ERROR)
+ return
+ end
+ vim.api.nvim_buf_set_name(buf, new_path)
+ vim.cmd("keepalt write!")
+ -- Renaming a buffer can leave a stale empty buffer under the old name.
+ for _, b in ipairs(vim.api.nvim_list_bufs()) do
+ if b ~= buf and vim.api.nvim_buf_get_name(b) == old_path then
+ pcall(vim.api.nvim_buf_delete, b, { force = true })
+ end
+ end
+
+ local link_count, file_count = update_all_notes(function(text)
+ return rewrite_links(text, oldname, new_rel)
+ end)
+
+ generate_tags()
+ vim.notify(string.format("Renamed to %s — updated %d link(s) in %d file(s)", new_rel, link_count, file_count))
+end
+
+vim.api.nvim_create_user_command("NotesRename", function(opts)
+ if opts.args ~= "" then
+ rename_current_note(opts.args)
+ else
+ vim.ui.input({ prompt = "Rename note to: ", default = note_name(vim.api.nvim_buf_get_name(0)) }, function(input)
+ if input and input ~= "" then
+ rename_current_note(input)
+ end
+ end)
+ end
+end, { nargs = "?", desc = "Rename current note and update wiki-links" })
+
+-- Rewrite `[[note#oldhead]]` heading anchors to `newhead` (only for this note's
+-- own headings; links to other notes' identically-named headings are untouched).
+local function rewrite_heading_links(text, note, oldhead, newhead)
+ local count = 0
+ local out = text:gsub("%[%[(.-)%]%]", function(inner)
+ local target, head = inner:match("^([^#]*)#(.*)$")
+ if target == note and head == oldhead then
+ count = count + 1
+ return "[[" .. target .. "#" .. newhead .. "]]"
+ end
+ end)
+ return out, count
+end
+
+-- The markdown heading at or above the cursor as `{ row, level, text }` (row is
+-- 1-based), or nil. Returning one value keeps the fields narrowed together.
+local function heading_at_cursor(buf)
+ local row = vim.api.nvim_win_get_cursor(0)[1]
+ local lines = vim.api.nvim_buf_get_lines(buf, 0, row, false)
+ for r = #lines, 1, -1 do
+ local hashes, text = lines[r]:match("^(#+)%s+(.-)%s*$")
+ if hashes then
+ return { row = r, level = #hashes, text = text }
+ end
+ end
+ return nil
+end
+
+-- Rename the heading at the cursor to `new_head` and update wiki-links to it.
+local function rename_current_section(new_head)
+ local buf = vim.api.nvim_get_current_buf()
+ local note = note_name(vim.api.nvim_buf_get_name(buf))
+ if note == nil then
+ vim.notify("Current buffer is not a note", vim.log.levels.ERROR)
+ return
+ end
+ local heading = heading_at_cursor(buf)
+ if heading == nil then
+ vim.notify("No heading at or above the cursor", vim.log.levels.ERROR)
+ return
+ end
+ new_head = vim.trim(new_head or "")
+ if new_head == "" or new_head == heading.text then
+ return
+ end
+
+ -- Rewrite the heading line itself, then persist before touching links.
+ local line = string.rep("#", heading.level) .. " " .. new_head
+ vim.api.nvim_buf_set_lines(buf, heading.row - 1, heading.row, false, { line })
+ vim.cmd("write")
+
+ local link_count, file_count = update_all_notes(function(text)
+ return rewrite_heading_links(text, note, heading.text, new_head)
+ end)
+
+ generate_tags()
+ vim.notify(
+ string.format("Renamed section to %s — updated %d link(s) in %d file(s)", new_head, link_count, file_count)
+ )
+end
+
+vim.api.nvim_create_user_command("NotesRenameSection", function(opts)
+ if opts.args ~= "" then
+ rename_current_section(opts.args)
+ else
+ local heading = heading_at_cursor(vim.api.nvim_get_current_buf())
+ vim.ui.input({ prompt = "Rename section to: ", default = heading and heading.text }, function(input)
+ if input and input ~= "" then
+ rename_current_section(input)
+ end
+ end)
+ end
+end, { nargs = "*", desc = "Rename the heading at the cursor and update wiki-links" })