1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
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
|