rvpm
Rust-based Vim Plugin Manager — a fast, pre-compiled plugin manager for Neovim

rvpm clones plugins in parallel, links merge = true plugins into a single
runtime-path entry, and ahead-of-time compiles a static loader.lua that
sources everything without any runtime glob cost.
Why rvpm?
- CLI-first — manage plugins from your terminal, not from inside Neovim
- TOML config — declarative plugin specs with Tera template support
- Pre-compiled loader —
rvpm generatewalks plugin directories at CLI time and bakes the file list intoloader.lua; Neovim just sources a fixed list ofdofile()/sourcecalls - Full lazy-loading —
on_cmd,on_ft,on_map,on_event,on_path,on_source, auto-detectedColorSchemePre, anddepends-aware loading - Merge optimization —
merge = trueplugins share a single rtp entry - Plugin discovery TUI —
rvpm storebrowses the GitHubneovim-plugintopic with live README preview and one-keystroke install - Resilient — cyclic dependencies, missing plugins, and config errors produce warnings, not crashes
- Fast startup — 9-phase loader model with
vim.go.loadplugins = falseand pre-globbedplugin//ftdetect//after/plugin/file lists - Global hooks —
before.lua/after.luaalongsideconfig.tomlare auto-detected at generate time; no config entry needed - Lazy trigger fidelity —
User Xxxpattern shorthand, bang/range/count/ complete-aware commands, keymaps with mode + desc, and<Ignore>-prefixed replay for safety; operator-pending mode preservesv:operator/v:count1/v:register - Colorscheme auto-detection — lazy plugins whose clone contains a
colors/*.vimorcolors/*.luafile automatically gain aColorSchemePreautocmd handler so:colorscheme <name>loads the plugin on demand - Dependency ordering — topological sort on
depends, resilient to cycles and missing references; eager→lazy deps are auto-promoted - Windows first-class — hardcoded
~/.config/~/.cachelayout for dotfiles portability, junction instead of symlink to avoid permission issues - Interactive TUI —
rvpm listwith sync / update / generate / remove / edit / set action keys - CLI-driven set —
rvpm set foo --on-event '["BufReadPre","User Started"]'or full JSON object form foron_mapwith mode/desc - TOML direct edit escape hatch —
rvpm config/rvpm setsub-menu to jump to the plugin's block in$EDITOR - Init.lua integration —
rvpm init --writewires the generated loader into~/.config/$NVIM_APPNAME/init.lua(creates the file if missing)
Installation
# From crates.io
# Or from source (latest main)
Pre-built binaries are also available on the
Releases page for Linux
(x86_64), macOS (Intel / Apple Silicon), and Windows (x86_64). Extract the
binary into any directory on your PATH.
Quick start
# 1. One-time setup (creates both config.toml and init.lua)
# → ~/.config/rvpm/<appname>/config.toml (plugin configuration)
# → ~/.config/nvim/init.lua (loader wiring)
# <appname> = $RVPM_APPNAME → $NVIM_APPNAME → "nvim"
# 2. Add plugins
# 3. Browse the GitHub "neovim-plugin" topic and install from the TUI
# 4. Manage installed plugins interactively
# 5. Open config.toml to tweak settings (lazy, triggers, etc.)
Configuration
~/.config/rvpm/<appname>/config.toml:
[]
# Your own variables, referenced via Tera templates {{ vars.xxx }}
= "~/.config/nvim/rc"
[]
# Root of all rvpm config (config.toml, global hooks, plugins/ subdir)
# Default: ~/.config/rvpm/<appname>
# config_root = "{{ vars.nvim_rc }}"
# Root of all rvpm cache (clones, merged rtp, loader.lua, store cache)
# Default: ~/.cache/rvpm/<appname>
# cache_root = "~/dotfiles/nvim/rvpm"
# Parallel git operations limit (default: 8)
= 10
# Auto-sync config.toml / global hooks / per-plugin hooks back to chezmoi
# source state after mutations (default: false). Requires `chezmoi` in PATH.
# chezmoi = true
[[]]
= "snacks"
= "folke/snacks.nvim"
# No on_* triggers → eager (loaded at startup)
[[]]
= "telescope"
= "nvim-telescope/telescope.nvim"
= ["snacks.nvim"]
# on_cmd is set → lazy is auto-inferred as true
= ["Telescope"]
= ["snacks.nvim"]
[[]]
= "neovim/nvim-lspconfig"
# on_ft / on_event → auto lazy
= ["rust", "toml", "lua"]
= ["BufReadPre", "User LazyVimStarted"]
[[]]
= "which-key"
= "folke/which-key.nvim"
# on_map → auto lazy
= [
"<leader>?",
{ = "<leader>v", = ["n", "x"], = "Visual leader" },
]
[options] reference
rvpm reads config.toml from ~/.config/rvpm/<appname>/config.toml, where
<appname> resolves to:
$RVPM_APPNAMEif set, else$NVIM_APPNAMEif set, else"nvim"(default)
This mirrors Neovim's $NVIM_APPNAME convention, so running
NVIM_APPNAME=nvim-test nvim pairs with NVIM_APPNAME=nvim-test rvpm sync
for fully isolated test configs.
| Key | Type | Default | Description |
|---|---|---|---|
config_root |
string |
~/.config/rvpm/<appname> |
Root for all rvpm config (config.toml, global before.lua / after.lua, and plugins/<host>/<owner>/<repo>/ per-plugin hooks). Supports ~ and Tera templates. Recommended: leave unset |
cache_root |
string |
~/.cache/rvpm/<appname> |
Root for all rvpm cache (plugins/repos/, plugins/merged/, plugins/loader.lua, store/). Recommended: leave unset |
concurrency |
integer |
8 |
Max number of parallel git operations during sync / update. Kept moderate to avoid GitHub rate limits |
chezmoi |
boolean |
false |
When true, rvpm automatically runs chezmoi re-add (or chezmoi add for new files whose ancestor is already managed) after mutating config.toml, global hooks, or per-plugin hooks. Warns if chezmoi is not in PATH. See chezmoi integration |
Symmetric layout.
config_rootandcache_rootare structurally parallel — each owns aplugins/subdirectory at the same depth:~/.config/rvpm/<appname>/ ← config_root ├── config.toml ├── before.lua (global, phase 3) ├── after.lua (global, phase 9) └── plugins/<host>/<owner>/<repo>/ (per-plugin init/before/after.lua) ~/.cache/rvpm/<appname>/ ← cache_root ├── plugins/ │ ├── repos/<host>/<owner>/<repo>/ (clones) │ ├── merged/ (linked rtp for merge=true) │ └── loader.lua (generated) └── store/ (`rvpm store` cache)💡 Leave
config_rootandcache_rootunset — the defaults are already<appname>-aware. Setting a literal path (e.g.cache_root = "~/dotfiles/rvpm") breaks appname isolation: every$NVIM_APPNAMEvariant will share the same cache. If you need a custom root and appname isolation, use a Tera template:= "~/dotfiles/rvpm/{{ env.NVIM_APPNAME }}"Prefer
~/over{{ env.HOME }}—~is portable (Windows too), while$HOMEis not set on Windows.
chezmoi integration
If you manage your dotfiles with chezmoi and want rvpm's config changes to land in the chezmoi source state automatically, set:
[]
= true
With this enabled, after every mutation (rvpm add / set / remove /
edit / config, plus TUI actions e / s / d that edit hooks,
plugin options, or remove plugins), rvpm:
- runs
chezmoi re-add <path>when the target file is already chezmoi-managed, and - runs
chezmoi add <path>when the file is newly created by rvpm and its nearest existing ancestor directory is chezmoi-managed (typical for newly created per-plugin hook files underplugins/<host>/<owner>/<repo>/).
Files whose ancestors are not managed by chezmoi are left alone, so this is safe to enable even if only part of your rvpm tree lives in chezmoi.
If options.chezmoi = true but the chezmoi binary isn't in PATH, rvpm
prints a warning on every mutation (since you explicitly opted in — the
loud feedback nudges you to either install chezmoi or set
chezmoi = false). The primary rvpm operation still succeeds.
Detection uses chezmoi source-path <path> (exit 0 = managed).
Tera templates
The entire config.toml is processed by Tera
before TOML parsing. You can use {{ vars.xxx }}, {{ env.HOME }},
{{ is_windows }}, {% if %} blocks, and more.
Context
| Variable | Type | Description |
|---|---|---|
vars.* |
any | User-defined variables from [vars] |
env.* |
string | Environment variables (e.g. {{ env.HOME }}) |
is_windows |
bool | true on Windows, false otherwise |
Variables referencing other variables
Variables can reference each other — including forward references:
[]
= "~/.cache/rvpm"
= "{{ vars.base }}/custom" # → "~/.cache/rvpm/custom"
# Forward reference works too
= "Hello {{ vars.name }}"
= "yukimemi"
# greeting → "Hello yukimemi"
Conditional plugin inclusion
Use {% if %} to completely exclude plugins from loader.lua at generate time:
[]
= true
= false
= true
[]
# ── Completion: pick one ─────────────────────────
{% if vars.use_blink %}
[[]]
= "saghen/blink.cmp"
= ["InsertEnter", "CmdlineEnter"]
{% endif %}
{% if vars.use_cmp %}
[[]]
= "hrsh7th/nvim-cmp"
= "InsertEnter"
{% endif %}
{% if vars.use_snacks %}
[[]]
= "folke/snacks.nvim"
{% endif %}
Platform-specific plugins
{% if is_windows %}
[[]]
= "thinca/vim-winenv"
{% endif %}
[[]]
= "folke/snacks.nvim"
= "{{ is_windows }}" # runtime cond: included in loader but guarded
{% if %}vscond:{% if %}removes the plugin entirely at generate time — it won't be cloned, merged, or appear inloader.lua.condkeeps the plugin inloader.luabut wraps it inif <expr> then ... endfor runtime evaluation.
[[plugins]] reference
| Key | Type | Default | Description |
|---|---|---|---|
url |
string |
(required) | Plugin repository. owner/repo (GitHub shorthand), full URL, or local path |
name |
string |
repo name from url (e.g. telescope.nvim) |
Friendly name used in rvpm_loaded_<name> User autocmd, on_source chain, and log messages. Auto-derived by taking the last path component of the URL and stripping .git |
dst |
string |
{cache_root}/plugins/repos/<host>/<owner>/<repo> |
Custom clone destination (overrides the default path layout) |
lazy |
bool |
auto | Auto-inferred: if any on_* trigger is set, defaults to true; otherwise false. Write lazy = false explicitly to force eager loading even with triggers |
merge |
bool |
true |
If true, the plugin directory is linked into {cache_root}/plugins/merged/ and shares a single runtimepath entry |
rev |
string |
HEAD | Branch, tag, or commit hash to check out after clone/pull |
depends |
string[] |
none | Plugins that must be loaded before this one. Accepts display_name (e.g. "snacks.nvim") or url (e.g. "folke/snacks.nvim"). Eager plugin depending on a lazy plugin: the lazy dep is auto-promoted to eager (a warning is printed to stderr). Lazy plugin depending on a lazy plugin: the dep(s) are loaded first inside the trigger callback via a load_lazy chain guarded against double-loading |
cond |
string |
none | Lua expression. When set, the plugin's loader code is wrapped in if <cond> then ... end |
build |
string |
none | Shell command to run after clone (not yet implemented) |
dev |
bool |
false |
When true, sync and update skip this plugin entirely (no clone/fetch/reset). Use for local development — the plugin stays on the rtp but rvpm won't touch the working tree |
Lazy trigger fields
All trigger fields are optional. When multiple triggers are specified on the same plugin they are OR-ed: any one firing loads the plugin.
| Key | Type | Accepts | Description |
|---|---|---|---|
on_cmd |
string | string[] |
"Foo" or ["Foo", "Bar"] |
Load when the user runs :Foo. Supports bang, range, count, completion |
on_ft |
string | string[] |
"rust" or ["rust", "toml"] |
Load on FileType event, then re-trigger so ftplugin/ fires |
on_event |
string | string[] |
"BufReadPre" or ["BufReadPre", "User LazyDone"] |
Load on Neovim event. "User Xxx" shorthand creates a User autocmd with pattern = "Xxx" |
on_path |
string | string[] |
"*.rs" or ["*.rs", "Cargo.toml"] |
Load on BufRead / BufNewFile matching the glob pattern |
on_source |
string | string[] |
"snacks.nvim" or ["snacks.nvim", "nui.nvim"] |
Load when the named plugin fires its rvpm_loaded_<name> User autocmd. Value must match the target plugin's display_name |
on_map |
string | MapSpec | array |
see below | Load on keypress. Accepts simple "<leader>f" or table form |
on_map formats
# Simple string — normal mode, no desc
= "<leader>f"
# Array of simple strings
= ["<leader>f", "<leader>g"]
# Table form with mode and desc
= [
"<leader>f",
{ = "<leader>v", = ["n", "x"] },
{ = "<leader>g", = "n", = "Grep files" },
]
| MapSpec field | Type | Default | Description |
|---|---|---|---|
lhs |
string |
(required) | The key sequence that triggers loading |
mode |
string | string[] |
"n" |
Vim mode(s) for the keymap ("n", "x", "i", etc.) |
desc |
string |
none | Description shown in :map / which-key before the plugin is loaded |
Colorscheme lazy loading
Lazy plugins that ship a colors/ directory (containing .vim or .lua
files) are automatically given a ColorSchemePre autocmd handler at generate
time. No extra config field is required.
When Neovim processes :colorscheme <name>, it fires ColorSchemePre before
switching the scheme. rvpm intercepts this event, loads the matching lazy
plugin, and then lets the colorscheme apply normally.
Eager plugins are unaffected: their colors/ directory is already on the
runtimepath and Neovim finds it without any handler.
Recommendation: if you have multiple colorscheme plugins installed, mark
all but your active one as lazy = true. rvpm will register the
ColorSchemePre handler for each one so they remain switchable on demand
without adding startup cost.
[[]]
= "folke/tokyonight.nvim"
= true # explicit — no on_* triggers to auto-infer from
[[]]
= "catppuccin/nvim"
= "catppuccin"
= true # explicit — ColorSchemePre is auto-registered, not an on_* field
Colorscheme plugins don't have
on_*triggers, solazy = truemust be written explicitly. rvpm handles the rest (scanningcolors/and registeringColorSchemePre).
With this config, running :colorscheme tokyonight or :colorscheme catppuccin
in Neovim will load the respective plugin just in time, with zero startup
overhead when neither is the initial colorscheme.
Hooks
Global hooks (before.lua / after.lua directly under {config_root}/)
and per-plugin hooks (under {config_root}/plugins/<host>/<owner>/<repo>/)
are auto-discovered — no config entries needed.
Global hooks
Place Lua files directly under {config_root}/ (default:
~/.config/rvpm/<appname>/) and rvpm picks them up automatically at
generate time:
| File | Phase | When it runs |
|---|---|---|
before.lua |
3 | After load_lazy helper is defined, before any per-plugin init.lua |
after.lua |
9 | After all lazy trigger registrations |
Useful for setup that must happen before plugins are initialised
(e.g. setting vim.g.* globals) or post-load orchestration that doesn't
belong to any single plugin.
Per-plugin hooks
Drop Lua files under {config_root}/plugins/<host>/<owner>/<repo>/ and
rvpm will include them in the generated loader:
| File | When it runs |
|---|---|
init.lua |
Before runtimepath is touched (pre-rtp phase) |
before.lua |
Right after the plugin's rtp is added, before plugin/* is sourced |
after.lua |
After plugin/* is sourced (safe to call plugin APIs) |
Example: ~/.config/rvpm/<appname>/plugins/github.com/nvim-telescope/telescope.nvim/after.lua
require.
vim..
Commands
| Command | Description |
|---|---|
rvpm sync [--prune] |
Clone/pull plugins and regenerate loader.lua. --prune deletes unused plugin directories |
rvpm generate |
Regenerate loader.lua only (skip git operations) |
rvpm add <repo> |
Add a plugin and sync |
rvpm update [query] |
git pull installed plugins |
rvpm remove [query] |
Remove a plugin from config.toml and delete its directory |
rvpm edit [query] [--init|--before|--after] [--global] |
Edit per-plugin Lua config in $EDITOR. Flag skips the file picker. --global edits the global before.lua / after.lua |
rvpm set [query] [flags] |
Interactively or non-interactively tweak plugin options (lazy, merge, on_*, rev) |
rvpm config |
Open config.toml in $EDITOR |
rvpm init [--write] |
Print (or write) the dofile(...) snippet to wire loader.lua into init.lua |
rvpm list [--no-tui] |
TUI plugin list with action keys; --no-tui for pipe-friendly plain text |
rvpm store |
TUI plugin browser over the GitHub neovim-plugin topic; Enter to install, o to open in browser |
Run rvpm <command> --help for flag-level details.
rvpm store — plugin discovery TUI
Browse, search, and install plugins from GitHub without leaving the terminal.
rvpm store queries the GitHub Search API for repositories tagged with the
neovim-plugin topic, displays them in a split-pane TUI, and fetches each
plugin's README.md on demand for preview.
Key bindings:
| Key | Action |
|---|---|
j / k / ↓ / ↑ |
Move selection |
Ctrl-d / Ctrl-u |
Scroll README pane |
/ |
Search (topic:neovim-plugin <query> against GitHub Search API) |
Enter |
Add the selected plugin to config.toml and sync |
o |
Open the plugin's GitHub page in your default browser |
s |
Cycle sort mode (stars / updated / name) |
R |
Clear the search cache and re-fetch |
q / Esc |
Quit |
Caching: search results are cached for 24 hours under
{cache_root}/store/; READMEs are cached for 7 days. Press R in the TUI
to force-refresh the search cache (README cache expires on its own TTL).
Network requirement: store needs network access to reach
api.github.com and raw.githubusercontent.com. Other commands
(sync / update / generate / list / ...) work offline once plugins
are cloned.
# ── Sync & generate ──────────────────────────────────────
# Clone/pull everything and regenerate loader.lua
# Same, but also remove plugin dirs no longer in config.toml
# Only regenerate loader.lua (after editing init/before/after.lua)
# ── Add / remove ─────────────────────────────────────────
# Add a plugin (creates entry in config.toml and syncs immediately)
# Remove interactively (fuzzy-select prompt)
# Remove by name match
# ── Edit per-plugin hooks ────────────────────────────────
# Pick a plugin interactively, then pick which file to edit
# Jump straight to a specific file (skips both selectors)
# ── Edit global hooks ────────────────────────────────────
# Open the interactive picker and select [ Global hooks ]
# Jump straight to the global before/after hooks
# ── Set plugin options ───────────────────────────────────
# Interactive mode (fuzzy-select plugin → pick option → edit)
# Non-interactive: set multiple flags at once
# on_map with full JSON object form (mode + desc)
# Pin to a specific tag
# ── Config / init ────────────────────────────────────────
# One-time setup: creates config.toml + init.lua in one shot
# Print the snippet without writing (dry run)
# Open config.toml in $EDITOR (auto-creates if missing; runs sync on close)
# ── List / status ────────────────────────────────────────
# TUI with interactive actions ([S] sync, [u] update, [d] remove, …)
# Plain text for scripting / piping
|
Design highlights
Phase 1: vim.go.loadplugins = false -- disable Neovim's auto-source
Phase 2: load_lazy helper -- runtime loader for lazy plugins
Phase 3: global before.lua -- ~/.config/rvpm/<appname>/before.lua
Phase 4: all init.lua (dependency order) -- pre-rtp phase
Phase 5: rtp:append(merged_dir) -- once, if any merge=true plugins
Phase 6: eager plugins in dependency order:
if not merge: rtp:append(plugin_path)
before.lua
source plugin/**/*.{vim,lua} -- pre-globbed at generate time
source ftdetect/** in augroup filetypedetect
source after/plugin/**
after.lua
User autocmd "rvpm_loaded_<name>"
Phase 7: lazy trigger registrations -- on_cmd / on_ft / on_map / etc
Phase 8: ColorSchemePre handlers -- auto-registered for lazy plugins
-- whose colors/ dir was detected at
-- generate time (no config needed)
Phase 9: global after.lua -- ~/.config/rvpm/<appname>/after.lua
Because the file lists are baked in at rvpm generate time, the loader does
zero runtime glob work. rvpm sync (or rvpm generate) is what pays the I/O
cost; Neovim startup just sources a fixed list of files.
When merge = true, the plugin directory is linked (junction on Windows,
symlink elsewhere) into {cache_root}/plugins/merged/. All merge = true
plugins share a single vim.opt.rtp:append(merged_dir) call, keeping
&runtimepath lean even with many eager plugins.
depends fields are topologically sorted. Cycles and missing dependencies
emit warnings instead of hard-failing (resilience principle). The sort
ordering is preserved all the way through to the generated loader.lua, so
before.lua / after.lua hooks run in the correct order relative to
dependencies.
Beyond ordering, depends also affects loading:
- Eager plugin → lazy dep: a pre-pass during
generate_loaderdetects this situation and auto-promotes the lazy dependency to eager, printing a note to stderr. This ensures the dep is unconditionally available before the eager plugin sources its files. - Lazy plugin → lazy dep: the dependency is loaded on-demand. When the
trigger fires, the generated callback calls
load_lazyfor each lazy dep (in dependency order) before loading the plugin itself. A double-load guard (if loaded["<name>"] then return end) prevents redundant sourcing when multiple plugins share the same dep and their triggers fire close together.
Directory layout (defaults)
~/.config/rvpm/<appname>/ ← config_root
├── config.toml ← main configuration
├── before.lua ← global before hook (phase 3, auto-detected)
├── after.lua ← global after hook (phase 9, auto-detected)
└── plugins/
└── <host>/<owner>/<repo>/
├── init.lua ← per-plugin hooks
├── before.lua
└── after.lua
~/.cache/rvpm/<appname>/ ← cache_root
├── plugins/
│ ├── repos/<host>/<owner>/<repo>/ ← plugin clones
│ ├── merged/ ← linked root for merge=true plugins
│ └── loader.lua ← generated loader
└── store/ ← `rvpm store` cache (search + README)
Windows uses the same .config / .cache paths under %USERPROFILE% —
no %APPDATA% — to keep dotfiles portable between Linux / macOS / WSL /
Windows.
<appname> resolves to $RVPM_APPNAME → $NVIM_APPNAME → "nvim".
Development
# Build
# Run the full test suite
# Format check / lint
# Inspect the generated loader from the sample fixture
rvpm is developed with TDD: tests come first, and new behaviors are covered by either unit or integration tests before implementation.
Acknowledgments
- lazy.nvim — design inspiration for the plugin loading model and lazy trigger patterns.
- dvpm — predecessor project (Deno-based).
License
MIT — see LICENSE.