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" })
|