summaryrefslogtreecommitdiffstats
path: root/.config/nvim/plugin/50-follow.lua
blob: 6784ad6e772fd89a0ab85448435c8ea9fc9c195e (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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
-- Following file names and URL inside Neovim

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

-- Add `'` to the list of characters included in `<cfile>` because `'` is a valid URI character
vim.opt.isfname:append("'")

-- 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%+%-%.]+)://(.*)")
	if scheme ~= nil then -- if the target is a URL (and not a file name)
		uri = vim.uri_decode(uri)
		target = scheme .. "://" .. uri
	end
	if scheme == "notes" then
		target = vim.g.notes_dir .. "/" .. target
	elseif scheme == "nvim-help" then
		local tagfiles = {}
		for _, path in pairs(vim.opt.runtimepath:get()) do
			table.insert(tagfiles, path .. "/doc/tags")
		end
		vim.opt_local.tags = tagfiles
		local matches = vim.fn.taglist(uri)
		if #matches == 0 then
			vim.notify("No help page found for " .. target, vim.log.levels.WARN)
			return
		end
		target = matches[1].filename
	end
	vim.cmd(edit_cmd .. " " .. vim.fn.fnameescape(target))
	if scheme == "nvim-help" then
		vim.opt_local.bufhidden = "wipe"
		vim.opt_local.buftype = "nofile"
		vim.opt_local.swapfile = false
		vim.opt_local.readonly = true
	end
end

-- Returns the inner text of a `[[wiki-link]]` under the cursor, or `nil`.
local function get_wikilink_target()
	local line = vim.api.nvim_get_current_line()
	local col = vim.api.nvim_win_get_cursor(0)[2] + 1
	local init = 1
	while true do
		local s, e, inner = line:find("%[%[(.-)%]%]", init)
		if s == nil then
			return nil
		end
		if col >= s and col <= e then
			return inner
		end
		init = e + 1
	end
end

-- Follow a `[[note]]` / `[[note#Heading]]` / `[[dir/]]` wiki-link relative to the
-- notes dir, creating parent directories (and the note itself on save) as needed.
local function follow_wikilink(inner, edit_cmd)
	local name, heading = inner:match("^(.-)#(.*)$")
	if name == nil then
		name = inner
	end
	local target = vim.g.notes_dir .. "/" .. name
	if name:sub(-1) == "/" then -- directory link: create and open it
		vim.fn.mkdir(target, "p")
		vim.cmd(edit_cmd .. " " .. vim.fn.fnameescape(target))
		return
	end
	if not name:match("%.%w+$") then -- default to a Markdown note
		target = target .. ".md"
	end
	vim.fn.mkdir(vim.fs.dirname(target), "p")
	vim.cmd(edit_cmd .. " " .. vim.fn.fnameescape(target))
	if heading ~= nil and heading ~= "" then
		vim.fn.cursor(1, 1)
		-- `\V` matches the heading literally; `\v` brackets the heading markers.
		local pat = [[\v^#+\s+\V]] .. vim.fn.escape(heading, [[\]]) .. [[\v\s*$]]
		if vim.fn.search(pat, "cW") == 0 then
			vim.notify("No heading '" .. heading .. "' in " .. name, vim.log.levels.WARN)
		end
	end
end

-- Follow a wiki-link if the cursor is on one, else fall back to URL/file following.
local function follow(edit_cmd)
	if vim.fn.mode() == "n" and vim.bo.filetype == "markdown" then
		local inner = get_wikilink_target()
		if inner ~= nil then
			follow_wikilink(inner, edit_cmd)
			return
		end
	end
	edit_target(get_target(), edit_cmd)
end

vim.keymap.set({ "n", "x" }, "<Leader>gg", function()
	follow("edit")
end, { desc = "Edit URL/file in current window" })
vim.keymap.set({ "n", "x" }, "<Leader>gs", function()
	follow("split")
end, { desc = "Edit URL/file in split window" })
vim.keymap.set({ "n", "x" }, "<Leader>gv", function()
	follow("vsplit")
end, { desc = "Edit URL/file in vertically split window" })