-- 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. `` — 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; ``-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