rvpm 3.14.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
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
# 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 static `loader.lua` that
sources everything without any runtime glob cost.

## Demo

**`init` → `add` → `list` → `b` browse → `Enter` add → `l` back → `S` sync**

![rvpm](vhs/demo.gif)

## Why rvpm?

- **CLI-first** — manage plugins from your terminal, not from inside Neovim
- **TOML config with Tera templates** — declarative, conditional, and shareable across machines
- **Pre-compiled loader**`rvpm generate` walks plugin directories at CLI
  time and bakes file lists into `loader.lua`; Neovim startup is a fixed
  list of `dofile()` / `source` calls with zero runtime glob
- **Full lazy-loading**`on_cmd`, `on_ft`, `on_map`, `on_event`, `on_path`,
  `on_source`, plus auto-detected `ColorSchemePre` and `depends`-aware loading
- **File-level merge**`merge = true` plugins share a single rtp entry via
  per-file hard links; namespace collisions surface in a `first-wins` summary
- **Plugin discovery TUI**`rvpm browse` walks the GitHub `neovim-plugin`
  topic with live README preview; `Enter` to install
- **Diagnostics & history**`rvpm doctor` reports config / state / env in one
  shot; `rvpm log` shows what commits landed on the last sync, with `⚠ BREAKING`
  highlight and optional inline `--diff`
- **Resilient** — cyclic dependencies, missing plugins, and config errors emit
  warnings, not crashes

## Installation

```sh
# From crates.io
cargo install rvpm

# Or from source (latest main)
cargo install --git https://github.com/yukimemi/rvpm
```

