rvpm 0.9.0

Fast Neovim plugin manager with pre-compiled loader and merge optimization
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
# rvpm

> **R**ust-based **V**im **P**lugin **M**anager — a fast, pre-compiled plugin manager for Neovim

[![CI](https://github.com/yukimemi/rvpm/actions/workflows/ci.yml/badge.svg)](https://github.com/yukimemi/rvpm/actions/workflows/ci.yml)
[![Release](https://github.com/yukimemi/rvpm/actions/workflows/release.yml/badge.svg)](https://github.com/yukimemi/rvpm/actions/workflows/release.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

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](https://github.com/folke/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
- **Global hooks**`~/.config/rvpm/before.lua` (Phase 0.7, before all plugin
  `init.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 = true` plugins share a single
  `vim.opt.rtp:append(...)` entry via junction/symlink
- **Full lazy triggers**`on_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
- **Colorscheme auto-detection** — lazy plugins whose clone contains a
  `colors/*.vim` or `colors/*.lua` file automatically gain a `ColorSchemePre`
  autocmd handler so `:colorscheme <name>` loads the plugin on demand; no
  extra config required
- **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 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 set` sub-menu to
  jump to the plugin's block in `$EDITOR`
- **Init.lua integration**`rvpm 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](https://github.com/yukimemi/rvpm/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

```sh
cargo install rvpm
```

### From source (latest main)

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

## Quick start

```sh
# 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 folke/snacks.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`:

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

[options]
# Per-plugin init/before/after.lua directory
# Default: ~/.config/rvpm/plugins
config_root = "{{ vars.nvim_rc }}/plugins"
# Parallel git operations limit (default: 8)
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  = "snacks"
url   = "folke/snacks.nvim"
merge = true     # Default for eager plugins
lazy  = false

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

[[plugins]]
url     = "neovim/nvim-lspconfig"
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" },
]
```

### `[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. Normally used with at least one trigger (`on_cmd`, `on_ft`, etc.), but a lazy plugin can also be loaded implicitly when it appears in another plugin's `depends` list (see below) |
| `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 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) |

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

```toml
# Simple string — normal mode, no desc
on_map = "<leader>f"

# Array of simple strings
on_map = ["<leader>f", "<leader>g"]

# Table form with mode and desc
on_map = [
  "<leader>f",
  { lhs = "<leader>v", mode = ["n", "x"] },
  { lhs = "<leader>g", mode = "n", desc = "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. This mirrors what
lazy.nvim does at runtime — rvpm bakes the decision in at generate time instead.

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.

**Example:**

```toml
[[plugins]]
url  = "folke/tokyonight.nvim"
lazy = true
# No on_cmd / on_event needed — rvpm detects colors/*.lua at generate time
# and registers a ColorSchemePre autocmd automatically.

[[plugins]]
url  = "catppuccin/nvim"
name = "catppuccin"
lazy = true
```

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.

### 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`

```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] [--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

```sh
# ── 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 folke/snacks.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 snacks --init
rvpm edit lspconfig --before

# ── Edit global hooks ────────────────────────────────────

# Open the interactive picker and select [ Global hooks ]
rvpm edit

# Jump straight to the global before/after hooks
rvpm edit --global --before    # ~/.config/rvpm/before.lua (Phase 0.7)
rvpm edit --global --after     # ~/.config/rvpm/after.lua  (Phase 4.5)

# ── 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 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.3: ColorSchemePre handlers            -- auto-registered for lazy plugins
                                              --   whose colors/ dir was detected at
                                              --   generate time (no config needed)
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.

Beyond ordering, `depends` now also affects **loading**:

- **Eager plugin → lazy dep**: a pre-pass during `generate_loader` detects 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_lazy` for 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)

| 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

```sh
# 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]https://github.com/folke/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]https://github.com/yukimemi/dvpm** — a Deno-based predecessor.

## License

MIT — see [LICENSE](LICENSE).