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.
Demo
init → add → list → b browse → Enter add → l back → S sync

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 browsebrowses the GitHubneovim-plugintopic with live README preview;Tabswitches focus between panes - 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 - Auto helptags —
sync/generateend with onenvim --headlessinvocation that runs:helptags <doc>for the merged dir and every lazy plugin'sdoc/. No per-startup glob, no:helptags ALLneeded - 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 config.toml + wires the loader into init.lua
# 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.)
Files end up under ~/.config/rvpm/<appname>/ and ~/.cache/rvpm/<appname>/
(see Directory layout). <appname> resolves to
$RVPM_APPNAME → $NVIM_APPNAME → "nvim".
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, browse cache)
# Default: ~/.cache/rvpm/<appname>
# cache_root = "~/dotfiles/nvim/rvpm"
# Parallel git operations limit (default: 8)
= 10
# Route config.toml / global hooks / per-plugin hooks through the chezmoi
# source state (write to source → chezmoi apply --force). Default: false.
# Requires `chezmoi` in PATH. See "chezmoi integration" below.
# chezmoi = true
# Auto-prune plugin dirs no longer referenced by config.toml on every
# `sync` and `generate`. Default: false. Same effect as always passing
# `sync --prune`. Standalone `rvpm clean` remains available.
# auto_clean = true
# Auto-generate helptags via `nvim --headless` after every `sync` /
# `generate`. Default: true. Lazy plugins are not on runtimepath at
# Neovim startup, so rvpm enumerates each plugin's `doc/` directory and
# runs `:helptags <path>` individually. Set to false to skip.
# auto_helptags = false
# How `rvpm add` records GitHub plugin URLs in config.toml.
# "short" (default) → owner/repo ; "full" → https://github.com/owner/repo.
# Duplicate detection normalises between styles either way.
# url_style = "full"
# Optional: run READMEs in the browse TUI through an external renderer
# (mdcat / glow / bat). See "External README renderer" below.
# [options.browse]
# readme_command = ["mdcat"]
[[]]
= "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 mirrors Neovim's $NVIM_APPNAME convention, so
NVIM_APPNAME=nvim-test nvim pairs with NVIM_APPNAME=nvim-test rvpm sync
for fully isolated test configs (see Directory layout
for where files land).
| 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, browse/). 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 writes mutations (config.toml, global hooks, per-plugin hooks) directly to the chezmoi source file (resolved via chezmoi source-path) and then runs chezmoi apply --force to materialise the change in the target. Falls back to writing the target directly if chezmoi is missing. Plain files only — .tmpl sources are rejected (rvpm has its own Tera engine). See chezmoi integration |
auto_clean |
boolean |
false |
When true, rvpm sync and rvpm generate automatically delete plugin directories under plugins/repos/ that are no longer referenced by config.toml. Equivalent to always passing --prune to sync. The standalone rvpm clean command is still available for one-off cleanups |
auto_helptags |
boolean |
true |
When true, rvpm sync / generate run nvim --headless once at the end to build helptags. Lazy plugins are not on runtimepath at Neovim startup, so rvpm enumerates each plugin's doc/ directory and runs :helptags <path> individually (merged plugins are processed via the single merged/doc/ directory). If nvim is not on PATH, rvpm prints a warning and continues. Set to false to skip helptag generation entirely (you can still run :helptags ALL manually inside Neovim) |
url_style |
"short" | "full" |
"short" |
How rvpm add writes GitHub plugin URLs to config.toml. "short" → owner/repo, "full" → https://github.com/owner/repo. Non-GitHub URLs (gitlab etc.) are saved verbatim regardless. Duplicate detection normalizes between styles, so the two forms never produce double entries |
💡 Leave
config_root/cache_rootunset. The defaults are already<appname>-aware. Setting a literal path (e.g.cache_root = "~/dotfiles/rvpm") breaks appname isolation — every$NVIM_APPNAMEvariant then shares the same cache. If you need a custom root and appname isolation, use a Tera template:cache_root = "~/dotfiles/rvpm/{{ env.NVIM_APPNAME }}". Prefer~/over{{ env.HOME }}(~is portable;$HOMEisn't set on Windows).See Directory layout below for the full on-disk structure under both roots.
chezmoi integration
If you manage your dotfiles with chezmoi, set
chezmoi = true and rvpm will route every write through the chezmoi
source state instead of mutating the target file directly — preserving
chezmoi's "source is truth" model:
[]
= true
Every mutation that touches config.toml, a global hook, or a per-plugin
hook (rvpm add / set / remove / edit / config / init --write,
plus the e / s / d action keys in rvpm list) follows this flow:
- Resolve the source path. rvpm asks chezmoi for the source path via
chezmoi source-path <target>. If the target itself isn't managed, rvpm walks its ancestors until it hits a managed directory and computes the source path relative to that ancestor. This is how newly created per-plugin hook files under a managedplugins/<host>/<owner>/<repo>/parent get picked up. - Write to the source file. rvpm writes the new content into the resolved source path. The target file is not touched at this step.
- Apply back. rvpm runs
chezmoi apply --force <target>to materialise the change in the target.--forceis intentional — rvpm is the authoritative writer of these files, so the merge prompt chezmoi would otherwise raise when target mtime changed is just noise.
Files whose ancestors aren't managed by chezmoi are left alone, so enabling the flag is safe even when only part of your rvpm tree lives in chezmoi.
Limitations:
.tmplsources are rejected. rvpm already has Tera templating forconfig.toml, and writing into a.tmplfile would silently corrupt the chezmoi template. When a resolved source ends in.tmpl, rvpm warns and falls back to writing the target directly.- If
options.chezmoi = truebutchezmoiis missing fromPATH, rvpm prints a warning (loud on purpose — you opted in explicitly) and writes to the target directly. The primary operation always succeeds.
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 clean |
Delete plugin directories no longer referenced by config.toml (no git, faster than sync --prune on 200+ plugins) |
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 browse |
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 list — plugin manager TUI
| Key | Action |
|---|---|
j / k / ↓ / ↑ |
Move selection |
g / Home |
Go to top |
G / End |
Go to bottom |
Ctrl-d / Ctrl-u |
Half page down / up |
Ctrl-f / Ctrl-b |
Full page down / up |
/ |
Incremental search |
n / N |
Next / previous search result |
b |
Switch to rvpm browse TUI |
c |
Open config.toml in $EDITOR |
e |
Edit per-plugin hooks (init / before / after.lua) |
s |
Set plugin options (lazy, merge, on_cmd, …) |
S |
Sync all plugins |
u |
Update selected plugin |
U |
Update all plugins |
d |
Remove selected plugin |
? |
Toggle help popup |
q / Esc |
Quit |
rvpm browse — plugin discovery TUI
Browse, search, and install plugins from GitHub without leaving the terminal.
rvpm browse fetches up to ~300 repositories tagged with the neovim-plugin
topic, displays them in a split-pane TUI with a GitHub-flavored markdown
preview, and installs the selected plugin into your config.toml on Enter.
Plugins already listed in config.toml are marked with a green ✓ at the
start of the row; pressing Enter on an installed plugin shows a warning
instead of adding a duplicate.
Navigation keys are focus-aware: press Tab to switch between the
plugin list and the README preview pane.
| Key | List focused | README focused |
|---|---|---|
j / k / ↓ / ↑ |
Move selection | Scroll line |
g / Home |
Go to top | Scroll to top |
G / End |
Go to bottom | Scroll to bottom |
Ctrl-d / Ctrl-u |
Half page down / up | Half page scroll |
Ctrl-f / Ctrl-b |
Full page down / up | Full page scroll |
| Key | Action |
|---|---|
Tab |
Switch focus between list and README |
/ |
Local incremental search over name + description + topics |
n / N |
Jump to next / previous search match |
S |
GitHub API search (topic:neovim-plugin <query>, replaces list) |
Enter |
Add the selected plugin to config.toml (warns if already installed) |
l |
Switch to rvpm list TUI |
c |
Open config.toml in $EDITOR |
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 |
? |
Toggle help popup |
q |
Quit |
Esc |
Cancel active input (/ or S) when in a search mode; quit otherwise |
Legend: ✓ in the leftmost column means the plugin is already in your
config.toml. Topics are shown in the rightmost column (#lua #ui ...).
Caching: search results are cached for 24 hours under
{cache_root}/browse/; 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: browse needs network access to reach
api.github.com and raw.githubusercontent.com. Other commands
(sync / update / generate / list / ...) work offline once plugins
are cloned.
External README renderer
The built-in tui-markdown pipeline handles most READMEs reasonably well,
but it can't match dedicated renderers like mdcat or glow for tables,
task lists, or themed output. Configure an external command and rvpm will
pipe the raw README through it and render its ANSI output instead:
[]
# Most common: mdcat reads from stdin by default
= ["mdcat"]
# Pass the terminal width explicitly (Tera-style `{{ name }}` placeholders)
# readme_command = ["mdcat", "--columns", "{{ width }}"]
# glow wants a file path and supports theme flags
# readme_command = ["glow", "-s", "dark", "-w", "{{ width }}", "{{ file_path }}"]
# bat can also pretty-print markdown
# readme_command = ["bat", "--language=markdown", "--color=always"]
Placeholders follow the same {{ name }} syntax rvpm uses elsewhere
([vars], Tera templates). Whitespace inside the braces is optional, so
{{width}} and {{ width }} are equivalent. Unknown names are left
literal. Supported names:
{{ width }}/{{ height }}— inner size of the README pane in cells{{ file_path }}— absolute path to a temp file containing the raw README (the command receives an empty stdin when any{{ file_* }}is used){{ file_dir }}— parent directory of{{ file_path }}{{ file_name }}— basename (e.g.rvpm-browse-readme-xxxx.md){{ file_stem }}— basename without extension{{ file_ext }}— extension without the leading dot (e.g.md)
Contract and safeguards:
- raw markdown goes to the command's stdin (unless
{file_path}is used) - the command's stdout is read and its ANSI escapes are parsed via
ansi-to-tui, so any ANSI-aware renderer works - hard timeout of 3 seconds per render; exceeding it falls back to the built-in path silently
- exit code ≠ 0, empty output, or spawn failure also falls back, with a one-line warning in the title bar
- leave
readme_commandunset to keep the offline built-in renderer as the default
# ── 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; regenerates loader.lua on close)
# ── List / status ────────────────────────────────────────
# TUI with interactive actions ([S] sync, [u] update, [d] remove, [?] help)
# 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
~/.config/rvpm/<appname>/ ← config_root
├── config.toml ← main configuration
├── before.lua ← global before hook (phase 3)
├── after.lua ← global after hook (phase 9)
└── plugins/<host>/<owner>/<repo>/
├── init.lua ← per-plugin pre-rtp hook
├── before.lua ← per-plugin pre-source hook
└── after.lua ← per-plugin post-source hook
~/.cache/rvpm/<appname>/ ← cache_root
├── plugins/
│ ├── repos/<host>/<owner>/<repo>/ ← plugin clones
│ │ └── doc/tags ← helptags for lazy / merge=false plugins
│ ├── merged/ ← linked rtp for merge=true
│ │ └── doc/tags ← helptags shared across merged plugins
│ └── loader.lua ← generated loader
└── browse/ ← `rvpm browse` cache (search + README)
Windows uses the same .config / .cache paths under %USERPROFILE% (no
%APPDATA%) so the same layout is portable across Linux / macOS / WSL /
Windows.
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.