if vim.env.NOTES_DIR == nil then vim.notify("NOTES_DIR is not set", vim.log.levels.ERROR) 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.api.nvim_create_user_command("NotesFindTags", function() require("fzf-lua").tags({ ctags_file = tagfile, cwd = notes_dir, winopts = { preview = { layout = "vertical" } }, fzf_opts = { ["--delimiter"] = "[\t]", ["--with-nth"] = "{2}\t{1}", ["--tabstop"] = "48" }, }) end, { desc = "Fuzzy-find notes (tag,name,section)" }) vim.api.nvim_create_user_command("NotesFindFile", function() require("fzf-lua").files({ cwd = notes_dir }) end, { desc = "Fuzzy-find notes (name)" }) vim.api.nvim_create_user_command("NotesFindGrep", function() require("fzf-lua").live_grep({ cwd = notes_dir }) end, { desc = "Fuzzy-find notes (content)" }) vim.keymap.set("n", "nn", vim.cmd.NotesFindTags, { desc = "Fuzzy-find notes" }) vim.keymap.set("n", "nf", vim.cmd.NotesFindFile, { desc = "Fuzzy-find notes file name" }) vim.keymap.set("n", "ng", vim.cmd.NotesFindGrep, { 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" }) ------------------------------------------------------------------------------------------------------------------------ -- Following notes (`notes://` scheme and `[[wiki-links]]`), wired into the follow engine ------------------------------------------------------------------------------------------------------------------------ local follow = require("dotfiles.follow") -- `notes://` resolves to `` relative to the notes dir. follow.register_scheme("notes", { resolve = function(uri) return notes_dir .. "/" .. uri 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 = 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 -- Wiki-links take precedence over generic URL/file following, but only on a -- `[[...]]` under the cursor in a markdown buffer in normal mode. follow.register_handler(function(edit_cmd) if vim.fn.mode() ~= "n" or vim.bo.filetype ~= "markdown" then return false end local inner = get_wikilink_target() if inner == nil then return false end follow_wikilink(inner, edit_cmd) return true end)