obsidivim#
Obsidivim is a greatly simplified "implementation" of Obsidian for Neovim. I made it because I found that I don't actually use many features of Obsidian, and I didn't need to be tied to a (proprietary) application for that.
It basically provides some niceties for working with a collection of interlinked Markdown files.
Code primarily written by an LLM.
Screenshots#
Click any image to view full size.
| Info panel & link highlighting | Wiki-link completion | Fuzzy note picker |
|---|---|---|
![]() |
![]() |
![]() |
Features#
- Wiki links:
[[note]],[[note|alias]],[[note#heading]]— follow, hover, create on miss; resolved by title, path, filename, or alias. - Rename: renames the file and heading, updating every inbound link.
- Frontmatter: reads
title,aliases, andtags. - Highlighting: links,
#tags, external-link markers, Obsidian-style concealing. - Pickers: notes, dailies, grep, backlinks, tags, orphans — via snacks.nvim, telescope.nvim, or fzf-lua.
- Info panel: linked and unlinked mentions of the current note.
- Snippets: markdown templates with placeholders, applied to new notes and dailies or inserted at the cursor.
- Daily notes: today/yesterday with configurable paths.
- Completion: blink.cmp source for
[[with note preview.
Requirements#
- Neovim 0.10+
- Optional: snacks.nvim, telescope.nvim, or fzf-lua for pickers
- Optional: blink.cmp for completion
Installation#
With lazy.nvim. This is a complete, batteries-included setup: picker (snacks.nvim), completion (blink.cmp), and concealing. The plugin defines no keymaps of its own — the ones below are suggestions.
{
{
url = "https://tangled.org/@jamie.schembri.me/obsidivim",
version = "*",
-- Or telescope.nvim / fzf-lua — see "Picker backends" below
dependencies = { "folke/snacks.nvim" },
event = "BufReadPre",
init = function()
-- Obsidian-style concealing of [[brackets]] and alias| targets
-- (alternatively, set these in after/ftplugin/markdown.lua)
vim.api.nvim_create_autocmd("FileType", {
pattern = "markdown",
callback = function()
vim.opt_local.conceallevel = 2
vim.opt_local.concealcursor = ""
end,
})
end,
keys = {
-- find
{ "<leader>nf", "<cmd>ObsidivimFind<cr>", desc = "Find note" },
{ "<leader>n/", "<cmd>ObsidivimSearch<cr>", desc = "Search notes" },
{ "<leader>nb", "<cmd>ObsidivimBacklinks<cr>", desc = "Backlinks" },
{ "<leader>n#", "<cmd>ObsidivimTags<cr>", desc = "Tags" },
-- write
{ "<leader>nn", "<cmd>ObsidivimNew<cr>", desc = "New note" },
{ "<leader>nR", "<cmd>ObsidivimRename<cr>", desc = "Rename note" },
{ "<leader>ns", "<cmd>ObsidivimSnippet<cr>", desc = "Insert snippet" },
-- dailies
{ "<leader>nd", "<cmd>ObsidivimFindDaily<cr>", desc = "Find daily notes" },
{ "<leader>nt", "<cmd>ObsidivimToday<cr>", desc = "Daily note (today)" },
{ "<leader>ny", "<cmd>ObsidivimYesterday<cr>", desc = "Daily note (yesterday)" },
-- ui
{ "<leader>np", "<cmd>ObsidivimPalette<cr>", desc = "Command palette" },
{ "<leader>nP", "<cmd>ObsidivimInfoPanel<cr>", desc = "Toggle info panel" },
},
opts = {
dir = "~/notes", -- required
daily_dir = "~/notes/daily", -- required
snippets_dir = "~/notes/.snippets", -- optional, see "Snippets"
-- Snippets for new notes: the defaults shown here, or snippet
-- file paths, e.g. "~/notes/.snippets/daily.md"
default_snippet = function(title)
return { "# " .. title, "" }
end,
daily_snippet = function(title)
return { "# " .. title, "" }
end,
on_attach = function(buf)
local ov = require("obsidivim")
local map = function(mode, lhs, rhs, desc)
vim.keymap.set(mode, lhs, rhs, { buffer = buf, desc = desc })
end
map("n", "K", ov.hover, "Preview wiki link")
map("n", "gf", ov.follow_link, "Follow wiki link")
map("n", "<C-]>", ov.follow_link, "Follow wiki link")
end,
},
},
-- Completion for [[wiki links]]. If you already configure blink.cmp
-- elsewhere, just add the "obsidivim" source and provider there.
{
"saghen/blink.cmp",
version = "1.*",
opts_extend = { "sources.default" },
opts = {
sources = {
default = { "lsp", "path", "snippets", "buffer", "obsidivim" },
providers = {
obsidivim = { module = "obsidivim.blink", name = "Notes" },
},
},
},
},
}
Configuration#
Defaults shown; dir and daily_dir are required.
opts = {
dir = nil, -- notes root
daily_dir = nil, -- daily notes root
-- os.date() format for daily note paths relative to daily_dir,
-- e.g. "%Y/%m/%Y-%m-%d" -> daily_dir/2026/07/2026-07-03.md
daily_path_format = "%Y-%m-%d",
-- os.date() format for the title of new daily notes.
-- nil uses the file name, e.g. "# 2026-07-03".
daily_title_format = nil, -- e.g. "%A, %B %d %Y" -> "# Friday, July 03 2026"
-- File name (without .md) for notes created from a title or wiki link:
-- "verbatim" keeps the title, minus filename-unsafe characters:
-- "Meeting: Notes!" -> Meeting Notes!.md
-- "slugify" lowercases and dashes: "Meeting: Notes!" -> meeting-notes.md
-- a fun(title): string returns the stem; a "/" in it creates the note
-- in a subdirectory (missing directories are created)
note_filename = "verbatim",
-- Snippets applied to new notes / daily notes: a snippet file path
-- (e.g. "~/notes/.snippets/note.md", see "Snippets") or a
-- fun(title): string[] returning buffer lines.
default_snippet = function(title)
return { "# " .. title, "" }
end,
daily_snippet = function(title)
return { "# " .. title, "" }
end,
-- Directory of snippet files, picked from by :ObsidivimSnippet.
snippets_dir = nil, -- e.g. "~/notes/.snippets"
-- Write newly created notes to disk immediately. When false they are
-- left as unsaved buffers.
auto_save = false,
picker = "auto", -- "auto" | "snacks" | "telescope" | "fzf-lua" | "select" | backend table
-- Read-only info split (linked + unlinked mentions) at the bottom of note
-- windows.
info_panel = {
enabled = false, -- auto-open/refresh for note buffers
height = 10,
},
highlight = {
enabled = true,
conceal = true, -- conceal [[ ]] and the target| of aliased links
tags = true, -- highlight #tags
external_icon = "↗", -- marker on external [text](url) links
-- (e.g. a nerd-font glyph); "" disables
},
on_attach = nil, -- fun(buf) called for markdown buffers in the notes dir
}
Snippets#
Snippets are plain markdown files. These placeholders are expanded (anything else is left alone):
| Placeholder | Expands to |
|---|---|
{{title}} |
note title (when inserting into an existing note: its first heading / file name) |
{{date}}, {{time}} |
2026-07-05, 14:30 |
{{date:<fmt>}}, {{time:<fmt>}} |
any os.date() format |
{{cursor}} |
nothing — the cursor lands here (default: end of snippet) |
default_snippet and daily_snippet are applied when a note / daily note
is created. Each is either a snippet file path or a fun(title): string[]
returning the buffer lines.
snippets_dir enables :ObsidivimSnippet: pick a snippet and insert it
below the cursor (or as the whole buffer when empty). A snippets_dir
inside your notes directory is fine: hidden directories are excluded from
note scans.
Frontmatter#
Notes may start with a YAML frontmatter block; three fields are read (anything else is ignored):
---
title: Cascading Style Sheets
aliases: [CSS, styles]
tags: [webdev, reference]
---
titleoverrides the first# headingas the note's title.aliasesare extra names the note answers to:[[CSS]]resolves here and completes after[[.tagscount in:ObsidivimTags, alongside inline#tags.
Values can be quoted, inline arrays ([a, b]), block lists (- a), or bare
scalars.
Renaming#
:ObsidivimRename [new title] (prompting with the current title when called
bare) renames the current note: the file (named via note_filename), its
# heading, and every inbound [[link]]. Links keep their addressing style
(title / path / filename / alias) and their #anchors and |aliases; notes
with unsaved changes elsewhere are skipped with a warning.
Daily notes#
ObsidivimToday/ObsidivimYesterday open (creating if needed) a note at
daily_dir + daily_path_format + .md. For example:
opts = {
dir = "~/notes",
daily_dir = "~/notes/daily",
daily_path_format = "%Y/%m/%Y-%m-%d",
daily_title_format = "%A %d %B %Y",
daily_snippet = "~/notes/.snippets/daily.md",
}
with ~/notes/.snippets/daily.md containing:
# {{title}}
## Tasks
## Log
creates ~/notes/daily/2026/07/2026-07-03.md containing:
# Friday 03 July 2026
## Tasks
## Log
LSP mappings#
follow_link and hover fall back to vim.lsp.buf.definition() and
vim.lsp.buf.hover() off-link, so they can safely shadow LSP keys in note
buffers. If an LSP (e.g. marksman) maps the same keys on LspAttach, its
mappings run later and win — register those with unique = true so the
buffer-local ones are kept.
Info panel#
With info_panel.enabled = true, a read-only split opens at the bottom of
note windows, refreshed as you switch notes or save:
- Linked mentions — notes containing a
[[link]]that resolves here. - Unlinked mentions — notes using this note's title as plain text.
The panel follows the focused note window and renders [[wiki links]] like
a note. <CR>, gf, or <C-]> jump to the mention under the cursor — in
the note window you came from, never the panel — and q closes it.
:ObsidivimInfoPanel toggles the panel and focuses it. It works with
enabled off too, so you can skip auto-open and drive it from a key.
Picker backends#
picker = "auto" uses the first of snacks.nvim, telescope.nvim, or fzf-lua
that is installed, and vim.ui.select otherwise. Set it explicitly
("snacks", "telescope", "fzf-lua", "select") to skip detection.
In all three native backends, <CR> opens the selection; in the find picker
<CR> with no match (or <C-x> at any point) creates a note from the typed
query, and <C-r> rescans the notes directory.
A backend just needs its plugin installed, e.g. swap the dependencies line
of the installation example for one of:
-- snacks.nvim
dependencies = { "folke/snacks.nvim" },
-- telescope.nvim
dependencies = {
{ "nvim-telescope/telescope.nvim", dependencies = { "nvim-lua/plenary.nvim" } },
},
-- fzf-lua (also needs the fzf binary, e.g. `brew install fzf`)
dependencies = { "ibhagwan/fzf-lua" },
To integrate another picker natively, pass a table with two functions:
picker = {
--- Show a list of items.
--- items(refresh?) returns { label, hint?, file, pos? }[]; call it with
--- refresh = true to rescan notes. Confirming an item should call
--- opts.confirm(item) when set, otherwise :edit its file and jump to pos
--- ({lnum, col}) when set. on_create (only set for the find picker) may
--- be called with a free-text query to create a note; support it if your
--- picker exposes the typed query.
pick = function(opts) end, -- opts = { title, items, on_create?, confirm? }
--- Live-grep the notes directory.
grep = function(opts) end, -- opts = { title, dir }
}
Commands#
| Command | Action |
|---|---|
ObsidivimFind |
Pick a note by title (create on no match) |
ObsidivimSearch |
Grep note contents |
ObsidivimBacklinks |
Notes linking to the current note |
ObsidivimTags |
Pick a #tag, then jump to an occurrence |
ObsidivimOrphans |
Notes with no inbound links |
ObsidivimNew |
Create a note (arg or prompt for title) |
ObsidivimRename |
Rename current note, updating inbound links |
ObsidivimSnippet |
Pick a snippet and insert it at the cursor |
ObsidivimFindDaily |
Pick a daily note |
ObsidivimToday |
Open today's daily note |
ObsidivimYesterday |
Open yesterday's daily note |
ObsidivimPalette |
Pick any Obsidivim command and run it |
ObsidivimInfoPanel |
Toggle the info panel |
API#
require("obsidivim"): find(), search(), backlinks(), tags(),
orphans(), create(title), rename(title), insert_snippet(),
find_daily(), today(), yesterday(), palette(),
toggle_info_panel(), follow_link(), hover()
Highlights#
Override with vim.api.nvim_set_hl (defaults shown):
| Group | Default |
|---|---|
ObsidivimLink |
underline, fg from @markup.link.label (fg or sp) |
ObsidivimLinkMissing |
links to DiagnosticUnderlineHint |
ObsidivimLinkExternal |
links to ObsidivimLink (external links + icon) |
ObsidivimTag |
links to Special |
Versioning#
Releases are SemVer git tags (vMAJOR.MINOR.PATCH); see
CHANGELOG.md. While pre-1.0, minor bumps may break opts.
Pin to the latest tagged release in lazy.nvim, or omit version to follow the
default branch:
{ url = "https://tangled.org/@jamie.schembri.me/obsidivim", version = "*" }