Pre-built binaries are also on the
[Releases](https://github.com/yukimemi/rvpm/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

```sh
# 1. One-time setup — creates config.toml + wires loader into init.lua
rvpm init --write

# 2. Add plugins
rvpm add folke/snacks.nvim
rvpm add nvim-telescope/telescope.nvim

# 3. Browse the GitHub "neovim-plugin" topic and install from the TUI
rvpm browse

# 4. Manage installed plugins interactively
rvpm list

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

Files end up under `~/.config/rvpm/<appname>/` and
`~/.cache/rvpm/<appname>/` (see [Directory layout](#directory-layout)).
`<appname>` resolves to `$RVPM_APPNAME` → `$NVIM_APPNAME` → `"nvim"`.

## Configuration

`~/.config/rvpm/<appname>/config.toml`:

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

[options]
# Parallel git operations limit (default: 8)
concurrency = 10

# Auto-prune plugin dirs no longer referenced by config.toml on every
# sync / generate. Default: false. Equivalent to always passing --prune.
# auto_clean = true

# Auto-generate helptags via `nvim --headless` after sync / generate.
# Default: true. 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
# url_style = "full"

[[plugins]]
name  = "snacks"
url   = "folke/snacks.nvim"
# No on_* triggers → eager (loaded at startup)

[[plugins]]
name      = "telescope"
url       = "nvim-telescope/telescope.nvim"
depends   = ["snacks.nvim"]
# on_cmd is set → lazy is auto-inferred as true
on_cmd    = ["Telescope"]
on_source = ["snacks.nvim"]

[[plugins]]
url      = "neovim/nvim-lspconfig"
# on_ft / on_event → auto lazy
on_ft    = ["rust", "toml", "lua"]
on_event = ["BufReadPre", "User LazyVimStarted"]

[[plugins]]
name = "which-key"
url  = "folke/which-key.nvim"
# on_map → auto lazy
on_map = [
  "<leader>?",
  { lhs = "<leader>v", mode = ["n", "x"], desc = "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.

| Key | Type | Default | Description |
|---|---|---|---|
| `config_root` | `string` | `~/.config/rvpm/<appname>` | Root for `config.toml`, global hooks, and per-plugin hooks. **Recommended: leave unset** |
| `cache_root` | `string` | `~/.cache/rvpm/<appname>` | Root for clones, merged rtp, generated loader, and browse cache. **Recommended: leave unset** |
| `concurrency` | `integer` | `8` | Max parallel git operations during `sync` / `update` |
| `chezmoi` | `boolean` | `false` | Route writes through chezmoi source state. See [Advanced → chezmoi integration]#advanced |
| `auto_clean` | `boolean` | `false` | `sync` / `generate` auto-delete plugin dirs no longer in `config.toml` (= always `--prune`) |
| `auto_helptags` | `boolean` | `true` | `sync` / `generate` run `nvim --headless` once at the end to build helptags for every plugin's `doc/`. Skipped with a warning if `nvim` is missing |
| `url_style` | `"short"` \| `"full"` | `"short"` | How `rvpm add` writes GitHub plugin URLs. Duplicate detection normalizes between styles |

> **💡 Leave `config_root` / `cache_root` unset.** Defaults are already
> `<appname>`-aware. Setting a literal path (e.g. `cache_root = "~/dotfiles/rvpm"`)
> breaks appname isolation — every `$NVIM_APPNAME` then shares the same
> cache. For a custom root *with* appname isolation, use a Tera template:
> `cache_root = "~/dotfiles/rvpm/{{ env.NVIM_APPNAME }}"`.

For everything beyond this minimal setup — full plugin field reference,
lazy trigger formats, hooks, conditional Tera templates, chezmoi
integration, and the external README renderer — see
[Advanced](#advanced).

## 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 hooks in `$EDITOR`; `--global` for global hooks |
| `rvpm set [query] [flags]` | Tweak plugin options (`lazy`, `merge`, `on_*`, `rev`) interactively or via flags |
| `rvpm config` | Open `config.toml` in `$EDITOR` |
| `rvpm init [--write]` | Print (or write) the 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 |
| `rvpm doctor` | Diagnose config, state, Neovim wiring, and external tools. Exit codes: `0` ok / `1` error / `2` warn |
| `rvpm log [query] [--last N] [--full] [--diff]` | Show what commits landed on recent `sync` / `update` / `add`. `--diff` embeds README / CHANGELOG / `doc/` patches; `⚠ BREAKING` highlight for Conventional Commits |

Run `rvpm <command> --help` for flag-level details. TUI key bindings and
more example invocations are in [Advanced](#advanced).

## Directory layout

```text
~/.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/                              ← hard-linked rtp for merge=true
│   │   └── doc/tags                         ← helptags shared across merged plugins
│   └── loader.lua                           ← generated loader
├── browse/                                  ← `rvpm browse` cache (search + README)
└── update_log.json                          ← `rvpm log` history (last 20 runs)
```

Windows uses the same `.config` / `.cache` paths under `%USERPROFILE%`
(no `%APPDATA%`), so the same layout is portable across Linux / macOS /
WSL / Windows.

## Advanced

Everything below is folded by default — open only the topics relevant to
what you're doing.

<details>
<summary><b>Plugin spec — all <code>[[plugins]]</code> fields</b></summary>

| 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's runtime files are hard-linked into `{cache_root}/plugins/merged/` and share 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 → lazy dep auto-promotes the dep to eager (with a warning); lazy → lazy dep loads dep first inside the trigger callback |
| `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 |

</details>

<details>
<summary><b>Lazy triggers — <code>on_cmd</code> / <code>on_ft</code> / <code>on_map</code> / <code>on_event</code> / <code>on_path</code> / <code>on_source</code></b></summary>

All trigger fields are optional. When multiple triggers are set 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`, 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 |
| `on_map` | `string \| MapSpec \| array` | see below | Load on keypress. 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)** | 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 |

</details>

<details>
<summary><b>Hooks — global & per-plugin Lua files</b></summary>

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>/`):

| 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. `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>/`:

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

```lua
require("telescope").setup({
  defaults = { layout_strategy = "vertical" },
})
vim.keymap.set("n", "<leader>ff", "<cmd>Telescope find_files<cr>")
```

</details>

<details>
<summary><b>Colorscheme lazy loading</b></summary>

Lazy plugins that ship a `colors/` directory (containing `.vim` or `.lua`
files) automatically gain 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, 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, mark all
but your active one as `lazy = true`. rvpm registers the
`ColorSchemePre` handler for each so they remain switchable on demand
without adding startup cost.

