wikilink.lua

dotfiles.git > main > 5dec7c2 > 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}