-- -- 50-follow.lua -- -- Wires the follow engine (lua/dotfiles/follow.lua): defines the followable entities and the -- `notes://` / `nvim-help://` schemes, registers them all (in the order they are tried, first match -- wins), then maps the gf-family keys to "open the entity under the cursor" and `]u` / `[u` -- to cycle between entities. -- -- Keymaps: -- `gf` follow the entity under the cursor in the current window -- `f` / `` ... in a split -- `gf` ... in a new tab -- `]u` / `[u` go to the next / previous followable entity -- -- `notes_dir` may be nil ($NOTES_DIR unset); only the wiki-link entity and the `notes://` scheme -- need it, so the rest of the engine (markdown links, URLs, , `nvim-help://`) wires up -- regardless. local notes_dir = require("dotfiles.notes").dir local df = require("dotfiles.follow") ---------------------------------------------------------------------------------------------------- -- Wiki-links -------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- -- All `[[wiki-link]]` matches in the buffer (markdown only), targeting the inner text. local function wikilink_matches() if vim.bo.filetype ~= "markdown" then return {} end local out = {} for i, line in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, false)) do local init = 1 while true do local s, e, inner = line:find("%[%[(.-)%]%]", init) if s == nil then break end out[#out + 1] = { from = { i, s - 1 }, to = { i, e }, target = inner } init = e + 1 end end return out 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 open_wikilink(edit_cmd, inner) 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 ---------------------------------------------------------------------------------------------------- -- Markdown links ---------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- -- Resolve a reference link `label` to its destination by scanning the buffer for its -- `[label]: dest` definition (case-insensitive, per CommonMark). Returns the destination, or nil -- if undefined. local function resolve_link_label(label) -- Escape Lua pattern magic chars so the label is matched literally. local escaped = label:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1") -- Reference labels are case-insensitive, so match each letter against either case. local insensitive = escaped:gsub("%a", function(c) return "[" .. c:upper() .. c:lower() .. "]" end) local label_pattern = "^%[" .. insensitive .. "%]: (.*)" for _, line in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, true)) do local match = line:match(label_pattern) if match ~= nil then return match end end return nil end -- The Markdown link under the cursor (open-only), targeting its destination. Resolves reference -- labels to their destination. Returns silently when the cursor is not on a link. local function markdown_link_matches() if vim.o.filetype ~= "markdown" or vim.treesitter.get_parser(0) == nil then return {} end -- `ignore_injections = false` because `link_destination`/`link_label` are injected by -- markdown_inline. local node = vim.treesitter.get_node({ ignore_injections = false }) if node == nil then return {} end local function is_link(type) return type == "full_reference_link" or type == "inline_link" or type == "shortcut_link" end local link_node if is_link(node:type()) then link_node = node elseif node:parent() ~= nil and is_link(node:parent():type()) then link_node = node:parent() end if link_node == nil then return {} end local dest for child in link_node:iter_children() do local text = vim.treesitter.get_node_text(child, 0) if child:type() == "link_destination" then dest = text break elseif child:type() == "link_label" or (link_node:type() == "shortcut_link" and child:type() == "link_text") then -- shortcut_link text doesn't include the `[]` unlike link_label if link_node:type() ~= "shortcut_link" then text = text:sub(2, -2) end dest = resolve_link_label(text) break end end if dest == nil then return {} end -- Cursor-derived match: anchor `from` at the cursor so it is open-only (never a `next` stop). local c = vim.api.nvim_win_get_cursor(0) return { { from = { c[1], c[2] }, to = { c[1], c[2] + 1 }, target = dest } } end ---------------------------------------------------------------------------------------------------- -- Autolinks --------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- --[[ All CommonMark autolinks `` in the buffer, targeting the inner URL with the angle brackets stripped. Registered before the bare-URL entity so a bracketed link wins: the looser URL scan would otherwise swallow the closing `>` and trailing punctuation into the target. The match spans the brackets so the cursor follows from `<` through `>`. `open_target` percent-decodes the URI, so the inner text may be percent-encoded (e.g. ``). ]] local function autolink_matches() local out = {} for i, line in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, false)) do local init = 1 while true do local s, e, inner = line:find("<(%a[%w+.-]*://[^>%s]*)>", init) if s == nil then break end out[#out + 1] = { from = { i, s - 1 }, to = { i, e }, target = inner } init = e + 1 end end return out end ---------------------------------------------------------------------------------------------------- -- URLs -------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- -- All `scheme://…` matches in the buffer. local function url_matches() local out = {} for i, line in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, false)) do local init = 1 while true do local s, e = line:find("%a[%w+.-]*://%S+", init) if s == nil then break end -- A `` autolink is matched by the autolink entity; skip it here. local is_autolink = s > 1 and line:sub(s - 1, s - 1) == "<" and line:find("^[^>%s]*>", s) ~= nil if not is_autolink then out[#out + 1] = { from = { i, s - 1 }, to = { i, e }, target = line:sub(s, e) } end init = e + 1 end end return out end ---------------------------------------------------------------------------------------------------- -- fallback -------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- -- A single open-only match at the cursor when there is a `` under it. local function cfile_matches() if vim.fn.expand("") == "" then return {} end local c = vim.api.nvim_win_get_cursor(0) return { { from = { c[1], c[2] }, to = { c[1], c[2] + 1 }, target = true } } end -- Fall back to Vim's built-in gf-family (so 'path', 'suffixesadd', … apply); `feedkeys` with `n` -- bypasses our own mappings to avoid recursion. local builtin_gf = { edit = vim.keycode("gf"), split = vim.keycode("f"), tabedit = vim.keycode("gf"), } local function open_cfile(edit_cmd, _) vim.api.nvim_feedkeys(builtin_gf[edit_cmd], "nx", false) end ---------------------------------------------------------------------------------------------------- -- Schemes ----------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- -- `notes://` resolves to `` relative to the notes dir. local notes_scheme = { resolve = function(uri) return notes_dir .. "/" .. uri end, } -- `nvim-help://` — open Neovim help for in a scratch, read-only buffer. local nvim_help_scheme = { resolve = function(uri) local tagfiles = {} for _, path in pairs(vim.opt.runtimepath:get()) do tagfiles[#tagfiles + 1] = path .. "/doc/tags" end vim.opt_local.tags = tagfiles local matches = vim.fn.taglist(uri) if #matches == 0 then return nil, "No help page found for nvim-help://" .. uri end return matches[1].filename end, after = function() vim.opt_local.bufhidden = "wipe" vim.opt_local.buftype = "nofile" vim.opt_local.swapfile = false vim.opt_local.readonly = true end, } ---------------------------------------------------------------------------------------------------- -- Registration ------------------------------------------------------------------------------------ ---------------------------------------------------------------------------------------------------- -- Entities are tried in registration order, first match wins; `` registers last so `gf` -- falls back to Vim's built-in behavior. The wiki-link entity and `notes://` scheme need -- $NOTES_DIR. if notes_dir ~= nil then df.register("wikilink", wikilink_matches, open_wikilink) df.register_scheme("notes", notes_scheme) else vim.notify( "50-follow.lua: NOTES_DIR is not set, wiki-link following disabled", vim.log.levels.WARN ) end df.register("markdown-link", markdown_link_matches, df.open) df.register("autolink", autolink_matches, df.open) df.register("url", url_matches, df.open) df.register("cfile", cfile_matches, open_cfile) df.register_scheme("nvim-help", nvim_help_scheme) ---------------------------------------------------------------------------------------------------- -- Keymaps ----------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- local function opener(edit_cmd) return function() df.open(edit_cmd) end end vim.keymap.set({ "n", "x" }, "gf", opener("edit"), { desc = "Follow entity under cursor" }) vim.keymap.set({ "n", "x" }, "f", opener("split"), { desc = "Follow entity in a split" }) vim.keymap.set({ "n", "x" }, "", opener("split"), { desc = "Follow entity in a split" }) vim.keymap.set({ "n", "x" }, "gf", opener("tabedit"), { desc = "Follow entity in a new tab" }) vim.keymap.set("n", "]u", function() df.next(false) end, { desc = "Go to next followable entity" }) vim.keymap.set("n", "[u", function() df.next(true) end, { desc = "Go to previous followable entity" })