```toml
[[plugins]]
url  = "folke/tokyonight.nvim"
lazy = true  # explicit — no on_* triggers to auto-infer from

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

> Colorscheme plugins don't have `on_*` triggers, so `lazy = true` must
> be written explicitly. rvpm handles the rest (scanning `colors/` and
> registering `ColorSchemePre`).

</details>

<details>
<summary><b>Tera templates — vars, env, conditionals</b></summary>

The entire `config.toml` is processed by
[Tera](https://keats.github.io/tera/) before TOML parsing. You can use
`{{ vars.xxx }}`, `{{ env.HOME }}`, `{{ is_windows }}`, `{% if %}` blocks,
and more.

**Available 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** — including forward references:

```toml
[vars]
base = "~/.cache/rvpm"
full = "{{ vars.base }}/custom"   # → "~/.cache/rvpm/custom"

# Forward reference works too
greeting = "Hello {{ vars.name }}"
name = "yukimemi"
# greeting → "Hello yukimemi"
```

**Conditional plugin inclusion** — `{% if %}` excludes plugins from
`loader.lua` entirely at generate time:

```toml
[vars]
use_blink = true
use_cmp = false

[options]

# ── Completion: pick one ─────────────────────────
{% if vars.use_blink %}
[[plugins]]
url = "saghen/blink.cmp"
on_event = ["InsertEnter", "CmdlineEnter"]
{% endif %}

{% if vars.use_cmp %}
[[plugins]]
url = "hrsh7th/nvim-cmp"
on_event = "InsertEnter"
{% endif %}
```

**Platform-specific plugins:**

```toml
{% if is_windows %}
[[plugins]]
url = "thinca/vim-winenv"
{% endif %}

[[plugins]]
url = "folke/snacks.nvim"
cond = "{{ is_windows }}"  # runtime cond: kept in loader but guarded
```

> **`{% if %}` vs `cond`**: `{% if %}` removes the plugin entirely at
> generate time — no clone, no merge, not in `loader.lua`. `cond` keeps
> the plugin in `loader.lua` but wraps it in `if <expr> then ... end`
> for runtime evaluation.

</details>

<details>
<summary><b>chezmoi integration</b></summary>

If you manage your dotfiles with [chezmoi](https://www.chezmoi.io/), set
`chezmoi = true` and rvpm routes every write through the chezmoi
**source state** instead of mutating the target file directly —
preserving chezmoi's "source is truth" model:

```toml
[options]
chezmoi = 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:

1. **Resolve the source path.** rvpm asks chezmoi via
   `chezmoi source-path <target>`. If the target itself isn't managed,
   rvpm walks its ancestors until it hits a managed directory. This is
   how newly created per-plugin hook files under a managed
   `plugins/<host>/<owner>/<repo>/` parent get picked up.
2. **Write to the source file.** rvpm writes the new content into the
   resolved source path. The target file is not touched at this step.
3. **Apply back.** rvpm runs `chezmoi apply --force <target>` to
   materialise the change. `--force` is intentional — rvpm is the
   authoritative writer of these files.

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:**

- `.tmpl` sources are rejected. rvpm has its own Tera engine; writing
  into a `.tmpl` would silently corrupt the chezmoi template. Falls
  back to writing the target directly with a warning.
- If `chezmoi` is missing from `PATH`, rvpm warns loudly and writes to
  the target directly. The primary operation always succeeds.

</details>

<details>
<summary><b>External README renderer for <code>rvpm browse</code></b></summary>

The built-in `tui-markdown` pipeline handles most READMEs reasonably,
but can't match dedicated renderers like `mdcat` or `glow` for tables,
task lists, or themed output. Configure an external command and rvpm
pipes the raw README through it, rendering the ANSI output:

```toml
[options.browse]
# Most common: mdcat reads from stdin by default
readme_command = ["mdcat"]

# Pass terminal width explicitly (Tera-style `{{ name }}` placeholders)
# readme_command = ["mdcat", "--columns", "{{ width }}"]

# glow wants a file path
# readme_command = ["glow", "-s", "dark", "-w", "{{ width }}", "{{ file_path }}"]

# bat can also pretty-print markdown
# readme_command = ["bat", "--language=markdown", "--color=always"]
```

**Placeholders** use the same `{{ name }}` syntax as elsewhere
(whitespace optional). Unknown names are left literal:

