diff options
| author | Thomas Vanbesien <tvanbesi@proton.me> | 2026-06-24 16:54:10 +0200 |
|---|---|---|
| committer | Thomas Vanbesien <tvanbesi@proton.me> | 2026-06-24 17:02:52 +0200 |
| commit | 42233850cef2e1b30dc6524e056f604c3b5d021b (patch) | |
| tree | 142a706725ebafa20a30603ee6eca546ddb84723 /plugin | |
| parent | 6d35e81f1bfdae7da7e98eea850e5f6c7741ece8 (diff) | |
| download | nvim-config-42233850cef2e1b30dc6524e056f604c3b5d021b.tar.gz nvim-config-42233850cef2e1b30dc6524e056f604c3b5d021b.zip | |
refactor(nvim): rewrite goto plugin (now called follow)
`lua/dotfiles/follow.lua` provides an engine to register "followable
entities" like URL, wiki-links etc and can be easily expanded.
The engine provides a function to navigate between followable entities
and a function to edit them inside neovim.
Diffstat (limited to 'plugin')
| -rw-r--r-- | plugin/50-follow.lua | 256 | ||||
| -rw-r--r-- | plugin/50-goto.lua | 112 | ||||
| -rw-r--r-- | plugin/50-notes.lua | 28 |
3 files changed, 265 insertions, 131 deletions
diff --git a/plugin/50-follow.lua b/plugin/50-follow.lua new file mode 100644 index 0000000..da3bd62 --- /dev/null +++ b/plugin/50-follow.lua @@ -0,0 +1,256 @@ +-- +-- 50-follow.lua +-- +-- Wires the follow engine (lua/dotfiles/follow.lua): registers the followable entities (in the +-- order they are tried, first match wins) and the `notes://` / `nvim-help://` schemes, 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 +-- `<C-w>f` / `<C-w><C-f>` ... in a split +-- `<C-w>gf` ... in a new tab +-- `]u` / `[u` go to the next / previous followable entity +-- + +if vim.env.NOTES_DIR == nil then + vim.notify("NOTES_DIR is not set", vim.log.levels.ERROR) + return +end +local notes_dir = vim.fs.normalize(vim.env.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 + +df.register("wikilink", wikilink_matches, open_wikilink) + +---------------------------------------------------------------------------------------------------- +-- 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 + +df.register("markdown-link", markdown_link_matches, df.open) + +---------------------------------------------------------------------------------------------------- +-- 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 + out[#out + 1] = { from = { i, s - 1 }, to = { i, e }, target = line:sub(s, e) } + init = e + 1 + end + end + return out +end + +df.register("url", url_matches, df.open) + +---------------------------------------------------------------------------------------------------- +-- <cfile> fallback -------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------- + +-- A single open-only match at the cursor when there is a `<cfile>` under it. +local function cfile_matches() + if vim.fn.expand("<cfile>") == "" 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("<C-w>f"), + tabedit = vim.keycode("<C-w>gf"), +} +local function open_cfile(edit_cmd, _) + vim.api.nvim_feedkeys(builtin_gf[edit_cmd], "nx", false) +end + +df.register("cfile", cfile_matches, open_cfile) + +---------------------------------------------------------------------------------------------------- +-- Schemes ----------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------- + +-- `notes://<path>` resolves to `<path>` relative to the notes dir. +df.register_scheme("notes", { + resolve = function(uri) + return notes_dir .. "/" .. uri + end, +}) + +-- `nvim-help://<tag>` — open Neovim help for <tag> in a scratch, read-only buffer. +df.register_scheme("nvim-help", { + 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, +}) + +---------------------------------------------------------------------------------------------------- +-- 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" }, "<C-w>f", opener("split"), { desc = "Follow entity in a split" }) +vim.keymap.set({ "n", "x" }, "<C-w><C-f>", opener("split"), { desc = "Follow entity in a split" }) +vim.keymap.set({ "n", "x" }, "<C-w>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" }) diff --git a/plugin/50-goto.lua b/plugin/50-goto.lua deleted file mode 100644 index 8c72c15..0000000 --- a/plugin/50-goto.lua +++ /dev/null @@ -1,112 +0,0 @@ --- --- 50-goto.lua --- - -local follow = require("dotfiles.goto") - --- Add `'` to the list of characters included in `<cfile>` because `'` is a valid URI character -vim.opt.isfname:append("'") - -vim.keymap.set({ "n", "x" }, "<Leader>gg", function() - follow.follow("edit") -end, { desc = "Edit URL/file in current window" }) -vim.keymap.set({ "n", "x" }, "<Leader>gs", function() - follow.follow("split") -end, { desc = "Edit URL/file in split window" }) -vim.keymap.set({ "n", "x" }, "<Leader>gv", function() - follow.follow("vsplit") -end, { desc = "Edit URL/file in vertically split window" }) - ------------------------------------------------------------------------------------------------------------------------- --- Following notes (`notes://` scheme and `[[wiki-links]]`), wired into the follow engine ------------------------------------------------------------------------------------------------------------------------- - --- `notes://<path>` resolves to `<path>` 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) - --- `nvim-help://<tag>` — open Neovim help for <tag> in a scratch, read-only --- buffer. Registered with the follow engine (see 50-follow.lua). - -require("dotfiles.goto").register_scheme("nvim-help", { - 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, -}) diff --git a/plugin/50-notes.lua b/plugin/50-notes.lua index 30c655c..4e0494c 100644 --- a/plugin/50-notes.lua +++ b/plugin/50-notes.lua @@ -23,7 +23,7 @@ -- `<Leader>nn`: fuzzy-find notes by tags -- `<Leader>nf`: fuzzy-find notes by file name -- `<Leader>ng`: fuzzy-find notes by content --- `<Tab>` / `<S-Tab>` (in notes buffers): go to next/previous URL or wiki-link +-- `<Tab>` / `<S-Tab>` (in notes buffers): go to next/previous followable entity -- if vim.env.NOTES_DIR == nil then @@ -33,8 +33,10 @@ end local notes_dir = vim.fs.normalize(vim.env.NOTES_DIR) local tagfile = vim.fs.joinpath(vim.fn.stdpath("state"), "notes-tags") +local follow = require("dotfiles.follow") + ---------------------------------------------------------------------------------------------------- --- Tags --------------------------------------------------------------------------------------------- +-- Tags -------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- -- Generates ctags for (markdown) files in `notes_dir` @@ -417,36 +419,24 @@ local function rename_section_command(opts) end ---------------------------------------------------------------------------------------------------- --- Navigation -------------------------------------------------------------------------------------- ----------------------------------------------------------------------------------------------------- - --- Moves the cursor to the start of the next (or previous, when `backward`) URL or `[[wiki-link]]`, --- wrapping around the buffer. "URL" means a `scheme://…` target, matching the follow engine in --- 50-goto.lua. -local function goto_url(backward) - local pattern = [[\v(\a[0-9A-Za-z+.-]*://\S+|\[\[.{-}\]\])]] - vim.fn.search(pattern, backward and "bw" or "w") -end - ----------------------------------------------------------------------------------------------------- -- Buffer init & pickers --------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- -- Initializes a notes buffer -- * Include generated notes tags in 'tags' option -- * Enable wikilink completion --- * Map <Tab>/<S-Tab> to jump between URLs/wiki-links +-- * Map <Tab>/<S-Tab> to jump between followable entities local function init_notes_buffer() 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" vim.keymap.set("n", "<Tab>", function() - goto_url(false) - end, { buffer = true, desc = "Go to next URL/wiki-link" }) + follow.next(false) + end, { buffer = true, desc = "Go to next followable entity" }) vim.keymap.set("n", "<S-Tab>", function() - goto_url(true) - end, { buffer = true, desc = "Go to previous URL/wiki-link" }) + follow.next(true) + end, { buffer = true, desc = "Go to previous followable entity" }) end local function find_notes_by_tag() |
