Vim Motions supports Lua configuration files using a sandboxed Lua 5.3 runtime. Enable it in Settings → Vim Motions → Vimrc & key bindings → Configuration mode → Lua only (or Lua + Vimrc). The key value-add over vimrc is conditional logic and function-based keymaps.
File location
The plugin searches the vault root for the first matching file in this order:
init.lua
.init.lua
obsidian.init.lua
.obsidian.init.lua
obsidian.lua
The first file found is used. Override this with a custom path in Settings → Vim Motions → Vimrc & key bindings → Custom init.lua path. The settings UI shows which file is currently active.
Shared config across vaults (desktop only)
Two ways to share one init.lua across multiple vaults on desktop:
Option A — Global config search toggle: Enable Settings → Vim Motions → Vimrc & key bindings → Search global config directory. The plugin will automatically search the Obsidian user data folder after exhausting vault-root candidates:
~/.config/obsidian/ (Linux)
~/Library/Application Support/obsidian/ (macOS)
%APPDATA%\obsidian\ (Windows)
Place your init.lua in the appropriate directory and it will be found automatically. Vault-root files always take priority.
Option B — Custom absolute path: Set an absolute path in Settings → Vim Motions → Vimrc & key bindings → Custom init.lua path:
Any path starting with /, ~, or a drive letter is read directly from the filesystem instead of through the vault.
Neither option is available on mobile.
Obsidian Sync
Obsidian Sync skips dotfiles. Use a non-dotfile name like init.lua (the first candidate in the fallback chain) to ensure your Lua config syncs across devices.
Multi-file configs with require()
Split your configuration across multiple files by placing Lua modules in a lua/ directory. The plugin supports both lua/name.lua and lua/name/init.lua patterns:
-- lua/keymaps.lualocal M = {}function M.setup() vim.g.mapleader = " " vim.keymap.set("n", "<leader>w", ":w<CR>", { desc = "Save" })endreturn M
Modules are cached in package.loaded — calling require("keymaps") twice returns the same table. load(chunk) is available for dynamic string compilation. dofile and loadfile remain disabled.
Security: module names containing .., absolute paths (/, \), or null bytes are rejected.
Where modules are searched
Two directories are searched, in this order:
lua/beside the configured init.lua — if Settings → Vim Motions → Keybindings → Lua config path points at folder/vim/init.lua, that is folder/vim/lua/.
lua/ at the vault root.
So a configuration kept in its own folder can carry its modules with it, while an existing vault-root lua/ keeps working unchanged. When both define the same module name, the one beside the configuration wins.
<vault>/
folder/vim/
init.lua # Lua config path points here
lua/
regex.lua # require("regex") — found first
lua/
shared.lua # require("shared") — still found
Configurations outside the vault
If your configuration lives at an absolute path outside the vault, its neighbouring lua/ directory is searched too, read through the filesystem rather than the vault. This is desktop only — on mobile that directory contributes nothing, the same as the out-of-vault configuration file itself.
require() resolves synchronously
Every .lua file under the search roots above is read into memory when the configuration loads. require() resolves from that snapshot, so both a package.loaded cache hit and a first-time module load complete synchronously, without reading from disk.
This is what makes lazy require work — the idiom nearly every Neovim plugin is built on:
-- The submodule is not loaded until you press `s`. The callback runs on the-- main Lua state and cannot wait for a file read, so it is served from memory.vim.keymap.set("n", "s", function() require("myplugin.jump").start()end)
Reading from disk is asynchronous, so it remains available only where the caller is able to wait for it: your top-level configuration, autocommand and timer callbacks, and anything else the plugin runs on a coroutine. Keymap callbacks, :lua, and expression mappings are not in that group, and are served by the snapshot alone.
The snapshot is rebuilt when the configuration reloads, and again after a successful vim.plugins.add() fetch — before your Lua resumes — so a freshly fetched plugin is immediately requirable.
Files added after the configuration loads
A .lua file you create while Obsidian is running is not in the snapshot yet. Requiring it from a keymap callback reports that it is not present in the configuration snapshot, naming every path that was tried across both search roots. Saving your main configuration file reloads it, or run the Vim Motions: Reload configuration command. Editing an existing module is subject to the same boundary. See known-limitations > Lua configuration.
Example init.lua
vim.g.mapleader = " "vim.opt.scrolloff = 8vim.opt.textobjects = truevim.opt.clipboard = "unnamedplus"-- Conditional config based on vaultif vim.vault_name() == "work" then vim.opt.clipboard = "unnamedplus"else vim.opt.clipboard = ""end-- Keymaps with string RHSvim.keymap.set("n", "<leader>w", ":w<CR>", { desc = "Save file" })vim.keymap.set("i", "jk", "<Esc>", { desc = "Exit insert mode" })-- Keymap with function callbackvim.keymap.set("n", "<leader>t", function() vim.cmd("obcommand daily-notes:open-today")end, { desc = "Open daily note" })-- Remove a mappingvim.keymap.del("n", "Q")-- Ex commandsvim.cmd("set nohlsearch")print("init.lua loaded for vault:", vim.vault_name())
Soft-reload
The Lua configuration file is watched for changes. When you save the file, the old Lua state is torn down and the file is re-executed without reloading the plugin. This allows for rapid iteration on your configuration.
You can also manually trigger a reload of all configuration files (both Lua and vimrc) using the Vim Motions: Reload configuration command from the Obsidian command palette.
External editor (desktop only)
On desktop, you can open your active configuration files in your system’s default external editor using the Vim Motions: Open configuration in default editor command. This will open both init.lua and .obsidian.vimrc if both are enabled and found.
To manage your modules from your file manager instead, use Vim Motions: Open configuration directory in system explorer. It reveals the folder containing your active configuration with the configuration file itself selected, which is the same folder require() searches for a lua/ directory. When both a Lua and a vimrc configuration are active in the same folder, it is revealed once.
Both commands work whether your configuration lives inside the vault or outside it — an out-of-vault path found through Global config search is opened through the operating system directly.
Supported APIs
The registered API surface includes 69 real vim.api.nvim_* implementations and 92 real vim.fn.* implementations with all async callbacks (89 without them). API real/stub/total counts are 69/88/157; fn counts are 92/39/131, or 89/39/128 without the runner. api-status-counts.test.ts guards these registries against NEOVIM_API_STATUS.md from source. Real handlers can still have limitations; callable presence does not establish plugin compatibility.
Known API/fn names resolve to handlers or stubs; unknown names raise on read. Deliberately absent fields in plain namespaces read nil. There is no implemented ABSENT_NVIM_API_FUNCTIONS tier. Silent placeholders such as iconv and the six uri_* helpers are worse to diagnose than warn-once stubs: they leave no console trace.
Warning
mini.surround and mini.splitjoin remain audit-blocked after the text, legacy-position and extmark coordinate fixes; integration Phases 6/7 remain cancelled, not passed. mini.splitjoin’s string-expr-mapping load blocker requires Vimscript evaluation, which this host does not have: an architectural constraint, not a missing-function to-do item, and it may never be unblockable here. See known-limitations > Audited third-party plugins remain blocked. The fork’s built-in surround is a separate feature. mini.comment’s existing tests now pin commit 27a29d6b949b9497f80a0a03421e89fed71d8c37; coverage still establishes only the tested operations. flash.nvim is terminally blocked by LuaJIT FFI, not fixable by adding shim APIs.
API
Description
Example
vim.opt.<name> = value
Set a plugin option (string options accept tables)
vim.opt.scrolloff = 8
vim.o.<name> = value
Read/write global engine options with compatibility fallbacks
vim.o.scrolloff = 8
vim.go.<name> = value
Global option access sharing the vim.o store
vim.go.eventignore = ""
vim.iter(source)
Iterator pipelines over tables or functions
vim.iter({1, 2}):totable()
vim.on_key(fn, ns?)
Observe physical keys before mappings
see Key observation
vim.treesitter.query.get(lang, name)
Resolve an explicit, vault, plugin, or bundled query
see vim.treesitter
vim.o.operatorfunc
Function called by the g@ operator
see Custom operators
vim.g.mapleader
Set the leader key
vim.g.mapleader = " "
vim.g.<name> = value
Set a user variable
vim.g.my_var = true
vim.cmd(string)
Execute an ex command
vim.cmd("set nohlsearch")
vim.vault_name()
Returns the current vault name
if vim.vault_name() == "work" then
vim.fn.has(feature)
Platform/feature detection
vim.fn.has("mac")
vim.fn.expand(expr)
Active file path (vault-relative)
vim.fn.expand("%:t")
vim.fn.fnamemodify(path, mods)
Path manipulation
vim.fn.fnamemodify(path, ":t:r")
vim.fn.exists(expr)
Check variable/option existence
vim.fn.exists("g:my_var")
vim.fn.localtime()
Unix timestamp
vim.fn.localtime()
vim.fn.strftime(fmt)
Format date/time
vim.fn.strftime("%Y-%m-%d")
vim.fn.filereadable(path)
Check vault file exists
vim.fn.filereadable("config.md")
vim.fn.isdirectory(path)
Check vault directory exists
vim.fn.isdirectory("templates")
vim.fn.glob(pattern)
Find matching vault files
vim.fn.glob("*.md")
vim.fn.undotree()
Returns undo tree dictionary
local tree = vim.fn.undotree()
vim.fn.mode()
Current vim mode
vim.fn.mode()
vim.fn.line(expr)
Cursor line (1-based, callbacks)
vim.fn.line(".")
vim.fn.col(expr)
UTF-8 byte column (1-based); see coordinate contract
The g@ operator calls a function stored in vim.o.operatorfunc. The function receives a string argument indicating the motion type: 'line', 'char', or 'block'.
The range covered by the motion is marked by the '[ and '] marks.
operatorfunc uses the same read/write path through vim.opt, vim.o, vim.go, nvim_get_option/nvim_set_option, and nvim_get_option_value/nvim_set_option_value. Assign a Lua function, a function-name string (optionally prefixed with v:lua.), or nil to clear it. Reads return the assigned name for a string assignment, or "" for a direct function or cleared value; they do not return the Lua function. Execution of g@ requires the bundled fork’s operator callback support.
vim.o.operatorfunc = function(type) local start_pos = vim.fn.getpos("'[") local end_pos = vim.fn.getpos("']") print("Operator called with type:", type) print("Range:", start_pos[2], start_pos[3], "to", end_pos[2], end_pos[3])endvim.keymap.set("n", "gz", "g@", { desc = "Custom operator" })
vim.textobject
Define custom text objects from Lua configuration.
vim.gen_spec.pair(open, close, opts?)
Creates a text object spec for delimiter pairs.
open (string) — Opening delimiter (e.g., "((", "**", "<")
close (string) — Closing delimiter (e.g., "))", "**", ">")
opts.multiline (boolean, default true) — Search across multiple lines
vim.textobject.add(keys, spec)
Register a custom text object.
keys (string) — Keybinding, must start with i (inner) or a (around), e.g., "iX", "a<"
Set the leader key with vim.g.mapleader. Common choices:
vim.g.mapleader = " " -- Space (recommended, matches most Neovim configs)vim.g.mapleader = "," -- Commavim.g.mapleader = "\\" -- Backslash (default)
Set mapleader before keymaps
Always set vim.g.mapleader before any vim.keymap.set or vim.obsidian.leader.add calls.
The leader key is substituted at registration time — changing it later won’t update existing mappings.
vim.v — Predefined variables
Neovim-compatible read-only predefined variables. Available in keymap callbacks and autocmds.
Core variables (available in keymap callbacks)
Variable
Type
Description
Default
vim.v.count
integer
Count given for the last Normal mode command. 0 when no count typed.
0
vim.v.count1
integer
Like count but defaults to 1 when no count given.
1
vim.v.register
string
Register in effect for the current command. '"' (unnamed) when none specified.
'"'
vim.v.operator
string
Pending operator (e.g., 'd', 'y', 'c'). Empty string when none.
These are only meaningful within specific evaluation contexts (fold expressions, statuscolumn, autocmds):
Variable
Type
R/W
Context
vim.v.foldstart
integer
R
Fold text evaluation
vim.v.foldend
integer
R
Fold text evaluation
vim.v.foldlevel
integer
R
Fold text evaluation
vim.v.folddashes
string
R
Fold text evaluation
vim.v.lnum
integer
R
statuscolumn evaluation
vim.v.relnum
integer
R
statuscolumn evaluation
vim.v.virtnum
integer
R
statuscolumn evaluation
vim.v.char
string
R/W
InsertCharPre autocmd
vim.v.event
table/nil
R
Autocmd event data
Example: expr mapping with count
-- Use gj/gk for screen-line movement, j/k for counted movementvim.keymap.set('n', 'j', function() if vim.v.count == 0 then return 'gj' else return vim.v.count1 .. 'j' endend, { expr = true, silent = true })vim.keymap.set('n', 'k', function() if vim.v.count == 0 then return 'gk' else return vim.v.count1 .. 'k' endend, { expr = true, silent = true })
Count is not auto-forwarded to expr mapping results
Count is not auto-forwarded to expr mapping results. If you type 3j and your expr callback returns 'j', it executes once. Concatenate the count yourself: return vim.v.count1 .. 'j'.
Supported vim.opt options
All plugin options are available via vim.opt. vim.o is an alias.
Option
Type
Default
Valid range / values
Example
textobjects
boolean
true
vim.opt.textobjects = true
replacewithregister
boolean
true
vim.opt.replacewithregister = true
navigation
boolean
true
vim.opt.navigation = true
hardwrap
boolean
true
vim.opt.hardwrap = true
listcontinuation
boolean
true
vim.opt.listcontinuation = true
tablenav
boolean
true
vim.opt.tablenav = true
workspacenav
boolean
true
vim.opt.workspacenav = true
number
boolean
false
vim.opt.number = true
relativenumber
boolean
false
vim.opt.relativenumber = true
flash
boolean
true
vim.opt.flash = true
flashmultiline
boolean
true
vim.opt.flashmultiline = true
flashjump
boolean
false
vim.opt.flashjump = true
flashcleverf
boolean
false
vim.opt.flashcleverf = true
flashsearch
boolean
true
vim.opt.flashsearch = true
labelmatchfontsize
boolean
false
vim.opt.labelmatchfontsize = true
easymotion
boolean
true
vim.opt.easymotion = true
easymotiondimming
boolean
true
vim.opt.easymotiondimming = true
hintmode
boolean
true
vim.opt.hintmode = true
statusbar
boolean
true
vim.opt.statusbar = true
chorddisplay
boolean
true
vim.opt.chorddisplay = true
powerline
boolean
false
vim.opt.powerline = true
expandtab
boolean
true
vim.opt.expandtab = true
cursorline
boolean
true
vim.opt.cursorline = true
foldcolumn
boolean
false
vim.opt.foldcolumn = true
undotree
boolean
true
vim.opt.undotree = true
undofile
boolean
false
vim.opt.undofile = true
vimtextareas
boolean
false
vim.opt.vimtextareas = true
yankring
boolean
true
vim.opt.yankring = true
harpoon
boolean
true
vim.opt.harpoon = true
dial
boolean
false
vim.opt.dial = true
jumplist
boolean
true
vim.opt.jumplist = true
foldawarenavigation
boolean
true
vim.opt.foldawarenavigation = true
foldopen
string
block,hor,mark,percent,search,undo
fdo
vim.opt.foldopen = "block,hor,search"
foldpersistence
boolean
false
vim.opt.foldpersistence = true
subword
boolean
false
vim.opt.subword = true
picker
boolean
true
vim.opt.picker = true
pickerleadermappings
boolean
true
vim.opt.pickerleadermappings = true
pickeromnisearch
boolean
false
vim.opt.pickeromnisearch = true
pickertasks
boolean
false
vim.opt.pickertasks = true
pickerdataview
boolean
false
vim.opt.pickerdataview = true
ripgrep
boolean
false
vim.opt.ripgrep = true
oil
boolean
false
vim.opt.oil = true
oilhiddenfiles
boolean
false
vim.opt.oilhiddenfiles = true
undotreeautoopen
boolean
false
vim.opt.undotreeautoopen = true
imswitching
boolean
false
vim.opt.imswitching = true
pcre
boolean
true
vim.opt.pcre = false
ignorecase
boolean
true
ic
vim.opt.ignorecase = false
smartcase
boolean
true
scs
vim.opt.smartcase = false
hlsearch
boolean
true
hls
vim.opt.hlsearch = false
incsearch
boolean
true
is
vim.opt.incsearch = false
wrapscan
boolean
true
ws
vim.opt.wrapscan = false
gdefault
boolean
false
gd
vim.opt.gdefault = true
startofline
boolean
true
sol
vim.opt.startofline = false
joinspaces
boolean
false
js
vim.opt.joinspaces = true
shiftround
boolean
false
sr
vim.opt.shiftround = true
whichwrap
string
"b,s"
ww
vim.opt.whichwrap = "b,s,h,l"
virtualedit
string
""
ve — "", "onemore", "all", "block", "insert"
vim.opt.virtualedit = "onemore"
nrformats
string
"bin,hex"
nf — "bin", "hex", "octal"
vim.opt.nrformats = "bin,hex,octal"
smoothcursor
boolean
false
vim.opt.smoothcursor = true
smoothcursorglide
boolean
true
vim.opt.smoothcursorglide = true
smoothcursorsmear
boolean
true
vim.opt.smoothcursorsmear = true
scrolloff
number
5
0–9999
vim.opt.scrolloff = 8
scanlimit
number
20
5–200
vim.opt.scanlimit = 20
undotreemaxnodes
number
1000
100–5000
vim.opt.undotreemaxnodes = 500
jumplistsize
number
200
> 0
vim.opt.jumplistsize = 100
yankhighlightduration
number
200
0–5000 ms
vim.opt.yankhighlightduration = 300
labelfontsize
number
14
10–20
vim.opt.labelfontsize = 14
tabstop
number
4
vim.opt.tabstop = 4
shiftwidth
number
4
vim.opt.shiftwidth = 4
textwidth
number
80
vim.opt.textwidth = 80
insertmodeescapetimeout
number
1000
100–5000 ms
vim.opt.insertmodeescapetimeout = 1000
operatorshadowtimeout
number
1000
0–5000 ms (0 = disabled). Aliases: ost, timeoutlen, tm
Enabling both vim.opt.number = true and vim.opt.relativenumber = true activates hybrid mode: the current line shows its absolute number, while all other lines show their relative distance from the cursor.
Example with cursor on line 8:
a 3 ## Introduction
2 Some text here.
1 More context.
8 ← cursor line (shows absolute number)
1 Additional notes.
b 2 Another paragraph.
3 Final thoughts.
The sign column (a, b) appears to the left of line numbers. The fold column (if enabled) appears to the right. This layout matches Neovim’s default gutter arrangement: sign column → line numbers → fold column → content.
Table syntax for string options
String options that accept comma-separated values can also be set using Lua tables. The elements are joined with commas automatically.
-- These are equivalent:vim.opt.workspacenavviewtypes = "markdown,graph,pdf,canvas"vim.opt.workspacenavviewtypes = {"markdown", "graph", "pdf", "canvas"}
See settings for the full list of options and their descriptions.
Supported vim.fn functions
92 Neovim vim.fn.* functions have real implementations with all async callbacks for configuration, buffer manipulation, register access, async key input, regex search, coordinate conversion, viewport information, and platform detection.
Function
Returns
Example
vim.fn.has(feature)
1 or 0
if vim.fn.has("mac") == 1 then
vim.fn.expand("%")
Vault-relative file path
vim.fn.expand("%") → "folder/note.md"
vim.fn.expand("%:t")
Filename only
vim.fn.expand("%:t") → "note.md"
vim.fn.expand("%:e")
Extension only
vim.fn.expand("%:e") → "md"
vim.fn.expand("%:r")
Path without extension
vim.fn.expand("%:r") → "folder/note"
vim.fn.fnamemodify(path, mods)
Modified path
vim.fn.fnamemodify("a/b.md", ":t:r") → "b"
vim.fn.exists(expr)
1 if exists, 0 otherwise
vim.fn.exists("g:my_var")
vim.fn.localtime()
Unix timestamp (seconds)
vim.fn.localtime()
vim.fn.strftime(fmt)
Formatted date string
vim.fn.strftime("%Y-%m-%d")
vim.fn.filereadable(path)
1 if vault file exists
vim.fn.filereadable("config.md")
vim.fn.isdirectory(path)
1 if vault directory exists
vim.fn.isdirectory("templates")
vim.fn.glob(pattern)
Newline-separated file list
vim.fn.glob("*.md")
vim.fn.mode()
Current mode string
vim.fn.mode() → "n", "i", "v"
vim.fn.line(expr)
Cursor line (1-based)
vim.fn.line(".") (callbacks only)
vim.fn.col(expr)
UTF-8 byte column (1-based)
vim.fn.col(".") (active editor)
vim.fn.charcol(expr)
Character column (1-based)
vim.fn.charcol(".")
vim.fn.virtcol(expr, list?, win?)
Display column or {first,last} (1-based)
vim.fn.virtcol(".", true, 0)
vim.fn.virtcol2col(win, lnum, col)
Byte column (1-based), 0 empty / -1 invalid
vim.fn.virtcol2col(0, 1, 4)
vim.fn.line2byte(lnum)
Byte position (1-based) or -1
vim.fn.line2byte(1) → 1 with a loaded buffer
vim.fn.byte2line(byte)
Line number (1-based) or -1
vim.fn.byte2line(1) → 1 with a loaded buffer
vim.fn.getline(expr)
Line content string
vim.fn.getline(".") (callbacks only)
vim.fn.tolower(s)
Lowercase string
vim.fn.tolower("Hello") → "hello"
vim.fn.toupper(s)
Uppercase string
vim.fn.toupper("Hello") → "HELLO"
vim.fn.trim(s)
Trimmed string
vim.fn.trim(" hi ") → "hi"
vim.fn.strlen(s)
String length
vim.fn.strlen("hello") → 5
vim.fn.strwidth(s)
UTF-16 length (known display-width defect)
vim.fn.strwidth("hello") → 5
vim.fn.stridx(s, needle)
First index of needle
vim.fn.stridx("hello", "ll") → 2
vim.fn.strridx(s, needle)
Last index of needle
vim.fn.strridx("abab", "ab") → 2
vim.fn.strpart(s, start, len?)
Substring
vim.fn.strpart("hello", 1, 3) → "ell"
vim.fn.substitute(s, pat, sub, flags)
Regex replace
vim.fn.substitute("hi", "h", "H", "") → "Hi"
vim.fn.nr2char(n)
Character from code point
vim.fn.nr2char(65) → "A"
vim.fn.char2nr(c)
Code point from character
vim.fn.char2nr("A") → 65
vim.fn.split(s, sep?)
List (table) of parts
vim.fn.split("a,b", ",")
vim.fn.join(list, sep?)
Joined string
vim.fn.join({"a","b"}, "-") → "a-b"
vim.fn.setreg(regname, value [, opts])
(none)
vim.fn.setreg('"', "text", "l") (linewise)
vim.fn.getreg(regname?)
Register content string
vim.fn.getreg('"') → "yanked text"
vim.fn.getregtype(regname?)
"v", "V", or "\x16"
vim.fn.getregtype('"') → "V" (linewise)
vim.fn.setline(lnum, text)
(none)
vim.fn.setline(1, "new content")
vim.fn.append(lnum, text|list)
(none)
vim.fn.append(0, "first line")
vim.fn.deletebufline(buf, first, last?)
0 success / 1 failure; buf 0 only
vim.fn.deletebufline(0, 2, "$")
vim.fn.indent(lnum)
Indent column number
vim.fn.indent(1) → 4
vim.fn.nextnonblank(lnum)
Line number or 0
vim.fn.nextnonblank(3)
vim.fn.prevnonblank(lnum)
Line number or 0
vim.fn.prevnonblank(3)
vim.fn.getpos(expr)
{buf, lnum, col, off}
vim.fn.getpos("'[") (operatorfunc range)
vim.fn.setpos(expr, list)
(none)
vim.fn.setpos(".", {0, 5, 1, 0})
vim.fn.cursor(lnum, col)
(none)
vim.fn.cursor(5, 1)
vim.fn.getcurpos()
{buf, lnum, col, off, want}
vim.fn.getcurpos()
vim.fn.type(expr)
Neovim type number
vim.fn.type("s") → 1 (string)
vim.fn.len(expr)
Length of string/list/dict
vim.fn.len({1,2,3}) → 3
vim.fn.empty(expr)
1 if empty, 0 otherwise
vim.fn.empty("") → 1
vim.fn.matchstr(s, pat)
Matched portion
vim.fn.matchstr("abc123", "\\d+") → "123"
vim.fn.match(s, pat [, start])
Match position or -1
vim.fn.match("hello", "ll") → 2
vim.fn.matchlist(s, pat)
Match groups list
vim.fn.matchlist("ab12", "(\\w+)(\\d+)")
vim.fn.escape(s, chars)
Escaped string
vim.fn.escape("a.b", ".") → "a\\.b"
vim.fn.repeat(s|list, count)
Repeated string/list
vim.fn.repeat("-", 5) → "-----"
vim.fn.reverse(s|list)
Reversed string/list
vim.fn.reverse("abc") → "cba"
vim.fn.range(n [, end [, stride]])
Number list
vim.fn.range(1, 5) → {1, 2, 3, 4, 5}
vim.fn.sort(list)
Sorted list (in-place)
vim.fn.sort({"c","a","b"}) → {"a","b","c"}
vim.fn.uniq(list)
Deduplicated list (in-place)
vim.fn.uniq({"a","a","b"}) → {"a","b"}
vim.fn.max(list)
Maximum number
vim.fn.max({3,1,4}) → 4
vim.fn.min(list)
Minimum number
vim.fn.min({3,1,4}) → 1
vim.fn.abs(n)
Absolute value
vim.fn.abs(-5) → 5
vim.fn.index(list, item)
0-based index or -1
vim.fn.index({"a","b"}, "b") → 1
vim.fn.count(list, val)
Occurrence count
vim.fn.count({1,2,1}, 1) → 2
vim.fn.add(list, item)
Appended list (mutates)
vim.fn.add(t, "x") (same as table.insert)
vim.fn.remove(list, idx)
Removed item (0-based idx)
vim.fn.remove(t, 0) removes first element
vim.fn.extend(list1, list2)
Merged list (mutates list1)
vim.fn.extend(t1, t2)
vim.fn.copy(expr)
Shallow copy
vim.fn.copy({1,2,3})
vim.fn.deepcopy(expr)
Deep copy
vim.fn.deepcopy(nested_table)
vim.fn.keys(dict)
Key list
vim.fn.keys({a=1, b=2})
vim.fn.values(dict)
Value list
vim.fn.values({a=1, b=2})
vim.fn.items(dict)
{{key, val}, ...} pairs
vim.fn.items({a=1}) → {{"a", 1}}
vim.fn.flatten(list)
Flattened list
vim.fn.flatten({{1,2},{3}}) → {1,2,3}
vim.fn.visualmode()
Last visual mode type
vim.fn.visualmode() → "v", "V", "\x16"
vim.fn.winsaveview()
View state table
local view = vim.fn.winsaveview()
vim.fn.winrestview(view)
(none)
vim.fn.winrestview(view)
vim.fn.foldclosed(lnum)
First line of fold, or -1
vim.fn.foldclosed(5) → 3 or -1
vim.fn.foldclosedend(lnum)
Last line of fold, or -1
vim.fn.foldclosedend(5) → 8 or -1
vim.fn.shiftwidth()
Effective shift width
vim.fn.shiftwidth() → 4
vim.fn.strdisplaywidth(s)
Display width (CJK-aware)
vim.fn.strdisplaywidth("你好") → 4
vim.fn.strcharpart(s, start, len?)
Substring by char index
vim.fn.strcharpart("hello", 1, 3) → "ell"
vim.fn.maparg(name, mode?)
RHS of mapping or ""
vim.fn.maparg("<leader>w", "n")
vim.fn.getcharstr()
Single keystroke (async)
local ch = vim.fn.getcharstr()
vim.fn.getchar()
Key code number (async)
local nr = vim.fn.getchar()
vim.fn.searchpos(pat, flags?)
{line, col} or {0, 0}
vim.fn.searchpos("\\bword\\b") → {3, 5}
vim.fn.input(prompt, default?, completion?)
User input string (async)
local name = vim.fn.input("Name: ")
vim.fn.strchars(s, skipcc?)
Character count
vim.fn.strchars("héllo") → 5
vim.fn.charidx(s, byteidx, countcc?)
Char index of a byte, or -1
vim.fn.charidx("héllo", 3) → 2
vim.fn.byteidx(s, nr)
Byte index of a char, or -1
vim.fn.byteidx("héllo", 2) → 3
vim.fn.wincol()
Cursor screen column (1-based)
vim.fn.wincol() → 5
vim.fn.winlayout()
Window tree
vim.fn.winlayout() → {"leaf", 0}
vim.fn.win_getid(winnr?, tabnr?)
Synthetic current handle 0 (also invalid ordinal result)
vim.fn.win_getid(1, 1) → 0
vim.fn.winnr(expr?)
''/$ → 1; # → 0; other forms error
vim.fn.winnr() → 1
Info
strchars counts composing marks separately unless skipcc is set; charidx and byteidx fold them into the preceding base character, matching Vim. wincol measures from the window edge, so the gutter counts. winlayout always reports a single leaf — see Architectural constraints.
Coordinate contract
Host editor callbacks remain UTF-16. The following enumerated boundaries convert explicitly; the whole shim is not byte-correct:
API
Units, forms and boundaries
nvim_buf_get_offset(0, index)
Line index 0 → buffer byte offset 0. Accepts one-past-last; EOL is always one byte, regardless of fileformat. Unloaded buffer returns -1; out-of-range indices raise.
line2byte(lnum) / byte2line(byte)
Both arguments/results are 1-based. line2byte accepts one-past-last and returns total bytes + 1; invalid/unloaded returns -1. Honors fileformat (dos EOL is two bytes); both EOL bytes belong to the preceding line.
Line 1 / UTF-8 byte column 0. Getter without a cursor returns {1,0}. Setter rejects malformed/non-integer tuples, invalid lines, negative columns and columns above v:maxcol. Past-EOL clamping is native.
nvim_buf_get_mark(0, name)
Line 1 / byte column 0; unset {0,0}, linewise end column v:maxcol (2147483647). Invalid mark names/handles raise.
col(expr)
., $, mark strings (for example "'a"), or {lnum, byte_col}. Line/column are 1-based; $ means byte length + 1 and can be the list’s column. Invalid positions return 0.
charcol(expr)
Expression forms resolve a byte position then return a 1-based character column; astral characters count once. List {lnum, char_col} is already character-based, validated and echoed, not converted from bytes. $ means character count + 1; invalid positions return 0.
virtcol(expr, list?, win?)
Same byte-position expressions as col; scalar is the character’s last display cell, list = true/nonzero gives inclusive {first,last}, all 1-based. Window must be 0; invalid position/window returns 0 or {0,0}. A malformed list flag returns 0.
virtcol2col(win, lnum, col)
Display column 1 → containing character’s byte column 1. Invalid window, negative line/column, fractional coordinates or line past buffer return -1. Line 0 and column 0 clamp up to 1; empty line returns 0; past EOL clamps to the last character’s first byte. It is not an exact inverse of virtcol.
nvim_buf_get_text(0, sr, sc, er, ec, {})
Rows/byte columns 0-based, exclusive end column. Returns exact UTF-8 slices, including interior bytes; past-EOL columns clamp.
nvim_buf_set_text(0, sr, sc, er, ec, replacement)
Rows/byte columns 0-based, exclusive end column. Interior bytes normalize start-down/end-up (D5); past-EOL columns error. Empty ranges at character boundaries insert.
getpos(expr) / setpos(expr, list)
Current cursor . or mark: {0, line, byte_col, 0}, line/column 1-based. Unset position reads {0,0,0,0}; linewise end reads v:maxcol. Writes normalize interior bytes down; supported successful writes return 0.
getcurpos()
{0, line, byte_col, 0, curswant}; first four elements use legacy-position units, fifth uses D6’s sticky display goal.
col/charcol/virtcol require a string or list; malformed argument types raise rather than returning a position sentinel. Floating-point entries in position lists raise. The offset and inverse-display Lua bindings require numeric arguments.
D4 deviation: an interior-byte cursor write normalizes to the containing character’s first byte. Neovim preserves interior bytes, which the UTF-16 host cannot represent. This is not parity; past-EOL clamping remains native and unchanged.
D5 deviation: text reads honor interior bytes exactly by slicing the UTF-8 encoding; fengari holds Lua strings as Uint8Array, so a split character is representable on the Lua side. Text writes normalize interior start-down/end-up because the host document is a JS UTF-16 string in which invalid UTF-8 has no representation. This asymmetry is deliberate, not parity.
D6 deviation:getcurpos reads sticky curswant from the fork’s vim.lastHPos, not pixel-valued lastHSPos. A stored goal survives shorter lines and is made 1-based; Infinity after $ maps to 2147483647. When lastHPos is -1, a positional fallback uses the first display cell of a wide character or last cell of a tab. This is the host’s partial goal state, not full Neovim parity.
Extmark deviation: CM6 cannot retain interior UTF-8 byte remainders in its offset model. Normalizing start-down/end-up follows D5 and preserves the full character’s span; getters return the normalized columns, unlike Neovim’s preserved interior bytes.
Display columns use resolved buffer tabstop and window list, listchars, wrap, showbreak, plus measured CM6 viewport width. Tabs, composing marks, wide characters and wrapped continuation prefixes are covered for the tested profiles; this is not complete terminal/grid or Unicode-width emulation. Unsupported option shadows do not add display semantics. vim.fn.strwidth still returns UTF-16 s.length, a separate quarantined defect; do not use it as an oracle.
deletebufline(0, first, last?) uses a 1-based inclusive range, defaults last to first, accepts $ for the last line, clamps an oversized last line, and preserves an empty line after deleting everything. Invalid ranges/handles or unavailable editor mutation return 1, not success.
Remaining coordinate seams include nvim_buf_set_mark, cursor, view save/restore, wincol, searchpos and JS-backed strlen/strpart/stridx/strridx. The repaired text, legacy-position and extmark handlers were already counted real; source-derived counts cannot detect semantic repair. The demand audit tests correctness of the probed calls, not just registration. See known-limitations > Lua API limitations.
String coordinate helpers
These five helpers now have real handlers rather than silent placeholders:
Byte offset → requested UTF index (0-based); omitted index means EOL. Legacy vim.str_utfindex(s, index?) returns two values, UTF-32 and UTF-16 indices.
vim.str_utf_start(s, index)
Input byte index is 1-based; returns the non-positive offset back to its containing character’s start.
vim.str_utf_end(s, index)
Input byte index is 1-based; returns the non-negative offset forward to its containing character’s final byte.
vim.str_utf_pos(s)
List of 1-based byte positions of each code point; empty string returns {}.
The index converters accept "utf-8", "utf-16", "utf-32"; strict indexing defaults true. Out-of-range UTF-16/32 conversions raise when strict, or return the end index when non-strict. Interior UTF-8 bytes and UTF-16 surrogate positions round up, unlike the editor cursor’s D4 normalization. str_utf_start/str_utf_end reject indices outside the string (including EOL); numeric strings/fractions follow the Lua numeric binding’s conversion. The native zero-index short circuit and UTF-8 identity form are preserved; use test/fixtures/neovim-string-coordinate-contract.ts for the exact edge-case matrix.
Seven helpers remain silent placeholders, with no warning: iconv, uri_decode, uri_encode, uri_from_bufnr, uri_from_fname, uri_to_bufnr, uri_to_fname. They are not real conversions.
vim.fn.getwininfo(winid?)
Returns a one-window list for the active editor. Omit winid or pass 0; a non-zero handle or no active editor returns {}.
Field
Value
topline, botline
Visible buffer lines, 1-based and inclusive; clipped to on-screen CM6 blocks
height, width
Viewport dimensions in rows and character columns, measured from CM6
textoff
Gutter width in character columns, rounded up
winid, bufnr
0 (current window/buffer)
winnr, tabnr, winrow, wincol
1
winbar, terminal, quickfix, loclist
0
variables
Empty table
local win = vim.fn.getwininfo()[1]if win then print(win.topline, win.botline, win.height, win.width, win.textoff)end
vim.fn.getcharstr() and vim.fn.getchar()
These functions yield the Lua coroutine and wait for the user to press a key.
They cannot be called directly from a keymap callback
A vim.keymap.set callback runs on the main Lua state and cannot yield, so calling getcharstr() there raises async APIs can only be called from async-capable callbacks. Schedule the work instead — a scheduled callback runs on a coroutine and can wait:
vim.keymap.set("n", "<leader>k", function() vim.schedule(function() vim.notify("Press a key...") local ch = vim.fn.getcharstr() vim.notify("You pressed: " .. ch) end)end)
The same applies to vim.fn.input(). Lifting this restriction is tracked in the project’s plan for async-capable keymap callbacks.
Modifier-only keys (Shift, Ctrl, Alt, Meta) are ignored. Special keys return their vim notation (e.g., <Esc>, <CR>, <BS>). Cannot be used in { expr = true } callbacks or snippet f()/d() nodes.
Concurrent waiters are served in call order: one keypress resolves exactly one of them.
Ten-second bound
The wait is subject to the coroutine runner’s 10-second await limit, so a longer human pause fails the call with async operation timed out. The key listener is released when that happens, so a timeout costs no keystroke — but a prompt you expect a user to think about for longer than ten seconds is not currently supportable.
vim.fn.searchpos()
Searches the current buffer for a regex pattern and returns the position:
-- Find next occurrence of "TODO"local pos = vim.fn.searchpos("TODO")if pos[1] > 0 then vim.api.nvim_win_set_cursor(0, pos)end
The flags parameter supports "b" (backward search), "n" (no move), "w" (wrap around). Default is forward search with wrapping. Returns {0, 0} when no match is found.
vim.fn.input()
Opens an Obsidian modal with a text input field:
vim.keymap.set("n", "<leader>r", function() local name = vim.fn.input("Rename to: ", vim.fn.expand("%:t:r")) if name then vim.ob.rename() endend)
Returns nil if the user cancels (presses Escape). The optional default parameter pre-fills the input. The completion parameter is accepted but ignored.
vim.fn.has() features
Feature
Returns 1 when
"mac" / "macunix"
macOS
"linux"
Linux desktop
"win32" / "win64"
Windows
"unix"
macOS or Linux
"mobile"
Mobile device (iOS or Android)
"desktop"
Desktop device
"ios"
iOS
"android"
Android
"obsidian"
Always (running in Obsidian)
"obsidian-X.Y"
Obsidian version >= X.Y
"nvim"
Never (not Neovim)
"vim"
Never (not Vim)
All other feature strings return 0. Use vim.fn.has("obsidian-1.7") to check for a minimum Obsidian version.
vim.fn.exists() expressions
Expression
Checks
"g:varname"
Whether vim.g.varname has been set
"&option"
Whether a vim.opt option exists
"*funcname"
Whether a vim.fn function is implemented
vim.fn.fnamemodify() modifiers
Modifier
Result
Example with "folder/note.md"
:t
Filename with extension
"note.md"
:r
Remove last extension
"folder/note"
:e
Extension only
"md"
:h
Directory part
"folder"
:t:r
Filename without extension
"note" (chained)
:p
Vault-relative path (identity)
"folder/note.md"
vim.fn.line() and vim.fn.col()
These functions return cursor position (1-based) and are only meaningful inside function callbacks. At config-load time they return 0 because no editor is active.
vim.keymap.set("n", "<leader>h", function() if vim.fn.line(".") == 1 then vim.notify("Already at top!") else vim.cmd("normal! gg") endend, { desc = "Smart go-to-top" })
Conditional config examples
-- Per-platform settingsif vim.fn.has("mobile") == 1 then vim.opt.easymotion = false vim.opt.hintmode = falseend-- Check if a templates directory existsif vim.fn.isdirectory("templates") == 1 then vim.g.has_templates = trueend-- Per-filetype keymaps (inside function callbacks)vim.keymap.set("n", "<leader>p", function() if vim.fn.expand("%:e") == "md" then vim.cmd("obcommand markdown:toggle-preview") endend, { desc = "Toggle preview" })-- User feedback via vim.notifyvim.keymap.set("n", "<leader>r", function() vim.cmd("obcommand app:reload") vim.notify("Reloaded!")end, { desc = "Reload" })-- Check if a config file existsif vim.fn.filereadable("vim-motions-config.md") == 1 then vim.opt.scrolloff = 10end
File paths are vault-relative
vim.fn.expand("%"), vim.fn.filereadable(), vim.fn.isdirectory(), and vim.fn.glob() use vault-relative paths. Absolute filesystem paths and .. path traversal are not supported for security.
Table and string utilities
A subset of Neovim’s vim.* utility functions is available for table manipulation, string operations, and debugging.
Function
Description
Example
vim.tbl_deep_extend(behavior, ...)
Recursive table merge. "force" = rightmost wins, "keep" = leftmost wins, "error" = throw on conflict. Lists are atomic (replaced, not merged).
vim.tbl_deep_extend("force", {a=1}, {a=2, b=3})
vim.tbl_extend(behavior, ...)
Shallow table merge (same behaviors as above)
vim.tbl_extend("force", defaults, opts)
vim.tbl_contains(t, value, opts?)
Check if table contains value. With {predicate=true}, value is called as a function.
vim.tbl_contains({1,2,3}, 2)
vim.tbl_keys(t)
Returns list of all keys
vim.tbl_keys({a=1, b=2})
vim.tbl_values(t)
Returns list of all values
vim.tbl_values({a=1, b=2})
vim.tbl_map(fn, t)
Map function over table values
vim.tbl_map(function(v) return v*2 end, {1,2,3})
vim.tbl_filter(fn, t)
Filter table by predicate
vim.tbl_filter(function(v) return v > 1 end, {1,2,3})
vim.tbl_count(t)
Count entries in table
vim.tbl_count({a=1, b=2}) → 2
vim.tbl_isempty(t)
Check if table is empty
vim.tbl_isempty({}) → true
vim.tbl_get(t, ...)
Safe nested access
vim.tbl_get({a={b=42}}, "a", "b") → 42
vim.list_extend(dst, src)
Append elements from src to dst
vim.list_extend({1,2}, {3,4})
vim.deepcopy(t)
Deep copy a table
local copy = vim.deepcopy(original)
vim.split(s, sep, opts?)
Split string. {plain=true} for literal sep, {trimempty=true} to trim empty parts.
vim.split("a,b,c", ",")
vim.trim(s)
Strip whitespace from both ends
vim.trim(" hi ") → "hi"
vim.startswith(s, prefix)
Check if string starts with prefix
vim.startswith("hello", "hel") → true
vim.endswith(s, suffix)
Check if string ends with suffix
vim.endswith("hello", "lo") → true
vim.pesc(s)
Escape Lua pattern special characters
vim.pesc("a.b") → "a%.b"
vim.inspect(value)
Human-readable string representation of any value. Useful for debugging.
print(vim.inspect({1,2,{nested=true}}))
vim.stricmp(a, b)
Case-insensitive string comparison. Returns -1 (a < b), 0 (equal), or 1 (a > b).
vim.stricmp("Hello", "hello") → 0
vim.iter
vim.iter(source) creates a real iterator pipeline from a list-like table, a map-like table (key/value pairs), an iterator function, or a callable table. Iterator triples such as vim.iter(pairs(tbl)) are accepted. List transformations are eager; map-table and function pipelines are lazy. Iterators can also be used in a generic for loop.
local result = vim.iter({1, 2, 3, 4}) :filter(function(n) return n % 2 == 0 end) :map(function(n) return n * 10 end) :totable() -- {20, 40}
The 26 available methods are:
Methods
Purpose
map(fn), filter(fn), flatten(depth?)
Transform, filter, or flatten values (flatten requires a list)
totable(), join(separator)
Collect values or join them into a string
next(), peek()
Consume or inspect the next item
rev(), pop(), rpeek(), rpop()
Reverse a list or access its tail; pop and rpop remove the tail
skip(n_or_fn), rskip(n), slice(first, last)
Skip a prefix, skip a list suffix, or keep an inclusive 1-based list slice
nth(n), last()
Find an item by position or the last item; negative nth requires a list
enumerate()
Add indices to items
any(fn), all(fn)
Short-circuit predicate checks
fold(initial, fn), each(fn)
Accumulate a value or visit items
take(n_or_fn)
Limit to a count or a matching prefix
find(value_or_fn), rfind(value_or_fn)
Search forward or backward (rfind requires a list)
count(), size()
Count by consuming, or inspect the remaining list length without consuming
Compatibility extensions
rpop, count, and size are Vim Motions extensions, not Neovim 0.12 methods. size() requires a list source and raises on function sources (including map-table and callable-table pipelines). Tail operations and reversal also require list sources. totable() on a forward list and fold() on a list do not drain the iterator; do not assume all terminal operations consume identically.
JSON
Function
Description
Example
vim.json.encode(value)
Encode Lua value to JSON string
vim.json.encode({a=1}) → '{"a":1}'
vim.json.decode(str)
Decode JSON string to Lua value
vim.json.decode('{"x":42}').x → 42
Regular expressions
vim.regex(pattern, flags?) creates a regex object wrapping JavaScript’s RegExp. This uses ECMAScript regex syntax, not Vim regex syntax.
Method
Description
Example
vim.regex(pattern, flags?)
Create a regex object. flags is optional (e.g. "g", "i", "gi")
local re = vim.regex("\\d+")
re:match_str(str)
Returns 0-based start, end byte offsets of first match, or nil
Replace match(es). Use "g" flag for global replace
vim.regex("o", "g"):replace("foo", "0") → "f00"
re:test(str)
Returns true if pattern matches, false otherwise
vim.regex("^hello"):test("hello world") → true
Invalid patterns raise a Lua error catchable with pcall:
local ok, err = pcall(vim.regex, "[invalid")-- ok = false, err contains "invalid regular expression"
ECMAScript regex, not Vim regex
vim.regex() uses JavaScript’s RegExp engine (ECMAScript syntax), not Vim’s regex syntax. This means patterns like \d, \w, [A-Z], and lookahead/lookbehind work as in JavaScript. Vim-specific atoms like \v, \m, \zs are not supported.
Return value convention
All match methods return 0-based byte offsets, matching Neovim’s vim.regex():match_str() convention. This differs from Lua’s string.find() which returns 1-based indices.
Notifications
Function
Description
Example
vim.notify(msg, level?)
Show notification. Level from vim.log.levels (default: INFO). ERROR/WARN show Notice + console.
vim.notify("Saved!", vim.log.levels.INFO)
vim.notify_once(msg, level?)
Same as vim.notify but only shows once per message
vim.notify_once("Migration complete")
vim.log.levels
Level
Value
Behavior
vim.log.levels.TRACE
0
Console only
vim.log.levels.DEBUG
1
Console only
vim.log.levels.INFO
2
Obsidian Notice + console
vim.log.levels.WARN
3
Obsidian Notice + console.warn
vim.log.levels.ERROR
4
Obsidian Notice + console.error
vim.log.levels.OFF
5
No output
Snippets
Define snippets using a LuaSnip-inspired DSL. Static snippets compile to VS Code JSON at load time.
Function
Description
vim.snippet.s(name, nodes, opts?)
Create a snippet definition
vim.snippet.t(text)
Static text node
vim.snippet.i(index, default?)
Editable tabstop (index 0 = final position)
vim.snippet.c(index, choices)
Choice node (list of t() nodes)
vim.snippet.rep(index)
Mirror/repeat another tabstop
vim.snippet.fmt(str, nodes, opts?)
Format string — {} replaced by nodes in order
vim.snippet.f(fn, deps)
Function node — computes text from dependency fields
vim.snippet.d(index, fn, deps)
Dynamic node — generates sub-snippet from field values
vim.snippet.sn(index, nodes, opts?)
Snippet node — return value for d() callbacks
vim.snippet.r(index, type_name?)
Restore node — preserves edits across d() regeneration
-- Normal mode mappingvim.keymap.set("n", "<leader>w", ":w<CR>", { desc = "Save file" })-- Insert mode escapevim.keymap.set("i", "jk", "<Esc>", { desc = "Exit insert mode" })-- Multiple modesvim.keymap.set({"n", "v"}, "<leader>y", '"+y', { desc = "Yank to clipboard" })-- Normal, visual, select and operator-pending at oncevim.keymap.set("", "j", "h", { desc = "Shift home row left" })-- Function callbackvim.keymap.set("n", "<leader>e", function() vim.cmd("obcommand file-explorer:reveal-active-file")end, { desc = "Reveal in explorer" })-- Remove default mappingvim.keymap.del("n", "Q")
Mode strings
The mode argument accepts a single string, a multi-character string ("nv"), or a table ({"n", "v"}). It follows Neovim’s :h map-modes:
Mode
Applies to
n
Normal
v
Visual
x
Visual
s
Select
o
Operator-pending
i
Insert
""
Normal, Visual, Select, and Operator-pending
Choosing between vim.cmd() and vim.obsidian.leader.add()
For leader-prefixed commands that execute Obsidian commands, vim.obsidian.leader.add() is the simplest approach — it automatically registers which-key labels. vim.keymap.set with function callbacks gives you more flexibility (conditional logic, vim.fn checks, vim.notify) but requires an explicit desc option for which-key labels.
Which-key auto-resolution with function callbacks
String RHS mappings like vim.keymap.set("n", "<leader>r", ":ob app:go-back<CR>") auto-resolve the Obsidian command name in the which-key popup. Function callbacks wrapping vim.cmd("ob ...") do not — Lua functions are opaque and cannot be introspected. Always provide a desc option when using function callbacks, or use a string RHS for automatic resolution.
Buffer-local variables (vim.b)
Per-buffer variable storage, isolated by file path:
vim.b.my_flag = trueif vim.b.my_flag then vim.notify("Flag is set for this buffer")endvim.b.my_flag = nil -- delete
Returns nil for unset variables. Variables persist for the session but are not saved across plugin reloads.
Buffer-local options (vim.bo)
Read buffer-local options:
Option
Default (markdown)
Description
commentstring
%% %s %%
Comment format string
filetype
file extension
Buffer filetype
expandtab
true
Use spaces for indentation
shiftwidth
Obsidian tab size
Indent width
tabstop
Obsidian tab size
Tab character width
modifiable
true
Buffer is editable
buftype
""
Buffer type
textwidth
0
Hard-wrap width
softtabstop
Obsidian tab size
Soft tab width
iminsert
0
Input method state
fileformat
"unix"
Line ending style
local cs = vim.bo.commentstring -- "%% %s %%"local ft = vim.bo.filetype -- "md" or file extension
Writes round-trip. expandtab, shiftwidth, softtabstop, tabstop, and textwidth are forwarded to the vim engine; every other key is retained in a buffer-local store keyed by file path, so a plugin that saves and restores an option sees its own value back.
Both forms work, matching Neovim:
local ft = vim.bo.filetype -- current bufferlocal ft = vim.bo[0].filetype -- indexed; handle must be 0local tick = vim.b[0].changedtick -- same for variable scopes
Only handle 0 is valid — Obsidian has a single buffer and window. Any other handle raises.
Window-local options (vim.wo)
Reads resolve in three steps: values previously written through vim.wo, then the window-local options the plugin implements, then the global scope.
Option
Source
wrap
CodeMirror line-wrapping state
any
Falls back to vim.o — see below
local wrapped = vim.wo.wrap -- true when the editor soft-wrapslocal so = vim.wo.scrolloff -- resolves through the global scope
Info
Obsidian has a single-window model, so every window-local option other than wrap resolves to the global scope. This matches Neovim, where a :setlocal value that was never set falls back to the global one.
UI hooks (vim.ui)
Neovim’s UI-hook namespace, backed by the plugin’s picker and input modal.
Function
Returns
Notes
vim.ui.select(items, opts, on_choice)
(none)
on_choice(item, index); index is 1-based
vim.ui.input(opts, on_confirm)
(none)
on_confirm(value); nil when cancelled
vim.ui.open(path, opts?)
handle, or nil, errmsg
Desktop only
vim.ui.progress_status()
""
Always idle
vim.keymap.set('n', '<leader>b', function() vim.ui.select({ 'main', 'develop' }, { prompt = 'Branch' }, function(choice, idx) if choice then vim.notify(choice .. ' (' .. idx .. ')') end end)end)
Both select and input are non-blocking: they return immediately and call back later, so they work from inside a keymap callback. on_choice receives the original item — a table stays a table — plus its 1-based index. A cancelled selection calls on_choice(nil, nil).
opts.format_item is applied once per item when the picker opens, matching Neovim’s own default implementation.
Info
vim.ui is a plain table with no metatable, so the standard override idiom works:
local original = vim.ui.selectvim.ui.select = function(items, opts, on_choice) ... endvim.ui.select = original -- restore
This is what dressing.nvim, telescope-ui-select and snacks do.
Warning
vim.ui.open is desktop-only and returns nil, errmsg where no handler exists — the same shape Neovim uses. opts.cmd is rejected: arbitrary command execution is outside what this plugin permits. vim.ui_attach/vim.ui_detach are not implemented.
Plugin management (vim.plugins)
Register Neovim-compatible Lua plugins. The plugin supports automatic fetching from GitHub when Settings → Vim Motions → Advanced → Auto-fetch plugins is enabled.
-- Register a plugin (checks if files exist in lua/, fetches if enabled)vim.plugins.add({ 'echasnovski/mini.nvim' })-- Pin to a specific branch, tag, or commitvim.plugins.add({ 'echasnovski/mini.comment', branch = 'main' })vim.plugins.add({ 'folke/flash.nvim', tag = 'v1.0.0' })vim.plugins.add({ 'nvim-lua/plenary.nvim', commit = 'abcdef1' })-- Then use the plugin normallyrequire('mini.comment').setup({})
If the plugin files are not found and auto-fetch is disabled, a Notice is shown with download instructions. When auto-fetch is enabled, the plugin is downloaded as a tarball archive and extracted to lua/. A lock file at lua/.plugin-lock.json tracks installed versions.
Downloads retain lua/**/*.lua and queries/{lang}/{name}.scm. Query files are isolated under lua/{owner}__{repo}/queries/ so they cannot overwrite user queries or another plugin’s queries. The query cache refresh completes before vim.plugins.add() resumes Lua execution. Plugins cached by an older version must be re-fetched to acquire .scm files discarded by the old downloader; reloading configuration alone does not download missing query files from an already cached plugin. See Query files and resolution.
-- List registered pluginslocal plugins = vim.plugins.list()for _, p in ipairs(plugins) do print(p.name, p.repo, p.available)end
Buffer-local keymaps
Keymaps can be scoped to specific files using the buffer option:
Use buffer = 0 for the current file. Buffer-local keymaps are automatically swapped when switching between files.
Buffer numbers
Obsidian does not use Neovim-style buffer numbers. Only buffer = 0 (current file) is supported. Positive buffer numbers produce an error.
Keymap accumulation
When setting buffer-local keymaps inside a BufEnter autocmd, always use nvim_create_augroup with { clear = true } (as shown above). Without an augroup, each file switch adds another copy of the keymap.
Buffer content
Read and modify editor content from Lua callbacks:
Only buffer = 0 (current buffer) is supported. These functions operate on the active editor.
vim.api.nvim_buf_call(0, fn) calls fn() directly in the current buffer, propagating all return values and errors. It does not switch buffers; non-zero handles are rejected.
Window and Cursor
Function
Description
Example
vim.api.nvim_get_current_win()
Returns 0 (current window)
local win = vim.api.nvim_get_current_win()
vim.api.nvim_win_get_cursor(0)
Get cursor {line 1, UTF-8 byte col 0}
local pos = vim.api.nvim_win_get_cursor(0)
vim.api.nvim_win_set_cursor(0, {line, col})
Set cursor position (0-indexed)
vim.api.nvim_win_set_cursor(0, {5, 0})
vim.api.nvim_win_get_buf(0)
Returns 0 (current buffer)
local buf = vim.api.nvim_win_get_buf(0)
vim.api.nvim_get_current_tabpage()
Returns 0 (current tabpage)
local tab = vim.api.nvim_get_current_tabpage()
vim.api.nvim_list_wins()
Returns {0} (list of window handles)
local wins = vim.api.nvim_list_wins()
vim.api.nvim_get_mode()
Current mode {mode, blocking}
vim.api.nvim_get_mode().mode → "n"
vim.api.nvim_strwidth(text)
Display width of string
vim.api.nvim_strwidth("hello") → 5
Window and Tabpage handles
Only 0 (current) is supported for window and tabpage handles.
vim.api.nvim_win_call(0, fn) calls fn() directly in the current window, propagating all return values and errors. It does not switch windows; non-zero handles are rejected.
vim.api.nvim_win_get_config(0) returns { relative = "", focusable = true, external = false, hide = false }. An empty relative reports a non-floating window, not a Neovim floating-window implementation. Non-zero handles are rejected. For visible lines and viewport dimensions, use vim.fn.getwininfo().
Function
Current-window result
vim.api.nvim_win_is_valid(win)
True only for integer handle 0; other integers false. Invalid types/arity raise.
vim.api.nvim_win_get_width(0)
CM6 viewport width in character cells, floored; 0 without an editor.
vim.api.nvim_win_get_height(0)
CM6 viewport height in rows, floored; 0 without an editor.
vim.api.nvim_win_get_position(0)
Synthetic grid origin {0,0}.
vim.api.nvim_win_get_number(0)
Synthetic ordinal 1.
These APIs require exactly one integer argument; nonzero handles raise except in nvim_win_is_valid. They do not expose Obsidian pane IDs or implement multiple Neovim windows. The global columns/lines fallbacks remain separate from live geometry.
Define custom commands that are usable from the : ex command line.
Function
Description
Example
vim.api.nvim_command(command)
Execute an ex command
vim.api.nvim_command("set nu")
vim.api.nvim_create_user_command(name, cmd, opts)
Define custom ex command
see below
vim.api.nvim_del_user_command(name)
Delete custom ex command
vim.api.nvim_del_user_command("Today")
-- Simple aliasvim.api.nvim_create_user_command("W", "w", {})vim.api.nvim_create_user_command("Q", "q", {})-- Command calling a Lua functionvim.api.nvim_create_user_command("Today", function() vim.cmd("obcommand daily-notes:open-today") vim.notify("Opened today's note")end, {})-- Command with argumentsvim.api.nvim_create_user_command("Open", function(opts) vim.cmd("obcommand switcher:open " .. opts.args)end, {})-- Toggle commandvim.api.nvim_create_user_command("SpellToggle", function() -- Toggle a user variable and notify if vim.g.spell_enabled then vim.g.spell_enabled = false vim.notify("Spell check disabled") else vim.g.spell_enabled = true vim.notify("Spell check enabled") endend, {})
Registered commands are immediately available via :CommandName in the ex command line. The function callback receives an opts table with an args field containing the argument string.
nvim_replace_termcodes now emits actual bytes in a Lua string, including internal special-key and modifier sequences; it is not an identity function. Unknown notation remains literal. from_part is accepted but ignored. special controls special-key conversion and do_lt controls <lt> conversion. Unlike Neovim, this implementation also expands <lt> with do_lt = true when special = false.
nvim_feedkeys decodes these bytes into the fork’s notation at the engine boundary. Convert special-key notation before feeding it; plain angle-bracket text passed directly to nvim_feedkeys is treated literally. Mode "n" disables remapping; "m" or "" enables it. Other mode flags are ignored with a warning.
local keys = vim.api.nvim_replace_termcodes("<Esc>j", true, true, true)vim.api.nvim_feedkeys(keys, "n", false)
Key observation
vim.on_key(fn, ns?) registers a callback and returns its namespace ID. Omitting ns or using 0 allocates an ID; reusing an ID replaces its callback. vim.on_key(nil, ns) removes it, and vim.on_key() returns the number of registered callbacks. Callbacks receive (key, typed) as encoded key-byte strings. A failing callback is logged and removed; all callbacks are detached before the Lua state closes.
local ns = vim.api.nvim_create_namespace("my-key-observer")vim.on_key(function(key, typed) -- Observe only: both arguments describe the same physical input. print(key, typed)end, ns)-- Later: vim.on_key(nil, ns)
Pre-mapping observation
Unlike Neovim’s post-mapping hook, this observes physical key input before mappings. Mapped expansions and programmatic feedkeys are not separately observed, both callback arguments carry the same physical input, and callback return values cannot discard keys. Desktop/popout and mobile dispatch reuse existing key listeners; modifier-only and composition events are ignored. Full parity requires changing key processing itself.
Autocommands
Vim Motions supports a Neovim-compatible autocommand system for reacting to editor events.
Function
Description
Example
vim.api.nvim_create_autocmd(event, opts)
Register autocommand
see below
vim.api.nvim_create_augroup(name, opts)
Create/get autocommand group
see below
vim.api.nvim_del_autocmd(id)
Delete autocommand
vim.api.nvim_del_autocmd(42)
vim.api.nvim_del_augroup_by_name(name)
Delete autocommand group
vim.api.nvim_del_augroup_by_name("my-config")
vim.api.nvim_clear_autocmds(opts)
Clear autocommands
vim.api.nvim_clear_autocmds({ group = g })
Per-view mode events
Mode events (InsertEnter, InsertLeave, ModeChanged) and cursor/yank/cmdline events (CursorMoved, CursorHold, TextYankPost, CmdlineEnter, CmdlineLeave) fire per-view across all editors — split panes, popover editors, and canvas card text inputs — when using the bundled vim fork (recommended setup). This means autocmd callbacks work in every editor, not just the active leaf.
Supported events
Event
When it fires
Pattern support
InsertEnter
Entering insert or replace mode (per-view)
No
InsertLeave
Leaving insert or replace mode (per-view)
No
CursorMoved
After cursor moves in normal mode (per-view)
No
CursorHold
After cursor is idle for updatetime ms (per-view)
No
ModeChanged
Any mode transition (per-view)
"old:new" with * wildcard
BufEnter
A file becomes the active note
Vault-relative path globs ("*.md", "projects/**")
BufLeave
A file is deactivated (switching away)
Vault-relative path globs
BufWritePre
Before saving a file
Vault-relative path globs
BufWritePost
After saving a file
Vault-relative path globs
LeafEnter
A leaf (tab/pane) gains focus (debounced 50ms)
No
LeafLeave
A leaf (tab/pane) loses focus
No
FileType
After BufEnter when filetype is detected
No
FocusGained
Obsidian window gains focus
No
FocusLost
Obsidian window loses focus
No
TextYankPost
After yank, delete, or change operation (per-view)
No
OilEnter
An oil explorer buffer becomes active
No
OilLeave
Leaving an oil explorer buffer
No
CmdlineEnter
Opening :, /, or ? prompt (per-view, active only)
No (data.cmdtype = ":", "/", or "?")
CmdlineLeave
Closing a command-line prompt (per-view, active only)
No (data.cmdtype = ":", "/", or "?")
CursorHold timing
Configure the idle timeout with vim.opt.updatetime = 1000 (milliseconds). Default is 4000ms, matching Neovim.
FileType detection
FileType detection is based on file extension (e.g. md → markdown, ts → typescript, py → python). If a filetype is unknown, the FileType event does not fire.
Usage examples
-- Augroup with clear (recommended for config reloads)local g = vim.api.nvim_create_augroup("my-config", { clear = true })-- Notify on insert modevim.api.nvim_create_autocmd("InsertEnter", { group = g, callback = function() vim.notify("Insert mode") end,})-- Per-folder settingsvim.api.nvim_create_autocmd("BufEnter", { group = g, pattern = "projects/**", callback = function(ev) vim.opt.shiftwidth = 4 end,})-- React to mode changesvim.api.nvim_create_autocmd("ModeChanged", { group = g, pattern = "*:i", callback = function(ev) -- ev.data.old_mode and ev.data.new_mode available end,})-- Auto-save on focus lostvim.api.nvim_create_autocmd("FocusLost", { group = g, callback = function() vim.cmd("w") end,})-- Track yank operationsvim.api.nvim_create_autocmd("TextYankPost", { group = g, callback = function(ev) -- ev.data.operator ("y", "d", "c") -- ev.data.regcontents (table of lines) -- ev.data.regtype ("V" linewise, "v" charwise) -- ev.data.regname (register name, e.g. "a", "" for default) -- ev.data.visual (boolean) end,})
Callback event data
The callback receives a table with the following fields:
{ event = "BufEnter", file = "projects/todo.md", -- vault-relative match = "projects/todo.md", buf = 0, -- always 0 id = 42, -- autocmd ID group = 1, -- group ID or nil data = nil, -- event-specific (TextYankPost, ModeChanged)}
For LeafEnter and LeafLeave, data includes { type = "markdown", leaf_id = "..." } and match is set to the leaf type. For FileType, match is the detected filetype (for example, markdown).
Augroup management
local g = vim.api.nvim_create_augroup("name", { clear = true })vim.api.nvim_del_autocmd(id)vim.api.nvim_del_augroup_by_name("name")vim.api.nvim_clear_autocmds({ group = g, event = "InsertEnter" })
ModeChanged pattern format
"n:i": normal to insert
"*:i": any mode to insert
"i:*": insert to any mode
"*:*": any transition
vim.keymap.set options
Option
Type
Default
Description
desc
string
(none)
Description shown in which-key popup. Required for function callbacks — string RHS with :ob/:obcommand auto-resolves the command name, but function callbacks are opaque and need an explicit description.
noremap
boolean
true
Non-recursive mapping
remap
boolean
false
Recursive mapping (inverse of noremap)
silent
boolean
(none)
Accepted but no effect in Obsidian
nowait
boolean
(none)
Accepted but no effect in Obsidian
buffer
number/boolean
(none)
Buffer-local keymap (0 or true = current file). See Buffer-local keymaps above. Non-zero numbers error.
expr
boolean
false
If true, the callback must return a string that is fed as keystrokes. Sync only — async APIs cannot be used in expr callbacks. String rhs not supported for expr.
Obsidian namespace
Obsidian-specific APIs that don’t exist in Neovim. Available as vim.obsidian or vim.ob.
Function
Returns
Example
vim.obsidian.vault_name()
Vault name
vim.obsidian.vault_name()
vim.obsidian.app_version()
Obsidian version string
vim.obsidian.app_version()
vim.obsidian.plugin_version()
Plugin version string
vim.obsidian.plugin_version()
vim.obsidian.run_command(id)
Execute Obsidian command by ID
vim.obsidian.run_command("app:reload")
vim.obsidian.list_commands()
Table of {id, name}
vim.obsidian.list_commands()
vim.obsidian.open_file(path)
Open a vault file
vim.obsidian.open_file("notes/todo.md")
vim.obsidian.pick(source, opts?)
Open a picker source
vim.obsidian.pick("files")
vim.obsidian.current_file()
Table {path, name, extension, basename} or nil
vim.obsidian.current_file().path
vim.obsidian.vault_path()
Vault absolute path (desktop only)
vim.obsidian.vault_path()
Picker sources include files, buffers, commands, headings, outline, backlinks, tags, recent, marks, registers, grep, and resume (reopen the last picker session).
-- Open files pickervim.obsidian.pick('files')-- Open grep with pre-filled queryvim.obsidian.pick('grep', { query = 'todo' })-- Resume last sessionvim.obsidian.pick('resume')
Workspace and leaf management
Function
Returns
Example
vim.ob.get_active_leaf()
Table {id, type, pinned}
local leaf = vim.ob.get_active_leaf()
vim.ob.get_leaf_type()
View type string (e.g., "markdown")
if vim.ob.get_leaf_type() == "pdf" then
vim.ob.list_leaves()
Table of leaf info tables
for _, leaf in ipairs(vim.ob.list_leaves())
vim.ob.is_markdown_view()
Boolean
if vim.ob.is_markdown_view() then
vim.ob.get_leaf_for_file(path)
Leaf info table or nil
vim.ob.get_leaf_for_file("note.md")
vim.ob.focus(direction)
Boolean (success)
vim.ob.focus("right")
vim.ob.split(direction)
Boolean (success)
vim.ob.split("vertical")
vim.ob.close_leaf()
Boolean (success)
vim.ob.close_leaf()
Note operations
Function
Description
Example
vim.ob.follow_link()
Follow link under cursor
vim.ob.follow_link()
vim.ob.backlinks()
Open backlinks for current note
vim.ob.backlinks()
vim.ob.daily()
Open today’s daily note
vim.ob.daily()
vim.ob.search()
Open global search
vim.ob.search()
vim.ob.tags()
Open tags view
vim.ob.tags()
vim.ob.new_note()
Create new note
vim.ob.new_note()
vim.ob.rename()
Rename current note
vim.ob.rename()
vim.ob.toggle_checkbox()
Toggle checkbox on current line
vim.ob.toggle_checkbox()
vim.ob.template()
Open template picker
vim.ob.template()
vim.ob.meta — Note metadata
Function
Returns
Description
vim.ob.meta.frontmatter(path?)
table or nil
YAML frontmatter as key-value pairs
vim.ob.meta.tags(path?)
string[]
Combined body + frontmatter tags
vim.ob.meta.links(path?)
{link, display, original}[]
Outgoing wikilinks and markdown links
vim.ob.meta.backlinks(path?)
string[]
Source file paths linking to this note
vim.ob.meta.headings(path?)
{heading, level}[]
Headings with H1-H6 level
vim.ob.meta.embeds(path?)
{link, display}[]
Embedded content (![[...]])
vim.ob.meta.aliases(path?)
string[]
YAML aliases
vim.ob.meta.tasks(path?)
{text, status, line}[]
Checklist items with status char
vim.ob.meta.lists(path?)
{text, line, indent}[]
All list items
All meta.* functions default to the current file when path is omitted.
vim.ob.fs — Vault filesystem
Function
Description
Async
vim.ob.fs.read(path)
Read file content as string
Yes
vim.ob.fs.readlines(path)
Read file content as table of lines
Yes
vim.ob.fs.files(pattern?)
Markdown files matching optional glob
No
vim.ob.fs.all_files()
All files in vault
No
vim.ob.fs.folders()
All folders
No
vim.ob.fs.exists(path)
Check if file exists
No
vim.ob.fs.stat(path?)
File stats {ctime, mtime, size}
No
vim.ob.fs.create(path, content?)
Create new file
No
vim.ob.fs.write(content) or write(path, content)
Overwrite file content
No
vim.ob.fs.append(content) or append(path, content)
Append to file
No
vim.ob.fs.rename(new_path) or rename(path, new_path)
Rename (updates backlinks)
No
vim.ob.fs.move(dest) or move(path, dest)
Move to folder or new path
No
vim.ob.fs.trash(path?)
Move to trash (user preference)
No
Async functions yield the Lua coroutine internally and resume when the operation completes. They work in keymap callbacks, autocmd handlers, timer callbacks, user commands, and at the top level of init.lua. They are blocked in snippet f()/d() nodes. Errors from async functions are catchable with pcall.
local content = vim.ob.fs.read("notes/todo.md")vim.notify("File has " .. #content .. " chars")local lines = vim.ob.fs.readlines("notes/todo.md")vim.notify("File has " .. #lines .. " lines")local ok, err = pcall(vim.ob.fs.read, "nonexistent.md")if not ok then vim.notify("Error: " .. err) end
Write operations silently reject paths inside .obsidian/. Write/append/rename/move/trash default to the current file when path is omitted.
vim.ob.ui — UI control
Function
Description
vim.ob.ui.sidebar(side, state?)
Toggle sidebar ("left", "right")
vim.ob.ui.command_palette()
Open command palette
vim.ob.ui.quickswitch()
Open quick switcher
vim.ob.ui.notice(msg)
Show notification (alias for vim.notify)
vim.obsidian.oil — Oil explorer
Functions for controlling the oil explorer. All functions are also available as ex commands (e.g., :oilparent).
Function
Description
vim.obsidian.oil.open(path)
Open oil for a directory
vim.obsidian.oil.close()
Close oil buffer
vim.obsidian.oil.parent()
Navigate to parent directory
vim.obsidian.oil.root()
Navigate to vault root
vim.obsidian.oil.refresh()
Refresh current listing
vim.obsidian.oil.toggle_hidden()
Toggle dotfile visibility
vim.obsidian.oil.cycle_sort()
Cycle sort order
vim.obsidian.oil.yank_path()
Copy file path to clipboard
vim.obsidian.oil.reveal()
Reveal in Obsidian file explorer
vim.obsidian.oil.open_entry()
Open file/directory under cursor
Use OilEnter / OilLeave autocmd events to set buffer-local keymaps:
Define key bindings for non-editor contexts (graph view, canvas, PDF viewer, file explorer, reading mode). These bindings work when no editor is focused.
Function
Description
vim.obsidian.keymap.set(lhs, rhs, opts?)
Create a global key mapping
vim.obsidian.keymap.del(lhs)
Remove a global key mapping
The rhs must be either :obcommand <command-id> or :<ex-command>:
The desc option automatically creates a label in the global which-key popup.
String-only RHS
Only string commands are supported as RHS (:obcommand ... or :ex-command). Lua function callbacks are not supported for global keymaps. Use vim.api.nvim_create_user_command to define a named command, then reference it.
No mode parameter
Global keymaps are mode-agnostic — they don’t use vim modes. The noremap option is accepted for compatibility but has no effect.
Which-key labels (vim.obsidian.whichkey)
Set group and command labels for the which-key popup. Labels from vim.keymap.set’s desc option are applied automatically for editor keymaps, but this API adds group labels, labels for keys you didn’t create, and global context labels.
The context option defaults to "editor". Use { context = "global" } for labels in the non-editor which-key overlay.
The add() function accepts a table of entries for batch configuration, similar to which-key.nvim’s wk.add():
local wk = vim.obsidian.whichkeywk.add({ { "<leader>f", group = "Find" }, { "<leader>g", group = "Git" }, { "<leader>t", group = "Table" }, { "<leader>w", desc = "Save file" }, { "<leader>q", desc = "Close tab" },})
Each entry uses group for prefix labels or desc for individual binding labels. The context and mode fields are supported per entry (mode is reserved for future use).
Valid shapes: "block", "bar", "underline", "hollow". Modes not specified keep their current value. This is equivalent to vim.opt.guicursor but uses a table instead of Neovim’s format string.
See cursor-shapes for the full list of modes and shapes.
Mode prompts (vim.obsidian.modeprompt)
Set the status bar mode text for multiple modes in a single call.
Valid mode keys: normal, insert, visual, replace, visual_line, visual_block, select, vreplace, command, search, insert_normal. This is equivalent to setting individual vim.g.mode_prompt_* variables but allows batch configuration.
See status-bar for details on status bar customization.
Custom surround pairs (vim.obsidian.surround)
Define custom character-to-delimiter mappings for surround operations (ys, ds, cs).
Function
Description
vim.obsidian.surround.set(trigger, opts)
Register a custom surround pair
vim.obsidian.surround.del(trigger)
Remove a custom surround pair
vim.obsidian.surround.add(entries)
Batch-register custom surround pairs
vim.obsidian.surround.set("l", { left = "[[", right = "]]" })vim.obsidian.surround.set("m", { left = "$$", right = "$$" })vim.obsidian.surround.add({ { "l", left = "[[", right = "]]" }, { "m", left = "$$", right = "$$" }, { "e", left = "\\begin{equation}", right = "\\end{equation}" },})
After registration, ysiw l wraps a word in [[word]], ds l removes surrounding [[...]], and cs l m changes [[...]] to $$...$$.
The trigger must be a single character, and left/right must both be
non-empty. Built-in surround characters can be overridden — set("(", { left = "(", right = ")" })
drops the inner spaces that ysiw( adds by default. An alias resolves to its
canonical character first, so overriding ) also changes b. Overriding t, f, or < replaces their interactive behaviour (tag target, function-call
target, and tag-name prompt respectively). Dropping an override from the config
and reloading restores the built-in. See surround > Overriding the built-in pairs.
Fork mode required
Custom surround pairs require the plugin’s bundled fork mode. Disable Obsidian’s built-in Vim mode in Settings → Editor → Vim key bindings for full support.
Leader bindings (vim.obsidian.leader)
Convenience API for binding leader key sequences to Obsidian commands. Automatically prepends the leader key, adds the :ob command prefix, and registers a which-key label from the desc option.
The second argument is an Obsidian command ID (the same IDs shown by :ob with no arguments). For general-purpose keymaps or Lua function callbacks, use vim.keymap.set instead.
Input method switching (vim.obsidian.im)
Programmatic control over input method (IM) switching for CJK users (per-view across all editors). Requires an external IM switching binary and configuration in Settings → Vim Motions → Input method. Desktop only.
Function/Property
Returns
Description
vim.obsidian.im.get()
string|nil
Current IM identifier (cached), or nil if unavailable
vim.obsidian.im.set(id)
Switch to specific IM identifier
vim.obsidian.im.save()
Save current IM for the active editor view
vim.obsidian.im.restore()
Restore saved IM for the active editor view
vim.obsidian.im.enabled
boolean
Read/write: master toggle for IM switching
vim.obsidian.im.auto
boolean
Read/write: auto-wire to InsertEnter/InsertLeave
When vim.obsidian.im.auto is true (default), the plugin automatically saves/restores IM on mode changes across all editor views. Set it to false for manual control:
These map directly to plugin UI elements via CSS custom properties:
Group
Controls
EasyMotionTarget
EasyMotion jump labels
EasyMotionShade
EasyMotion dimmed text
HintTarget
Hint mode labels
StatusLineNormal
Normal mode status bar
StatusLineInsert
Insert mode status bar
StatusLineVisual
Visual mode status bar
StatusLineReplace
Replace mode status bar
StatusLineVLine
V-Line mode status bar
StatusLineVBlock
V-Block mode status bar
StatusLineCommand
Command mode status bar
StatusLineSearch
Search mode status bar
StatusLineSelect
Select mode status bar
StatusLineVReplace
V-Replace mode status bar
Case-sensitive group names
Highlight group names are case-sensitive. Use the exact casing shown in the table above (e.g., EasyMotionTarget, not easymotiontarget). This differs from Neovim, where highlight group names are case-insensitive.
User-defined highlight groups
Custom groups generate CSS classes (.vim-hl-GroupName) that can be used in CSS snippets:
vim.api.nvim_create_namespace() returns a unique integer ID per namespace name (matching Neovim). The same name always returns the same ID. Namespace 0 is the global namespace. Namespaces are used for highlight groups, extmarks, and clearing decorations.
Underline styles
Only one underline style can be active per highlight group. If multiple underline attributes (undercurl, underdouble, underdotted, underdashed) are set, only the first one takes effect.
UI
Function
Description
Example
vim.api.nvim_echo(chunks, history, opts)
Show Obsidian notification (Notice)
vim.api.nvim_echo({{"Hello", "None"}}, true, {})
Options
vim.o and vim.go share global option access. Reads resolve in this order: engine value → compatibility shadow store → defaults below → nil. Valid engine values such as false, 0, and "" remain authoritative. Writes are retained in the shadow store even when the engine does not implement the option; retaining a value does not make the corresponding Neovim feature functional. This fallback behavior applies to vim.o/vim.go, not generally to vim.opt or the option getter functions.
Option
Fallback default
eventignore
""
selection
"inclusive"
cmdheight
1
columns
80
lines
24
cpo
"aABceFs"
background
"light" or "dark" from the active Obsidian theme
columns and lines are compatibility defaults, not measured viewport dimensions; use vim.fn.getwininfo() for geometry. operatorfunc is special-cased consistently across vim.opt, vim.o, vim.go, and all four global option functions below; see Custom operators.
Extmark API subset for virtual text and highlights. Extmarks are anchored to buffer positions and move with text edits. Byte columns follow the normalization deviation in Coordinate contract; sign text remains unsupported.
Virtual text chunks (list of {text, highlight_group} pairs)
virt_text_pos
string
Position: "overlay" (on top), "eol" (after line), "inline" (within text)
hl_group
string
Highlight group for the extmark range
end_row
number
Exclusive range end row (0-based)
end_col
number
Exclusive range end byte column (0-based); interior bytes normalize up
hl_eol
boolean
Extend the highlight to the end of the range’s final line
priority
number
Decoration ordering (default: 4096); not guaranteed visual precedence over native CM6 decorations
id
number
Reuse an existing extmark ID (update instead of create)
local ns = vim.api.nvim_create_namespace("my-plugin")-- Virtual text at end of linevim.api.nvim_buf_set_extmark(0, ns, 0, 0, { virt_text = {{"← cursor here", "Comment"}}, virt_text_pos = "eol",})-- Overlay textvim.api.nvim_buf_set_extmark(0, ns, 2, 5, { virt_text = {{"FIXME", "WarningMsg"}}, virt_text_pos = "overlay",})-- Clear all extmarks in namespacevim.api.nvim_buf_clear_namespace(0, ns, 0, -1)
Buffer argument
Only buffer = 0 (current buffer) is supported. Extmark positions are 0-indexed rows and UTF-8 byte columns. Columns beyond the line’s byte length error rather than clamp. Both getters now return modeled details as Lua tables with byte end_col and virt_text as {text, highlight_group} pairs; previously the scalar-only serializer silently returned nil for details.
Supported options subset
This is a subset, not full Neovim extmark parity. sign_text, virt_lines, line_hl_group, spell, conceal, and undo_restore remain unavailable. The coordinate and serialization repairs do not add new options.
vim.version
Version parsing and comparison utilities matching Neovim’s vim.version module.
Useful for comparing keys returned by vim.fn.getcharstr() against special key names.
vim.treesitter
Do not hold a node across a re-parse
A TSNode returned by get_node(), iter_captures(), tree:root() or any
node method is backed by memory inside the WebAssembly parser. Calls such as get_node() and get_parser() re-parse whenever the document has changed,
and a re-parse can move that memory, which leaves an earlier node pointing at
a buffer that no longer exists. Reading it can crash Obsidian’s window rather
than raise a Lua error.
Read what you need from a node immediately — :type(), :range(), text —
and store those values. Do not keep a node in a table, an upvalue, or across
a vim.schedule() or autocommand boundary.
-- risky: node is used after another call re-parseslocal node = vim.treesitter.get_node()vim.schedule(function() print(node:type()) end)-- safe: the value is extracted before anything else can parselocal node_type = vim.treesitter.get_node():type()vim.schedule(function() print(node_type) end)
This applies whether or not the Neovim backend is enabled; the Lua parser
cache is independent of it.
The Lua Treesitter API uses bundled web-tree-sitter WASM grammars for markdown, markdown_inline, and html, alongside Obsidian’s native Lezer parser. Lua configuration loading awaits runtime initialization and query-file preloading so synchronous query.get() calls in init.lua can use the snapshot immediately. This does not replace native highlighting or implement every Neovim Treesitter UI API; see known-limitations > Treesitter integration (`vim.treesitter`).
Named queries
API
Behavior
vim.treesitter.query.parse(lang, text)
Compile an inline query
vim.treesitter.query.set(lang, name, text)
Register a per-Lua-state override; compile lazily on get()
vim.treesitter.query.get(lang, name)
Resolve and cache a named query; return nil if unavailable
List resolved vault-relative physical paths; bundled constants have no paths and are omitted
query:iter_captures(node, source, start?, stop?)
Iterate captures using document text and optional row bounds
query:iter_matches(node, source, start?, stop?)
Iterate matches using the same source/row convention
For query iteration, source is a source string or buffer handle 0 (uses the parsed node’s retained source). Row bounds follow the source argument. File-loaded queries use this document text for predicates, rather than an empty query-owned source.
<vault>/lua/queries/{lang}/{name}.scm user queries.
<vault>/lua/{plugin}/queries/{lang}/{name}.scm plugin queries, with plugin roots ordered lexically. Auto-fetched plugins use {owner}__{repo} as their directory name.
Bundled textobjects query constants for markdown, markdown_inline, and html.
The first non-extending file is the base; later ordinary base files are ignored. Leading ;; extends modelines append a file to the selected base (a user extension can therefore augment the bundled fallback). Leading ;; inherits: language,other_language modelines load inherited sources recursively before the base and extensions. Optional (language) entries apply to direct resolution but are skipped when resolving an included language. query.get_files(..., true) uses that included-language behavior. Cycles are diagnosed and skipped. Modelines must be at the start of the file in consecutive comment lines.
An explicit query.set() override normally replaces file/bundled content. Its own leading ;; extends or ;; inherits: modelines can include file-based sources. Compiled hits and misses are invalidated when the snapshot refreshes; existing returned query objects remain usable until their Lua state closes.
For example, save this as lua/queries/markdown/textobjects.scm to extend the bundled query:
;; extends(paragraph) @paragraph.outer
After reloading configuration:
local query = vim.treesitter.query.get("markdown", "textobjects")local files = vim.treesitter.query.get_files("markdown", "textobjects")-- files contains vault-relative .scm paths, not bundled constants.
Reloads and cached plugins
.scm edits require a configuration reload; there is no live query-file watcher. Re-fetch plugins cached before query-file support to obtain previously discarded .scm files. A successful plugin fetch refreshes the snapshot before Lua resumes.
Resource limits
Query resolution allows 128 KiB per file, a 4 MiB snapshot, 512 KiB per combined query, 64 resolved sources, and 16 inheritance levels. Exceeding a limit logs a diagnostic and skips the affected content. Malformed sources are also diagnosed and skipped rather than crashing Lua.
The old .scm loading gap affected query-dependent integrations such as nvim-treesitter-textobjects. It did not block the core of mini.ai or mini.surround: their Treesitter textobjects are opt-in and default to use_nvim_treesitter = false. Resolving queries does not imply that every API required by those plugins is supported.
When to use Lua vs Vimrc
Use init.lua (recommended) when you need conditional logic (per-vault config), function-based keymaps, or prefer Neovim-style Lua syntax
Use vimrc for simple key mappings and option settings if you prefer traditional Vimscript syntax
Both can be used together: init.lua loads after vimrc, and Lua values override vimrc values on conflict
Loading order
The plugin follows a specific override hierarchy:
Settings UI values (base)
Vimrc values override Settings UI
init.lua values override both
Override hierarchy
This hierarchy differs from Neovim, which typically uses either init.lua or .vimrc, but not both simultaneously. In Vim Motions, they are additive.
Unsupported Neovim APIs
Obsidian is not Neovim. Many Neovim-specific APIs are not available in this sandboxed environment.
Obsidian is not Neovim
The following Neovim APIs are not available: vim.lsp, vim.diagnostic. Attempting to use them produces a clear error message. vim.treesitter supports parsing and named queries with limitations — see vim.treesitter. vim.api is partially supported (69 real nvim_* implementations), as is vim.fn (92 real implementations with async callbacks). Registered compatibility stubs return placeholders or intentionally reject; names outside the registries error on read. These counts do not establish plugin compatibility or whole-shim byte correctness. vim.version, vim.validate, and vim.keycode are also available. The Lua runtime is sandboxed: only 7 standard libraries are loaded (_G, string, table, math, coroutine, utf8, os). The io, debug, and package libraries are not available (but package.loaded and package.path are provided by the plugin’s require() implementation). require() loads modules from lua/ beside the configuration and at the vault root. load(chunk) compiles string chunks. dofile and loadfile are disabled. rawget, rawset, and rawequal are available for Neovim compatibility.
collectgarbage behavior
collectgarbage() is available with all standard modes. Since fengari has no garbage collector, behavior differs from PUC-Rio Lua:
Mode
Behavior
"collect"
Drains the __gc finalizer queue for unreachable userdata
"count"
Returns 0, 0 (no memory tracking)
"isrunning"
Returns false
Other modes
No-op, returns 0
__gc metamethods on userdata are supported via FinalizationRegistry. Finalizers fire when userdata becomes unreachable from JavaScript and the queue is drained (at collectgarbage("collect"), outermost pcall return, or plugin unload). Finalization timing is non-deterministic and ordering is unspecified. __gc on tables is not supported. Errors in __gc are silently swallowed.
Keymapping mode reference
Mode string
Context
Description
'n'
Normal
Normal mode mappings
'i'
Insert
Insert mode mappings
'v'
Visual
Visual mode (same as 'x')
'x'
Visual
Visual mode (alias for 'v')
's'
Select
Select mode only
'o'
Operator-pending
Operator-pending mode (e.g., text objects for d, c, y)
Difference from Neovim
In Neovim, 'v' maps to both visual and select mode. In Vim Motions, 'v' maps to visual mode only. Use {"v", "s"} to map in both visual and select modes.
Unsupported modes
Command-line ('c'), terminal ('t'), and insert+command ('!') modes are not supported.
Multiple modes can be specified as a table: vim.keymap.set({"n", "v"}, ...).
Autocmd event data reference
Every autocmd callback receives an event table with these common fields:
Field
Type
Description
event
string
Event name (e.g., "BufEnter")
file
string
Vault-relative file path
match
string
Pattern match string
buf
number
Buffer number (always 0)
id
number
Autocmd ID
group
number or nil
Augroup ID (nil if no group)
data
table or nil
Event-specific data (see below)
Per-event data fields
Most events set data = nil. Only these events provide event-specific data:
TextYankPost:
Field
Type
Description
operator
string
Operator used ("y", "d", "c")
regcontents
table
Table of yanked lines
regtype
string
"V" (linewise), "v" (charwise)
regname
string
Register name (e.g., "a", "" for default)
visual
boolean
Whether the yank was from visual mode
ModeChanged:
Field
Type
Description
old_mode
string
Mode before transition
new_mode
string
Mode after transition
All other events (InsertEnter, InsertLeave, CursorMoved, CursorHold, BufEnter, BufLeave, BufWritePre, BufWritePost, FocusGained, FocusLost): data = nil.
Highlight group CSS reference
Plugin-defined highlight groups map to CSS custom properties. User-defined groups generate CSS classes.
Plugin groups → CSS variables
Group
CSS variable
Controls
EasyMotionTarget
--vim-motions-em
EasyMotion jump labels
EasyMotionShade
--vim-motions-em-shade
EasyMotion dimmed text
HintTarget
--vim-motions-hint
Hint mode labels
StatusLineNormal
--vim-pl-normal
Normal mode status bar
StatusLineInsert
--vim-pl-insert
Insert mode status bar
StatusLineVisual
--vim-pl-visual
Visual mode status bar
StatusLineReplace
--vim-pl-replace
Replace mode status bar
StatusLineVLine
--vim-pl-v-line
V-Line mode status bar
StatusLineVBlock
--vim-pl-v-block
V-Block mode status bar
StatusLineCommand
--vim-pl-command
Command mode status bar
StatusLineSearch
--vim-pl-search
Search mode status bar
StatusLineSelect
--vim-pl-select
Select mode status bar
StatusLineVReplace
--vim-pl-vreplace
V-Replace mode status bar
Plugin groups update CSS custom properties on the document root (:root). For example, setting fg on StatusLineNormal updates --vim-pl-normal-fg.
User-defined groups
Custom highlight groups generate a CSS class .vim-hl-{GroupName}. Use these in CSS snippets to style custom elements:
The Lua runtime runs in a sandboxed Lua 5.3 environment (a browser-only version of fengari, absorbed into the monorepo and converted to TypeScript ESM).
os.getenv, os.remove, os.rename, and os.tmpname require desktop (Node.js). On mobile, they return nil. os.execute and os.exit are permanently blocked on all platforms.
Not available
Library/function
Reason
io
Stripped from runtime (file system access)
debug
Not loaded by plugin (security)
package (native)
Stripped from runtime; plugin provides package.loaded/package.path and a custom require()
dofile, loadfile
Disabled (no direct file loading)
os.execute
Permanently blocked (arbitrary shell execution is a security risk)
os.exit
Permanently blocked (would terminate Obsidian)
Execution limits
Config load instruction limit: 1,000,000 Lua VM instructions per execution. Scripts exceeding this limit are terminated with a timeout error.
Runtime callback instruction limit: 500,000 instructions for function keymaps, user commands, autocmd handlers, and timer callbacks. 100,000 instructions for snippet dynamic nodes (f()/d()). An infinite loop in a callback shows a throttled error Notice (5-second cooldown) and Obsidian remains responsive.
Error handling: Syntax errors, runtime errors, and instruction limit timeouts are caught and displayed as an Obsidian Notice. The plugin continues to load normally.
Error handling
Syntax errors and runtime errors show an Obsidian Notice with the error message. The plugin continues to load normally. Check the developer console for details.