- `{{ 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 empty stdin when any `{{ file_* }}` is used)
- `{{ file_dir }}` / `{{ file_name }}` / `{{ file_stem }}` / `{{ file_ext }}`

**Contract & safeguards:**

- raw markdown goes to stdin (unless `{{ file_path }}` is used)
- stdout is read and ANSI escapes parsed via `ansi-to-tui`
- 3-second hard timeout per render; exceeding falls back silently
- exit code ≠ 0, empty output, or spawn failure also falls back, with a
  one-line warning in the title bar
- leave `readme_command` unset to keep the offline built-in renderer

</details>

<details>
<summary><b><code>rvpm list</code> — TUI key bindings</b></summary>

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

</details>

<details>
<summary><b><code>rvpm browse</code> — TUI key bindings & caching</b></summary>

`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 in `config.toml` are
marked with a green `✓`.

Navigation keys are **focus-aware** — press `Tab` to switch panes:

| 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`); quit otherwise |

**Caching:** search results are cached for 24 hours under
`{cache_root}/browse/`; READMEs for 7 days. Press `R` to force-refresh
the search cache.

**Network:** browse needs network access to `api.github.com` and
`raw.githubusercontent.com`. Other commands work offline once plugins
are cloned.

</details>

<details>
<summary><b>Loader model — 9 phases</b></summary>

```text
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 file lists are baked in at `rvpm generate` time, the loader does
zero runtime glob work. `rvpm sync` (or `rvpm generate`) pays the I/O
cost; Neovim startup just sources a fixed list of files.

</details>

<details>
<summary><b>Merge strategy — file-level hard links</b></summary>

When `merge = true`, the plugin's runtime files are **hard-linked at
the file level** 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.

File-level linking matters when multiple plugins place files under the
same directory (e.g., several cmp-related plugins dropping files into
`lua/cmp/`). The naive directory-link approach loses the later plugin's
contents; rvpm walks each plugin recursively and hard-links individual
files, surfacing any path collision in a `first-wins` summary at the
end of `sync` / `generate`.

Hard links work on Windows without admin rights (unlike symbolic links)
and on every Unix; they only require the source and target to be on the
same volume — and rvpm keeps both `repos/` and `merged/` under
`<cache_root>` for that reason. If a hard link fails (cross-volume,
non-NTFS quirks), the link falls back to a copy.

Plugin-root metadata (`README.md`, `LICENSE`, `Makefile`, `*.toml`,
`package.json`, etc.) is skipped — it's not on any runtimepath and
just adds noise. Dotfiles at any depth (`.gitignore`, `.luarc.json`)
are skipped for the same reason. Only the standard rtp directories
(`plugin/`, `lua/`, `doc/`, `ftplugin/`, `colors/`, `queries/`,
`tutor/`, …) plus `denops/` (for
[denops.vim](https://github.com/vim-denops/denops.vim) TypeScript
plugins) are walked.

</details>

<details>
<summary><b>Dependency ordering &amp; lazy→eager promotion</b></summary>

`depends` fields are topologically sorted. Cycles and missing
dependencies emit warnings instead of hard-failing (resilience
principle). Sort order is preserved 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_loader`
  detects this 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 _G["rvpm_loaded_" .. name] then return end`)
  prevents redundant sourcing when multiple plugins share the same dep.

</details>

<details>
<summary><b>More command examples</b></summary>

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

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

rvpm edit --global --before    # phase 3
rvpm edit --global --after     # phase 9

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

# ── Diagnostics & history ────────────────────────────────

# Diagnose config / state / Neovim wiring / external tools
rvpm doctor

# Show what commits landed on the last sync / update
rvpm log

# Last 5 runs, with README/CHANGELOG/doc patches inline
rvpm log --last 5 --diff

# Filter by plugin name substring
rvpm log telescope

# ── List ─────────────────────────────────────────────────

# TUI with interactive actions
rvpm list

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

</details>

## Development

```sh
# Build
cargo build

# Run the full test suite
cargo test

# 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** — design inspiration
  for the plugin loading model and lazy trigger patterns.
- **[dvpm]https://github.com/yukimemi/dvpm** — predecessor project (Deno-based).

## License

MIT — see [LICENSE](LICENSE).