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 /lua | |
| 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 'lua')
| -rw-r--r-- | lua/dotfiles/follow.lua | 147 | ||||
| -rw-r--r-- | lua/dotfiles/goto.lua | 153 |
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 |
