8000 fix(merge): drop a removed plugin's specs before deriving what stays by zuqini · Pull Request #49 · zuqini/zpack.nvim · GitHub
[go: up one dir, main page]

Skip to content

fix(merge): drop a removed plugin's specs before deriving what stays - #49

Merged
zuqini merged 8 commits into
mainfrom
prune-before-derive
Oct 30, 2026
Merged

zuqini merged 8 commits into
mainfrom
prune-before-derive

Conversation

@zuqini
@zuqini zuqini commented Oct 30, 2026 •
Copy link
Copy Markdown
Owner

Follow-up to #46. #46 drops the specs a disabled or optional-skipped plugin declares under dependencies, but only after the same-name fold, the enabled decisions and the dependency graph were worked out from every spec. Only opts were re-merged, so a dropped spec could still:

  • rename the plugin it configured. That could collide with another plugin and abort setup() with vim.pack's "Conflicting src":
    { 'test/p', enabled = false, dependencies = { { 'user/repo', name = 'alias' } } },
    { 'user/repo' }, { 'other/repo' },
  • disable a plugin that stays (enabled = false), and cascade to the plugins depending on it
  • add dependency edges, force-loading a lazy plugin with its parent
  • decide where the plugin installs from (a fork url)

This ports lazy.nvim's resolve logic instead:

  • Import records where each spec table was declared: top level, or under a parent's dependencies or specs. The records are cleared once the dependency graph is built.
  • Pruning runs as one queue, starting from the disabled and optional-only plugins only. Each removal takes every spec declared under that plugin with it, and only the plugins that lost specs are re-merged and looked at again. The order is fixed, so the outcome no longer depends on plugin names:
    1. removals that rest on a top-level spec, which nothing later can undo
    2. the rest, one at a time by the import order of the specs they rest on, since a parent is imported before the specs declared under it
    3. plugins whose specs are all optional, together, once nothing else is left (lazy.nvim's fix_disabled then fix_optional)
  • The cascade (a plugin whose dependency is disabled is disabled too) keeps its warning, and skips plugins with only optional specs, which are skipped quietly as before. It is one of the removal reasons in that queue, so a cascade-disabled plugin's dependencies and specs no longer disable or cascade other plugins.
  • The dependency graph is built afterwards from the specs that are left. That replaces the import-time graph and the edge-rekeying code.
  • Name and source: a plugin keeps the name the fold gave it, as lazy.nvim does, and its source is re-picked from its remaining specs, as lazy.nvim re-derives url.
  • Each spec's enabled is evaluated once, so a function-form enabled still runs once.
  • A plugin keeps the source the fold gave it while a spec with that source remains; only when all of them were dropped (a fork url under a removed plugin) is it re-picked from the remaining specs.

Also matching lazy.nvim:

  • a disabled plugin's specs children go with it
  • a spec table listed in several places is registered once. It no longer doubles opts_extend lists ({ 'delve', 'delve', 'stylua' }), no longer gets pruned when it's both top-level and under a disabled plugin, and tables that list each other no longer overflow the stack. Unlike lazy.nvim, which records only the first listing, it stays while any listing stays, and is a dependency only if every remaining listing is one.

The vimdoc and docs/tips.md list where this differs from lazy.nvim.

Not changed: a plugin skipped by cond keeps its dependency specs. cond is evaluated at load time and means "installed, not loaded"; the vimdoc now says so (decision zpack_nvim-0po).

Review follow-up: the last four commits address a second review. The first cut still let pairs() order decide some nested and cascade outcomes, cascaded through a cascade-disabled plugin's specs, made a shared table eager when its other listing was dropped, called function-form enabled twice, and kept the spec records alive for the session. Name-order tests cover each shape. A fuzz that renames plugins over 1,500+ random nested specs found no name-dependent outcome (the first cut had 7 in 200).

Second review follow-up (last two commits): an independent bug hunt found removals ordered by a plugin's earliest nested spec rather than the spec the removal rests on (so a result could change with lua/plugins/*.lua file order), optional-only plugins getting a cascade WARN instead of the quiet skip, a function-form enabled running twice, and the source re-pick falling to a bare-name https://github.com/mason.nvim. Each has a test. A file-order fuzz (300 seeds × 6 orders) now only varies where plugins' specs disable each other in a cycle, which has no order-free answer (tracked in a decision bead).

Behaviour change: a spec table listed both at the top level and under a lazy plugin's dependencies is no longer a dependency-only plugin, so without its own triggers it loads at startup. On main that depended on which listing was imported last.

Checked against real configs: LazyVim's spec files (core, all 115 extras, and 40 random extra subsets, parsed as data) and my own config, through pre-#46, main, this PR and lazy.nvim's own resolver. Versus pre-#46, the only differences are plugins declared under the specs of an optional or disabled parent that are now dropped (telescope extensions without telescope, blink-cmp-avante without blink, snacks picker keys for a disabled flash.nvim), as lazy.nvim does; no plugin changes between lazy and eager. Versus lazy.nvim, the remaining differences all predate #46 (zpack ANDs enabled across specs and cascades disabled dependencies). My config is identical on all versions.

Startup cost: differences of a few hundred microseconds are negligible. Median of 30 in-process runs, import + resolve_all:

config pre-#46 main this PR
LazyVim, all extras (~160 entries, 500 specs) 7.8 ms 7.9 ms 8.4 ms
LazyVim, core + ~30% of extras 5.5 ms 6.0 ms 6.0 ms
my config (38 plugins) 2.6 ms 2.6 ms 2.7 ms

The prune step re-merges the plugins that lost specs, which is most of the difference; it grows with the number of nested removals, which is under 10 in real configs.

Refs zpack_nvim-07l

#46 dropped the specs a disabled or optional-skipped plugin declares
under `dependencies`, but only after the same-name fold, the `enabled`
decisions, their propagation and the dependency graph had all been
derived from every spec. Only opts were re-merged, so a dropped spec
still:
- renamed the plugin it configured, which could collide with another
  plugin's name and abort setup() with vim.pack's "Conflicting `src`"
- disabled a plugin that stays (enabled = false), and cascaded to the
  plugins depending on it
- added dependency edges, force-loading a lazy plugin with its parent
- decided where the plugin installs from (a fork `url`)

Port lazy.nvim's resolve instead. Import records where each spec table
was declared (top level, or a parent's `dependencies` or `specs`).
resolve_all then removes disabled plugins, top-level-only ones first,
and then optional-only ones, until neither finds anything, as
lazy.nvim's fix_disabled/fix_optional loop does. Each removal takes
every spec declared under the plugin with it, and only the plugins
that lost specs are re-merged and re-evaluated. A plugin that depends
on a disabled one is still disabled with the same warning, once those
removals have settled, so a dependency declared under a plugin that
goes away does not count.

The dependency graph is built afterwards from the specs left, which
replaces the import-time graph, the edge stripping and the fold's edge
rekeying. A plugin keeps the name the fold gave it, as lazy.nvim fixes
a name when a fragment is added, and its source is re-picked from its
remaining specs, as lazy.nvim re-derives `url`.

Also matching lazy.nvim, a disabled plugin's `specs` children go with
it, and a plugin left with only optional specs logs the same DEBUG
message as one skipped directly.

Startup cost is on par with main: on a synthetic 165-plugin config,
resolve_all is within noise with one disabled plugin and up to ~0.2 ms
slower with 40 optional extras.
A table reused under two plugins' `dependencies` (or also listed at the
top level) was imported once per listing, so its plugin got the same
spec several times: opts_extend appended its lists once per listing,
e.g. { 'delve', 'delve', 'stylua' }. The last import also overwrote
_is_dependency, so a table that is top-level and under a disabled
plugin's dependencies looked dependency-only and was pruned with it.
Tables listing each other as dependencies recursed until setup() hit a
stack overflow.

As in lazy.nvim, a table is one fragment: later listings only record
the extra parent, and it is a dependency only if every listing is one.
A spec stays while a chain of parents leads back to the top level, so
tables listing each other go with the plugin they were declared under.

Refs zpack_nvim-07l
… changed

Removals rest on top-level specs first; the rest go one at a time in
import order, so a parent is settled before the specs declared under it.
A plugin depending on a disabled one is handled in the same pass, so a
cascade-disabled plugin's specs no longer disable or cascade others.
Only disabled and optional-only plugins start the pass, and `enabled` is
evaluated again only when a dropped spec set it.
…s left

A table listed under a removed plugin's `specs` and a kept plugin's
`dependencies` loads with the kept plugin again, as on main.
…built

The spec maps kept every spec table, including pruned plugins' opts and
closures, alive for the whole session. The graph now only names plugins
in the registry, so registration no longer checks for dropped ones.
A disabled dependency disables its parent, a table listed in several
places stays while any listing does, and `enabled = false` under an
optional parent still applies.
Each spec's `enabled` is evaluated once, so a plugin re-merged after
pruning no longer calls a function-form `enabled` again, and a removal
knows which specs it rests on. It is settled when any of them is
top-level; otherwise removals go one at a time by the import order of
those specs rather than the plugin's earliest nested spec, so the result
no longer depends on the order lua/plugins/*.lua files import in. A
plugin with only `optional` specs is no longer cascade-disabled with a
warning; it is skipped quietly, as before.
Re-picking the source from the remaining specs could fall to a bare-name
spec like `dependencies = 'mason.nvim'`, which expands to the
nonexistent https://github.com/mason.nvim. Re-pick only when every spec
that gave the source was dropped, as for a fork `url` under a removed
plugin.
@zuqini
zuqini merged commit 871104e into main Oct 30, 2026
4 checks passed
zuqini added a commit that referenced this pull request Oct 31, 2026
…re names

Follow-up to a review of main after #49, a bug hunt and a differential run
against ported lazy.nvim code. Spec lists take bare strings and nested
lists anywhere; a `[1]` without an owner is a reference by name, not a
source; `optional` applies under `dependencies`; merged keys stay separate
and the later spec replaces or disables one; the last `enabled` or `cond`
wins; a disabled dependency no longer disables its parent; a trigger nothing
can fire leaves a plugin eager; `import` on a plugin spec reads that plugin's
modules, installing it from its merged spec if needed.

Load order: dependencies are sourced before their parents, lazy and eager,
in the order they're listed. Modules import by module name. A failed
vim.pack.add reports the plugin and setup() continues. setup() after startup
sources plugin/. Lazy plugins get triggers in an install session, except what
a pending build already loaded.

See the PR description for the full list and the behaviour changes.

Closes zpack_nvim-2fu, zpack_nvim-tp7, zpack_nvim-5n0, zpack_nvim-8cy,
zpack_nvim-na9, zpack_nvim-dso, zpack_nvim-nvx, zpack_nvim-yvt
zuqini added a commit that referenced this pull request Oct 31, 2026
…re names (#51)

Follow-up to a review of main after #49, with six review rounds on this branch. Spec lists, keys, triggers, bare names, optional, enabled/cond, imports from plugins, load order and versions now follow lazy.nvim; the PR description lists each change and the behaviour changes.
@zuqini
zuqini deleted the prune-before-derive branch November 6, 2026 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0