summaryrefslogtreecommitdiffstats
path: root/plugin
diff options
context:
space:
mode:
authorThomas Vanbesien <tvanbesi@proton.me>2026-06-24 16:54:10 +0200
committerThomas Vanbesien <tvanbesi@proton.me>2026-06-24 17:02:52 +0200
commit42233850cef2e1b30dc6524e056f604c3b5d021b (patch)
tree142a706725ebafa20a30603ee6eca546ddb84723 /plugin
parent6d35e81f1bfdae7da7e98eea850e5f6c7741ece8 (diff)
downloadnvim-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.lua256
-rw-r--r--plugin/50-goto.lua112
-rw-r--r--plugin/50-notes.lua28
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()