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 loader.lua that sources
everything without any runtime vim.fn.glob cost.
Inspired by lazy.nvim — rvpm adopts the
same "take full control of plugin loading" approach (vim.go.loadplugins = false), but adds merge optimization and generate-time file-list
compilation on top.
Why rvpm?
| lazy.nvim | rvpm | |
|---|---|---|
| Plugin loading control | ✓ (own dispatch) | ✓ (own dispatch) |
init / config hooks |
✓ | ✓ (init.lua / before.lua / after.lua) |
| Per-plugin runtimepath | ✓ | ✓ (when merge = false) |
| Merged runtimepath (single rtp entry for many plugins) | ✗ | ✓ |
| Runtime glob elimination (plugin file paths baked at generate time) | ✗ | ✓ |
| Written in | Lua | Rust |
| Installation workflow | Lua in init.lua |
CLI tool, static loader.lua |
| Parallel git operations | Lua coroutines | Tokio JoinSet + Semaphore |
| Config format | Lua tables | TOML + Tera templates |
The upshot: rvpm does more work at rvpm sync / rvpm generate time so that
Neovim startup reads exactly the files it needs and nothing else.
Features
- Fast startup — Phase 0–4 loader model with
vim.go.loadplugins = falseand pre-globbedplugin//ftdetect//after/plugin/file lists - Global hooks —
~/.config/rvpm/before.lua(Phase 0.7, before all plugininit.lua) and~/.config/rvpm/after.lua(Phase 4.5, after all lazy trigger registrations); auto-detected at generate time, no config required - Merge optimization —
merge = trueplugins share a singlevim.opt.rtp:append(...)entry via junction/symlink - Full lazy triggers —
on_cmd/on_ft/on_map/on_event/on_path/on_source(plugin chain), withUser Xxxpattern shorthand, bang/range/count/complete aware commands, keymaps with mode + desc, and<Ignore>-prefixed replay for safety - Dependency ordering — topological sort on
depends, resilient to cycles and missing references - Windows first-class — hardcoded
~/.config/~/.cachelayout for dotfiles portability, junction instead of symlink to avoid permission issues - Interactive TUI (
rvpm list) — plugin list with action keys for sync/update/generate/remove/edit/set - CLI-driven set —
rvpm set foo --on-event '["BufReadPre","User Started"]'or full JSON object form for on_map with 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 a pre-built binary
Download the latest archive from the Releases page for your platform:
- Linux (x86_64):
rvpm-x86_64-unknown-linux-gnu.tar.gz - macOS (Intel):
rvpm-x86_64-apple-darwin.tar.gz - macOS (Apple Silicon):
rvpm-aarch64-apple-darwin.tar.gz - Windows (x86_64):
rvpm-x86_64-pc-windows-msvc.zip
Extract the binary into any directory on your PATH.
From crates.io
From source (latest main)
Quick start
# 1. One-time setup (creates both config.toml and init.lua)
# → ~/.config/rvpm/config.toml (plugin configuration, auto-created)
# → ~/.config/nvim/init.lua (loader wiring, auto-created or appended)
# Respects $NVIM_APPNAME for custom Neovim configs.
# 2. Add plugins
# 3. Open config.toml to tweak settings (lazy, triggers, etc.)
# 4. Explore the TUI
Configuration
~/.config/rvpm/config.toml:
[]
# Your own variables, referenced via Tera templates {{ vars.xxx }}
= "~/.config/nvim/rc"
[]
# Per-plugin init/before/after.lua directory
# Default: ~/.config/rvpm/plugins
= "{{ vars.nvim_rc }}/plugins"
# Parallel git operations limit (default: 8)
= 10
# Optional: move all rvpm data (repos + merged + loader.lua) under a custom root
# base_dir = "~/dotfiles/nvim/rvpm"
# Optional: override only loader.lua location (overrides base_dir for loader)
# loader_path = "~/.cache/nvim/rvpm/loader.lua"
[[]]
= "snacks"
= "folke/snacks.nvim"
= true # Default for eager plugins
= false
[[]]
= "telescope"
= "nvim-telescope/telescope.nvim"
= true
= ["snacks.nvim"]
# Trigger on command — plugin loads when the user runs :Telescope
= ["Telescope"]
# Or as a User autocmd chained off another plugin
= ["snacks.nvim"]
[[]]
= "neovim/nvim-lspconfig"
= true
# Multiple triggers are OR-ed: any one firing loads the plugin
= ["rust", "toml", "lua"]
= ["BufReadPre", "User LazyVimStarted"]
[[]]
= "which-key"
= "folke/which-key.nvim"
= true
# on_map accepts simple strings or full `{ lhs, mode, desc }` tables
= [
"<leader>?",
{ = "<leader>v", = ["n", "x"], = "Visual leader" },
]
[options] reference
| Key | Type | Default | Description |
|---|---|---|---|
config_root |
string |
~/.config/rvpm/plugins |
Root directory for per-plugin init.lua / before.lua / after.lua. Supports ~ and {{ vars.xxx }} templates |
concurrency |
integer |
8 |
Max number of parallel git operations during sync / update. Kept moderate to avoid GitHub rate limits |
base_dir |
string |
~/.cache/rvpm |
Root for all rvpm data (repos, merged, loader.lua). Setting this moves everything together |
loader_path |
string |
{base_dir}/loader.lua |
Override only the loader.lua output path. Takes precedence over base_dir for the loader file |
[[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 |
{base_dir}/repos/<host>/<owner>/<repo> |
Custom clone destination (overrides the default path layout) |
lazy |
bool |
false |
If true, the plugin is not loaded at startup — requires at least one trigger (on_cmd, on_ft, etc.) |
merge |
bool |
true |
If true, the plugin directory is linked into {base_dir}/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 first. Accepts display_name (e.g. "snacks.nvim") or url (e.g. "folke/snacks.nvim") |
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) |
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 |
Global hooks
Place Lua files directly under ~/.config/rvpm/ and rvpm picks them up
automatically at generate time — no configuration entry needed:
| File | Phase | When it runs |
|---|---|---|
~/.config/rvpm/before.lua |
0.7 | After load_lazy helper is defined, before any per-plugin init.lua |
~/.config/rvpm/after.lua |
4.5 | After all lazy trigger registrations |
These are useful for any setup that must happen before plugins are initialised
(e.g. setting vim.g.* globals) or for post-load orchestration that doesn't
belong to any single plugin.
Per-plugin hooks
Drop Lua files under {config_root}/<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/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 (or selecting [ Global hooks ] in the interactive picker) edits ~/.config/rvpm/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 |
Run rvpm <command> --help for flag-level details.
Usage examples
# ── 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
# Drop into $EDITOR for manual TOML editing from the set menu
# → pick a plugin → select [ Open config.toml in $EDITOR ]
# ── 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 0–4 loader model
Phase 0: vim.go.loadplugins = false -- disable Neovim's auto-source
Phase 0.5: load_lazy helper -- runtime loader for lazy plugins
Phase 0.7: global before.lua -- ~/.config/rvpm/before.lua (if present)
Phase 1: all init.lua (dependency order) -- pre-rtp phase
Phase 2: rtp:append(merged_dir) -- once, if any merge=true plugins
Phase 3: 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 4: lazy trigger registrations (on_cmd / on_ft / on_map / etc)
Phase 4.5: global after.lua -- ~/.config/rvpm/after.lua (if present)
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.
Merge optimization
When merge = true, the plugin directory is linked (junction on Windows,
symlink elsewhere) into {base_dir}/merged/. All merge = true plugins share
a single vim.opt.rtp:append(merged_dir) call — lazy.nvim doesn't do this, so
if you have ~100 eager plugins, rvpm keeps your &runtimepath lean.
Dependency ordering
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.
Directory layout (defaults)
| Path | Purpose |
|---|---|
~/.config/rvpm/config.toml |
Main configuration (fixed location) |
~/.config/rvpm/before.lua |
Global before hook — runs at Phase 0.7, before all plugin init.lua |
~/.config/rvpm/after.lua |
Global after hook — runs at Phase 4.5, after all lazy trigger registrations |
~/.config/rvpm/plugins/<host>/<owner>/<repo>/ |
Per-plugin init/before/after.lua (options.config_root to override) |
~/.cache/rvpm/repos/<host>/<owner>/<repo>/ |
Plugin clones |
~/.cache/rvpm/merged/ |
Linked root for merge = true plugins |
~/.cache/rvpm/loader.lua |
Generated loader |
Windows uses the same .config / .cache paths under %USERPROFILE% — no
%APPDATA% — to keep dotfiles portable between Linux/macOS/WSL/Windows.
options.base_dir = "..." moves all of ~/.cache/rvpm/ to a different root
(useful for dotfiles-managed caches). options.loader_path = "..." moves only
loader.lua.
Development
# Build
# Run the full test suite
# Run a single test
# 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 by
@folke— the approach of taking over plugin loading entirely (vim.go.loadplugins = false), theftdetectaugroup wrapping trick, the<Ignore>-prefixed feedkeys replay, and the per-handler designs (cmd.lua,keys.lua,event.lua,ft.lua) were all studied and adapted for rvpm. rvpm is an independent Rust re-implementation inspired by these ideas. - dvpm — a Deno-based predecessor.
License
MIT — see LICENSE.