wikilink.lua
dotfiles.git > main > 9f496ce > files / nvim / lua / wikilink.lua
--- Follow [[wikilinks]] and [text](local/links.md) like Obsidian does
local fn = vim.fn
--- Returns the note name and anchor (heading or ^block) of the link under the cursor, or nil
local function link_under_cursor()
local line = vim.api.nvim_get_current_line()
local col = vim.api.nvim_win_get_cursor(0)[2] + 1
for s, target, e in line:gmatch("()%[%[(.-)%]%]()") do
if col >= s and col < e then
return target:gsub("|.*", ""):match("^([^#]*)#?(.*)$")
end
end
for s, target, e in line:gmatch("()%[[^%]]*%]%(([^)]+)%)()") do
if col >= s and col < e and not target:match("^%a+:") then
local name, anchor = vim.uri_decode(target):match("^([^#]*)#?(.*)$")
--- Markdown links use #some-heading slugs, turn them back into words
return name, (anchor:gsub("-", " "))
end
end
end
--- The vault is the nearest dir with .obsidian (or .git), falling back to the file's own dir
local function vault_root(buf)
buf = buf or 0
return vim.fs.root(buf, {".obsidian", ".git"}) or vim.fs.dirname(vim.api.nvim_buf_get_name(buf))
end
--- Obsidian matches by note name anywhere in the vault, or by path if one is given
local function resolve(name)
if not name:match("%.%w+$") then name = name .. ".md" end
local here = fn.expand("%:p:h")
local root = vault_root()
for _, dir in ipairs({here, root}) do
local path = vim.fs.joinpath(dir, name)
if fn.filereadable(path) == 1 then return path end
end
local matches = vim.fs.find(function(f, dir)
return vim.fs.joinpath(dir, f):sub(-#name - 1) == "/" .. name
end, {path = root, type = "file", limit = math.huge})
--- Shortest path wins, same as Obsidian
table.sort(matches, function(a, b) return #a < #b end)
if matches[1] then return matches[1] end
--- Doesn't exist yet, so make it next to the current note
return vim.fs.joinpath(here, name)
end
--- Jumps to the "# Heading" or "^block-id" in the current buffer
local function jump_to_anchor(anchor)
local pat = anchor:sub(1, 1) == "^"
and "\\V " .. fn.escape(anchor, "\\") .. "\\$"
or "\\c\\V\\^#\\+\\s\\+" .. fn.escape(anchor, "\\") .. "\\s\\*\\$"
if fn.search(pat, "cw") == 0 then
vim.notify("No anchor " .. anchor, vim.log.levels.WARN)
end
end
--- Follows the link under the cursor, otherwise runs `fallback` as a normal mode command
local function follow(fallback)
local name, anchor = link_under_cursor()
if not name then
return vim.cmd("normal! " .. fallback)
end
if name ~= "" then
vim.cmd.edit(fn.fnameescape(resolve(name)))
else
vim.cmd("normal! m'")
end
if anchor ~= "" then jump_to_anchor(anchor) end
end
local skipped_dirs = {[".git"] = true, [".obsidian"] = true, [".trash"] = true, node_modules = true}
--- Every file in the vault, relative to its root
local function vault_files(root)
local found = {}
for path, type in vim.fs.dir(root, {depth = math.huge, skip = function(dir)
return not skipped_dirs[vim.fs.basename(dir)]
end}) do
if type == "file" then found[#found + 1] = path end
end
return found
end
--- Every note in the vault as {name = "Note", path = "dir/Note"}, name is what Obsidian links by
local function notes()
local found = {}
for _, path in ipairs(vault_files(vault_root())) do
if path:match("%.md$") then
local rel = path:sub(1, -4)
found[#found + 1] = {name = vim.fs.basename(rel), path = rel}
end
end
return found
end
--- Linkable names and paths per vault, notes without their .md like links write them
local indexes = {}
local function vault_index(root)
if indexes[root] then return indexes[root] end
local index = {names = {}, paths = {}}
for _, path in ipairs(vault_files(root)) do
path = path:gsub("%.md$", "")
index.names[vim.fs.basename(path)] = true
index.paths[#index.paths + 1] = "/" .. path
end
indexes[root] = index
return index
end
--- Whether following the link would land on a note that already exists
local function exists(name, buf)
if name == "" then return true end
name = name:gsub("%.md$", "")
local index = vault_index(vault_root(buf))
if not name:find("/") then return index.names[name] == true end
local here = vim.fs.dirname(vim.api.nvim_buf_get_name(buf))
if fn.filereadable(vim.fs.joinpath(here, name .. ".md")) == 1 then return true end
for _, path in ipairs(index.paths) do
if path:sub(-#name - 1) == "/" .. name then return true end
end
return false
end
--- Notes come and go, so rescan the next time a link gets highlighted
vim.api.nvim_create_autocmd({"BufEnter", "BufWritePost", "BufFilePost", "FocusGained"}, {
group = vim.api.nvim_create_augroup("wikilink", {}),
callback = function() indexes = {} end,
})
--- (#wikilink-exists? @capture) for highlighting links to missing notes differently
vim.treesitter.query.add_predicate("wikilink-exists?", function(match, _, source, pred)
for _, node in ipairs(match[pred[2]] or {}) do
local target = vim.treesitter.get_node_text(node, source):gsub("|.*", ""):gsub("#.*", "")
if not exists(target, source) then return false end
end
return true
end, {force = true})
return {follow = follow, notes = notes}