rvpm 0.2.0

Fast Neovim plugin manager with pre-compiled loader and merge optimization
rvpm-0.2.0 is not a library.

rvpm

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

CI Release License: MIT

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 = false and pre-globbed plugin/ / ftdetect/ / after/plugin/ file lists
  • Merge optimizationmerge = true plugins share a single vim.opt.rtp:append(...) entry via junction/symlink
  • Full lazy triggerson_cmd / on_ft / on_map / on_event / on_path / on_source (plugin chain), with User Xxx pattern 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 / ~/.cache layout 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 setrvpm set foo --on-event '["BufReadPre","User Started"]' or full JSON object form for on_map with mode/desc
  • TOML direct edit escape hatchrvpm config / rvpm set sub-menu to jump to the plugin's block in $EDITOR
  • Init.lua integrationrvpm init --write wires 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

cargo install rvpm

From source (latest main)

cargo install --git https://github.com/yukimemi/rvpm

Quick start

# 1. One-time setup (creates both config.toml and init.lua)
rvpm init --write
# → ~/.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
rvpm add nvim-lua/plenary.nvim
rvpm add nvim-telescope/telescope.nvim

# 3. Open config.toml to tweak settings (lazy, triggers, etc.)
rvpm config

# 4. Explore the TUI
rvpm list

Configuration

~/.config/rvpm/config.toml:

[vars]
# Your own variables, referenced via Tera templates {{ vars.xxx }}
config_base = "~/.config/nvim/rc/after"

[options]
# Per-plugin init/before/after.lua directory
# Default: ~/.config/rvpm/plugins
config_root = "{{ vars.config_base }}/plugins"
# Parallel git operations limit (default: unlimited)
concurrency = 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"

[[plugins]]
name  = "plenary"
url   = "nvim-lua/plenary.nvim"
merge = true     # Default for eager plugins
lazy  = false

[[plugins]]
name    = "telescope"
url     = "nvim-telescope/telescope.nvim"
lazy    = true
depends = ["plenary"]
# Trigger on command — plugin loads when the user runs :Telescope
on_cmd  = ["Telescope"]
# Or as a User autocmd chained off another plugin
on_source = ["plenary"]

[[plugins]]
name    = "nvim-treesitter"
url     = "nvim-treesitter/nvim-treesitter"
lazy    = true
# Multiple triggers are OR-ed: any one firing loads the plugin
on_ft   = ["rust", "toml", "lua"]
on_event = ["BufReadPre", "User LazyVimStarted"]

[[plugins]]
name = "which-key"
url  = "folke/which-key.nvim"
lazy = true
# on_map accepts simple strings or full `{ lhs, mode, desc }` tables
on_map = [
  "<leader>?",
  { lhs = "<leader>v", mode = ["n", "x"], desc = "Visual leader" },
]

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("telescope").setup({
  defaults = { layout_strategy = "vertical" },
})
vim.keymap.set("n", "<leader>ff", "<cmd>Telescope find_files<cr>")

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] Edit per-plugin Lua config in $EDITOR. Flag skips the file picker
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
rvpm sync

# Same, but also remove plugin dirs no longer in config.toml
rvpm sync --prune

# Only regenerate loader.lua (after editing init/before/after.lua)
rvpm generate

# ── Add / remove ─────────────────────────────────────────

# Add a plugin (creates entry in config.toml and syncs immediately)
rvpm add nvim-lua/plenary.nvim
rvpm add nvim-telescope/telescope.nvim --name telescope

# Remove interactively (fuzzy-select prompt)
rvpm remove

# Remove by name match
rvpm remove telescope

# ── Edit per-plugin hooks ────────────────────────────────

# Pick a plugin interactively, then pick which file to edit
rvpm edit

# Jump straight to a specific file (skips both selectors)
rvpm edit telescope --after
rvpm edit plenary --init
rvpm edit treesitter --before

# ── Set plugin options ───────────────────────────────────

# Interactive mode (fuzzy-select plugin → pick option → edit)
rvpm set

# Non-interactive: set multiple flags at once
rvpm set telescope --lazy true --on-cmd "Telescope"
rvpm set nvim-cmp --on-event '["InsertEnter", "CmdlineEnter"]'

# on_map with full JSON object form (mode + desc)
rvpm set which-key --on-map '{"lhs":"<leader>?","mode":["n","x"],"desc":"Which Key"}'

# Pin to a specific tag
rvpm set telescope --rev "0.1.8"

# 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
rvpm init --write

# Print the snippet without writing (dry run)
rvpm init

# Open config.toml in $EDITOR (auto-creates if missing; runs sync on close)
rvpm config

# ── List / status ────────────────────────────────────────

# TUI with interactive actions ([S] sync, [u] update, [d] remove, …)
rvpm list

# Plain text for scripting / piping
rvpm list --no-tui
rvpm list --no-tui | grep Missing

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 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)

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/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
cargo build

# Run the full test suite
cargo test

# Run a single test
cargo test test_loader_phase_order_init_rtp_before

# Format check / lint
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings

# Inspect the generated loader from the sample fixture
cargo test dump_full_sample_loader -- --ignored --nocapture

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), the ftdetect augroup 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.