summaryrefslogtreecommitdiffstats
path: root/.config/nvim
diff options
context:
space:
mode:
authorThomas Vanbesien <tvanbesi@proton.me>2026-06-07 20:58:17 +0200
committerThomas Vanbesien <tvanbesi@proton.me>2026-06-08 12:37:59 +0200
commitb3bf3fb2ee8605344a4e4d68807d0dfa22f48561 (patch)
tree01509ab814cce648ed4d79670f0f9233524673bc /.config/nvim
parentcdb8a3148654d740f84a611b1424ef88bf6f9014 (diff)
downloaddotfiles-b3bf3fb2ee8605344a4e4d68807d0dfa22f48561.tar.gz
dotfiles-b3bf3fb2ee8605344a4e4d68807d0dfa22f48561.zip
refactor(nvim): extract follow engine into dotfiles.follow module
Diffstat (limited to '.config/nvim')
-rw-r--r--.config/nvim/lua/dotfiles/follow.lua144
-rw-r--r--.config/nvim/plugin/50-follow.lua180
-rw-r--r--.config/nvim/plugin/50-notes.lua72
-rw-r--r--.config/nvim/plugin/50-nvim-help.lua23
4 files changed, 247 insertions, 172 deletions
diff --git a/.config/nvim/lua/dotfiles/follow.lua b/.config/nvim/lua/dotfiles/follow.lua
new file mode 100644
index 0000000..5a3962c
--- /dev/null
+++ b/.config/nvim/lua/dotfiles/follow.lua
@@ -0,0 +1,144 @@
+-- Generic "follow the thing under the cursor" engine: extracts a target (a
+-- markdown link, `<cfile>`, or the visual selection), parses an optional
+-- `scheme://uri`, and opens it. Features register per-scheme resolvers and
+-- pre-target follow handlers (e.g. wiki-links) so this file stays
+-- scheme-agnostic — see 50-notes.lua and 50-nvim-help.lua.
+
+local M = {}
+
+-- scheme name -> { resolve = fun(uri): target|nil, err?, after = fun()? }
+-- resolve maps the decoded uri to a path to edit; returning nil aborts (with
+-- an optional message). after, if present, runs in the freshly-opened buffer.
+local scheme_handlers = {}
+
+-- Follow handlers that get first crack before generic target extraction. Each is
+-- fun(edit_cmd): handled(boolean), tried in registration order.
+local follow_handlers = {}
+
+-- Register a `scheme://` resolver. See `scheme_handlers` for the handler shape.
+function M.register_scheme(name, handler)
+ scheme_handlers[name] = handler
+end
+
+-- Register a pre-target follow handler. See `follow_handlers` for the shape.
+function M.register_handler(fn)
+ follow_handlers[#follow_handlers + 1] = fn
+end
+
+local function get_visual_selection()
+ return table.concat(vim.fn.getregion(vim.fn.getpos("v"), vim.fn.getpos("."), { type = vim.fn.mode() }), "\n")
+end
+
+-- Returns a suitable target for `vim.cmd.edit()` by looking for a Markdown link under the cursor.
+-- Returns `nil` if no target was found, if the current buffer `'filetype'` is not `markdown`.
+local function get_markdown_link_target()
+ local function get_link_node(node)
+ local function type_is_link(type)
+ return type == "full_reference_link" or type == "inline_link" or type == "shortcut_link"
+ end
+ if type_is_link(node:type()) then
+ return node
+ elseif node:parent() ~= nil and type_is_link(node:parent():type()) then
+ return node:parent()
+ end
+ return nil
+ end
+
+ local function follow_link_label(label)
+ -- Escape Lua pattern magic chars so the label is matched literally.
+ local escaped = label:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")
+ -- Reference labels are case-insensitive in CommonMark, 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
+ vim.notify("No link destination found for link label [" .. label .. "]", vim.log.levels.ERROR)
+ return nil
+ end
+
+ if vim.o.filetype == "markdown" and vim.treesitter.get_parser(0) ~= nil then
+ -- `ignore_injections = false` because the `link_destination`|`link_label` types are injected by `markdown_inline`
+ local node = vim.treesitter.get_node({ ignore_injections = false })
+ if node == nil then
+ vim.notify("No node found under cursor", vim.log.levels.ERROR)
+ return nil
+ end
+ local link_node = get_link_node(node)
+ if link_node == nil then
+ return nil
+ end
+ for child in link_node:iter_children() do
+ local text = vim.treesitter.get_node_text(child, 0)
+ if child:type() == "link_destination" then
+ return text
+ elseif
+ child:type() == "link_label" or (link_node:type() == "shortcut_link" and child:type() == "link_text")
+ then
+ -- shortcut_link text don't include the `[]` unlike link_label
+ if link_node:type() ~= "shortcut_link" then
+ text = text:sub(2, -2)
+ end
+ return follow_link_label(text)
+ end
+ end
+ vim.notify("No link destination/label found in link_node children", vim.log.levels.ERROR)
+ return nil
+ end
+ return nil
+end
+
+-- Returns a suitable target for `vim.cmd.edit()`.
+-- In normal mode, looks for a filename/link under the cursor.
+-- In selection mode, use the visual selection as-is.
+local function get_target()
+ if vim.fn.mode() == "n" then
+ return get_markdown_link_target() or vim.fn.expand("<cfile>")
+ else
+ return get_visual_selection()
+ end
+end
+
+-- Edit the file/URL under the cursor or in visual selection.
+-- target: URL or absolute file name
+-- edit-type: edit|split|vsplit
+local function edit_target(target, edit_cmd)
+ local scheme, uri = string.match(target, "^(%a[%w%+%-%.]+)://(.*)")
+ if scheme ~= nil then -- if the target is a URL (and not a file name)
+ uri = vim.uri_decode(uri)
+ target = scheme .. "://" .. uri
+ end
+ local handler = scheme ~= nil and scheme_handlers[scheme] or nil
+ if handler ~= nil then
+ local resolved, err = handler.resolve(uri)
+ if resolved == nil then
+ if err ~= nil then
+ vim.notify(err, vim.log.levels.WARN)
+ end
+ return
+ end
+ target = resolved
+ end
+ vim.cmd(edit_cmd .. " " .. vim.fn.fnameescape(target))
+ if handler ~= nil and handler.after ~= nil then
+ handler.after()
+ end
+end
+
+-- Follow a registered handler (e.g. a wiki-link) if one claims the cursor, else
+-- fall back to generic URL/file following.
+function M.follow(edit_cmd)
+ for _, fn in ipairs(follow_handlers) do
+ if fn(edit_cmd) then
+ return
+ end
+ end
+ edit_target(get_target(), edit_cmd)
+end
+
+return M
diff --git a/.config/nvim/plugin/50-follow.lua b/.config/nvim/plugin/50-follow.lua
index 6784ad6..1e48cd5 100644
--- a/.config/nvim/plugin/50-follow.lua
+++ b/.config/nvim/plugin/50-follow.lua
@@ -1,183 +1,19 @@
--- Following file names and URL inside Neovim
+-- Following file names and URLs inside Neovim. The engine lives in
+-- `lua/dotfiles/follow.lua`; per-scheme behavior is registered by the feature
+-- that owns it — see 50-notes.lua (`notes://` and `[[wiki-links]]`) and
+-- 50-nvim-help.lua (`nvim-help://`).
-local function get_visual_selection()
- return table.concat(vim.fn.getregion(vim.fn.getpos("v"), vim.fn.getpos("."), { type = vim.fn.mode() }), "\n")
-end
-
--- Returns a suitable target for `vim.cmd.edit()` by looking for a Markdown link under the cursor.
--- Returns `nil` if no target was found, if the current buffer `'filetype'` is not `markdown`.
-local function get_markdown_link_target()
- local function get_link_node(node)
- local function type_is_link(type)
- return type == "full_reference_link" or type == "inline_link" or type == "shortcut_link"
- end
- if type_is_link(node:type()) then
- return node
- elseif node:parent() ~= nil and type_is_link(node:parent():type()) then
- return node:parent()
- end
- return nil
- end
-
- local function follow_link_label(label)
- -- Escape Lua pattern magic chars so the label is matched literally.
- local escaped = label:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")
- -- Reference labels are case-insensitive in CommonMark, 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
- vim.notify("No link destination found for link label [" .. label .. "]", vim.log.levels.ERROR)
- return nil
- end
-
- if vim.o.filetype == "markdown" and vim.treesitter.get_parser(0) ~= nil then
- -- `ignore_injections = false` because the `link_destination`|`link_label` types are injected by `markdown_inline`
- local node = vim.treesitter.get_node({ ignore_injections = false })
- if node == nil then
- vim.notify("No node found under cursor", vim.log.levels.ERROR)
- return nil
- end
- local link_node = get_link_node(node)
- if link_node == nil then
- return nil
- end
- for child in link_node:iter_children() do
- local text = vim.treesitter.get_node_text(child, 0)
- if child:type() == "link_destination" then
- return text
- elseif
- child:type() == "link_label" or (link_node:type() == "shortcut_link" and child:type() == "link_text")
- then
- -- shortcut_link text don't include the `[]` unlike link_label
- if link_node:type() ~= "shortcut_link" then
- text = text:sub(2, -2)
- end
- return follow_link_label(text)
- end
- end
- vim.notify("No link destination/label found in link_node children", vim.log.levels.ERROR)
- return nil
- end
- return nil
-end
+local follow = require("dotfiles.follow")
-- Add `'` to the list of characters included in `<cfile>` because `'` is a valid URI character
vim.opt.isfname:append("'")
--- Returns a suitable target for `vim.cmd.edit()`.
--- In normal mode, looks for a filename/link under the cursor.
--- In selection mode, use the visual selection as-is.
-local function get_target()
- if vim.fn.mode() == "n" then
- return get_markdown_link_target() or vim.fn.expand("<cfile>")
- else
- return get_visual_selection()
- end
-end
-
--- Edit the file/URL under the cursor or in visual selection.
--- target: URL or absolute file name
--- edit-type: edit|split|vsplit
-local function edit_target(target, edit_cmd)
- local scheme, uri = string.match(target, "^(%a[%w%+%-%.]+)://(.*)")
- if scheme ~= nil then -- if the target is a URL (and not a file name)
- uri = vim.uri_decode(uri)
- target = scheme .. "://" .. uri
- end
- if scheme == "notes" then
- target = vim.g.notes_dir .. "/" .. target
- elseif scheme == "nvim-help" then
- local tagfiles = {}
- for _, path in pairs(vim.opt.runtimepath:get()) do
- table.insert(tagfiles, path .. "/doc/tags")
- end
- vim.opt_local.tags = tagfiles
- local matches = vim.fn.taglist(uri)
- if #matches == 0 then
- vim.notify("No help page found for " .. target, vim.log.levels.WARN)
- return
- end
- target = matches[1].filename
- end
- vim.cmd(edit_cmd .. " " .. vim.fn.fnameescape(target))
- if scheme == "nvim-help" then
- vim.opt_local.bufhidden = "wipe"
- vim.opt_local.buftype = "nofile"
- vim.opt_local.swapfile = false
- vim.opt_local.readonly = true
- 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()
- follow("edit")
+ follow.follow("edit")
end, { desc = "Edit URL/file in current window" })
vim.keymap.set({ "n", "x" }, "<Leader>gs", function()
- follow("split")
+ follow.follow("split")
end, { desc = "Edit URL/file in split window" })
vim.keymap.set({ "n", "x" }, "<Leader>gv", function()
- follow("vsplit")
+ follow.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 dee4da3..fec0dd2 100644
--- a/.config/nvim/plugin/50-notes.lua
+++ b/.config/nvim/plugin/50-notes.lua
@@ -325,3 +325,75 @@ vim.api.nvim_create_user_command("NotesRenameSection", function(opts)
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://<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)
diff --git a/.config/nvim/plugin/50-nvim-help.lua b/.config/nvim/plugin/50-nvim-help.lua
new file mode 100644
index 0000000..926bb68
--- /dev/null
+++ b/.config/nvim/plugin/50-nvim-help.lua
@@ -0,0 +1,23 @@
+-- `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.follow").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,
+})