summaryrefslogtreecommitdiffstats
path: root/lua
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 /lua
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 'lua')
-rw-r--r--lua/dotfiles/follow.lua147
-rw-r--r--lua/dotfiles/goto.lua153
2 files changed, 147 insertions, 153 deletions
diff --git a/lua/dotfiles/follow.lua b/lua/dotfiles/follow.lua
new file mode 100644
index 0000000..deff888
--- /dev/null
+++ b/lua/dotfiles/follow.lua
@@ -0,0 +1,147 @@
+-- Generic "follow the thing under the cursor" engine. A *followable entity* is a `find`/`open`
+-- pair: `find` locates the entity's occurrences in the buffer, `open` acts on one. The engine
+-- cycles the cursor between all entities' matches and opens the entity under the cursor, trying
+-- entities in registration order, first match wins. `scheme://` resolvers handle targets that
+-- carry a scheme. Entities and schemes are wired up in plugin/50-follow.lua.
+--
+-- API:
+-- register(name, find, open) — add a followable entity (entity contract below)
+-- register_scheme(name, handler) — add a `scheme://` resolver (scheme contract below)
+-- open(edit_cmd[, target]) — open the entity under the cursor / selection, or `target`
+-- next(backward) — move the cursor to the next/previous entity match (wraps)
+-- entities — the ordered registry: a list of { name, find, open }
+-- `edit_cmd` is one of "edit" | "split" | "tabedit"; `target` is a path or `scheme://uri`.
+
+local M = {}
+
+-- Ordered registry of followable entities (the "global table"):
+-- { name = string, find = fun(): match[], open = fun(edit_cmd, target) }
+-- A `match` is `{ from = {lnum, col}, to = {lnum, col}, target = any }` with 1-based `lnum` and
+-- 0-based, end-exclusive byte `col`. A `find` may enumerate the whole buffer (its matches become
+-- `next` stops) or return only the match under the cursor (open-only, e.g. `<cfile>` — return a
+-- match whose `from` sits at the cursor so it never becomes a `next` target).
+M.entities = {}
+
+-- scheme name -> { resolve = fun(uri): target|nil, err?, after = fun()? } (see `open_target`)
+local scheme_handlers = {}
+
+-- Register a followable entity. `find` and `open` are both required (passed together) so an entity
+-- can never be half-defined. Registration order is the try order; `<cfile>`-style fallbacks last.
+function M.register(name, find, open)
+ assert(
+ type(find) == "function" and type(open) == "function",
+ "follow.register: both find and open are required"
+ )
+ M.entities[#M.entities + 1] = { name = name, find = find, open = open }
+end
+
+-- Register a `scheme://` resolver. See `scheme_handlers` for the shape.
+function M.register_scheme(name, handler)
+ scheme_handlers[name] = handler
+end
+
+-- The visual selection text (used to follow a target spanning an explicit selection).
+local function get_visual_selection()
+ return table.concat(
+ vim.fn.getregion(vim.fn.getpos("v"), vim.fn.getpos("."), { type = vim.fn.mode() }),
+ "\n"
+ )
+end
+
+-- Open `target` (a path or `scheme://uri`) with `edit_cmd`, dispatching to a registered scheme
+-- resolver when the target carries a scheme. The string-opening half of `M.open`, used as the
+-- `open` for the url/markdown-link entities.
+local function open_target(edit_cmd, target)
+ local scheme, uri = string.match(target, "^(%a[%w%+%-%.]+)://(.*)")
+ local target_is_url = scheme ~= nil
+ if target_is_url then
+ 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
+ if target_is_url then
+ target = vim.fn.fnameescape(target)
+ end
+ vim.cmd(edit_cmd .. " " .. target)
+ if handler ~= nil and handler.after ~= nil then
+ handler.after()
+ end
+end
+
+-- Compare two `{lnum, col}` positions in buffer order (by line, then column): `pos_le` is `a <= b`
+-- (a is at or before b), `pos_lt` is `a < b` (a is strictly before b).
+local function pos_le(a, b)
+ return a[1] < b[1] or (a[1] == b[1] and a[2] <= b[2])
+end
+local function pos_lt(a, b)
+ return a[1] < b[1] or (a[1] == b[1] and a[2] < b[2])
+end
+
+-- Move the cursor to the start of the next (or previous, when `backward`) entity match, wrapping
+-- around the buffer. Matches whose `from` is at the cursor (open-only entities) are skipped.
+function M.next(backward)
+ local c = vim.api.nvim_win_get_cursor(0)
+ local cur = { c[1], c[2] }
+ local best, wrap
+ for _, e in ipairs(M.entities) do
+ for _, m in ipairs(e.find()) do
+ local p = m.from
+ if not backward then
+ if pos_lt(cur, p) and (best == nil or pos_lt(p, best)) then
+ best = p
+ end
+ if wrap == nil or pos_lt(p, wrap) then
+ wrap = p
+ end
+ else
+ if pos_lt(p, cur) and (best == nil or pos_lt(best, p)) then
+ best = p
+ end
+ if wrap == nil or pos_lt(wrap, p) then
+ wrap = p
+ end
+ end
+ end
+ end
+ local dest = best or wrap
+ if dest ~= nil then
+ vim.api.nvim_win_set_cursor(0, { dest[1], dest[2] })
+ end
+end
+
+-- Open the entity under the cursor (or the visual selection) with `edit_cmd`. With an explicit
+-- `target` (a path or `scheme://uri`), open that directly via the scheme-dispatching opener — this
+-- form is what the url/markdown-link entities register as their `open`. Entities are tried in
+-- registration order; the first whose `find` covers the cursor wins.
+function M.open(edit_cmd, target)
+ if target ~= nil then
+ open_target(edit_cmd, target)
+ return
+ end
+ if vim.fn.mode():find("^[vV\22]") then
+ open_target(edit_cmd, get_visual_selection())
+ return
+ end
+ local c = vim.api.nvim_win_get_cursor(0)
+ local cur = { c[1], c[2] }
+ for _, e in ipairs(M.entities) do
+ for _, m in ipairs(e.find()) do
+ if pos_le(m.from, cur) and pos_lt(cur, m.to) then
+ e.open(edit_cmd, m.target)
+ return
+ end
+ end
+ end
+end
+
+return M
diff --git a/lua/dotfiles/goto.lua b/lua/dotfiles/goto.lua
deleted file mode 100644
index 55c076b..0000000
--- a/lua/dotfiles/goto.lua
+++ /dev/null
@@ -1,153 +0,0 @@
--- 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%+%-%.]+)://(.*)")
- local target_is_url = scheme ~= nil
- if target_is_url then
- 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
- if target_is_url then
- target = vim.fn.fnameescape(target)
- end
- vim.cmd(edit_cmd .. " " .. 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