summaryrefslogtreecommitdiffstats
path: root/lua/dotfiles/follow.lua
blob: deff888ab48940f8d4d4a4d4f4c58907a06e3dd4 (plain)
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