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