diff options
Diffstat (limited to '.config/nvim')
| -rw-r--r-- | .config/nvim/plugin/50-follow.lua | 63 | ||||
| -rw-r--r-- | .config/nvim/plugin/50-notes.lua | 322 |
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" }) |
