Repository navigation
fix(merge): drop a removed plugin's specs before deriving what stays - #49
Merged
Merged
Conversation
#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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #46. #46 drops the specs a disabled or
optional-skipped plugin declares underdependencies, but only after the same-name fold, theenableddecisions and the dependency graph were worked out from every spec. Only opts were re-merged, so a dropped spec could still:setup()with vim.pack's "Conflictingsrc":{ 'test/p', enabled = false, dependencies = { { 'user/repo', name = 'alias' } } }, { 'user/repo' }, { 'other/repo' },enabled = false), and cascade to the plugins depending on iturl)This ports lazy.nvim's resolve logic instead:
dependenciesorspecs. The records are cleared once the dependency graph is built.optional, together, once nothing else is left (lazy.nvim'sfix_disabledthenfix_optional)optionalspecs, which are skipped quietly as before. It is one of the removal reasons in that queue, so a cascade-disabled plugin'sdependenciesandspecsno longer disable or cascade other plugins.url.enabledis evaluated once, so a function-formenabledstill runs once.urlunder a removed plugin) is it re-picked from the remaining specs.Also matching lazy.nvim:
specschildren go with itopts_extendlists ({ '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.mdlist where this differs from lazy.nvim.Not changed: a plugin skipped by
condkeeps its dependency specs.condis 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'sspecs, made a shared table eager when its other listing was dropped, called function-formenabledtwice, 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/*.luafile order), optional-only plugins getting a cascade WARN instead of the quiet skip, a function-formenabledrunning twice, and the source re-pick falling to a bare-namehttps://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
dependenciesis 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
specsof an optional or disabled parent that are now dropped (telescope extensions without telescope,blink-cmp-avantewithout 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 ANDsenabledacross 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: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