Repository navigation
fix: close lazy.nvim parity gaps in spec lists, keys, triggers and bare names - #51
Merged
Merged
Conversation
…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
force-pushed
the
zpack-parity-followup
branch
from
October 31, 2026 07:12
f7db509 to
5751072
Compare
vim.pack raises when no tag fits a range; lazy.nvim uses the default branch.
With `defaults = { version = '*' }`, a common lazy.nvim setting, that lost
every untagged plugin for the session and left it re-cloning on each start.
A range written as a string (`version`, `sem_version`, `defaults.version`) now
falls back: by reading the tags on disk for an installed plugin, so
vim.pack.update doesn't raise over it either, and by retrying without the
version for one being installed. A vim.version.range() object keeps vim.pack's
behaviour. The check costs about 2 ms over 57 plugins, only for string ranges.
Closes zpack_nvim-uzm
# Conflicts: # doc/zpack.txt # docs/spec.md # lua/zpack/init.lua # lua/zpack/lazy_trigger/ft.lua # lua/zpack/startup.lua
From the fifth review of this branch: - A load that leaves sourcing to native startup only does so before v:vim_did_init, so a require or build after startup sources plugin/. A plugin's command proxies go before it loads, as in lazy.nvim. - A dependency that throws no longer stops its parent. - after/plugin waits for native startup until VimEnter, where it was sourced twice. - A lazy dependency sources before its eager parent when setup() runs after startup. - A tag vim.pack doesn't read as semver no longer counts as fitting a range, so `:ZPack update` doesn't raise over it. - Version priority is lazy.nvim's: commit, tag, version, sem_version, branch, default. `version = false` keeps a commit, tag or branch. - An import ahead of its plugin's spec finds the module in every session, and a same-module import with an owner looks in the owner. - A plugin removed by an install session's trial resolve is looked at again once another plugin's specs are known. - An owner installed under a name from another of its specs is found without a trial resolve. - A throwing `enabled` is reported even if a trial resolve ran it. Also finishes the merge of #50: the pending-build call lost its argument and the context its `load` field, and an empty pattern list is no trigger since this branch, so its ft test needs a real event beside it.
From the sixth review of this branch: - after/plugin waited for native startup until VimEnter, but native startup only covers what reaches 'runtimepath' before v:vim_did_init. A plugin loaded by the startup file never got it, in that session or later. - setup() after startup sources after/plugin for eager plugins too. - A plugin's load drops a command proxy only once no other pending plugin claims it, and never a real command that replaced it. Pins the startup phases with real nvim processes, since the suite's stubbed v:vim_did_init can't reproduce them.
zuqini
added a commit
that referenced
this pull request
Nov 1, 2026
…e it
{ 'AstroNvim/astrocommunity', { import = 'astrocommunity.pack.lua' } }, the shape
of AstroNvim's template, imported nothing in 2.x and since #51 warned every session
and kept :ZPack clean refusing. lazy.nvim searches the plugins it knows of, after
installing them. This only stats the installed ones: the first start installs the
plugin, warns and refuses, and the next imports. Installing to look would install
listed plugins early for any import that's truly missing.
zuqini
added a commit
that referenced
this pull request
Nov 1, 2026
The stand-in came with #51 and was never documented for 2.x, where cond on an import was ignored.
zuqini
added a commit
that referenced
this pull request
Nov 1, 2026
Started as the second round of startup work after #50 and #51. Review of it found bugs on main that delete plugins, so this now changes behaviour. `docs/tips.md` "Upgrading to 3.0" lists the changes that can affect a working config, and the override below lists every user-visible one. **Startup:** zpack's startup loops run a few hundred times and never again, so LuaJIT spends longer compiling them than they would take to interpret: about 8.4 ms of a 35 ms sampled startup with 150 plugins, 3.3 ms after this. Each module that runs at startup opts its own functions out with `jit.off(true, true)`. User configs and plugins keep the JIT, and it is guarded for non-LuaJIT builds. CPU time in ms, 150 plugins, median of 300 rounds, paired d-cpu with a bootstrap 95% CI, measured under load 7-9 before the last few setup() fixes: | Scenario | lazy.nvim | before review | this | |---|---|---|---| | realistic | 148.5 | 143.5 | 142.4 | | open a file | 205.8 | 189.9 | 190.2 | | all eager | 432.7 | 255.2 | 250.1 | Machine load inflates these numbers. The paired delta, this minus lazy.nvim, is -7.1 [-10.2, -3.7], -16.7 [-20.8, -10.9] and -179 [-187, -172] ms. Against the state before review there is no regression, within A/A noise. **Lost specs:** a failed `vim.pack.add` emptied the registry, and `:ZPack clean` and `:ZPack sync` then deleted every installed plugin without asking. A spec module, import or list entry zpack can't read did the same to what it declares. These now refuse, with a warning at startup and in `:checkhealth`. A bulk `:ZPack update` still runs, with its preview, and says so. An import under a disabled plugin doesn't count, since what it declares goes with the plugin. An `import` whose module is gitignored on this machine needs `enabled`, as `docs/tips.md` shows (zpack_nvim-n5w tracks whether this should become a prompt). **Imports:** a plugin listed without an `import` of its own isn't searched, as in 2.x and unlike lazy.nvim (decision zpack_nvim-587). `{ { 'u/dist' }, { import = 'dist.plugins.extra' } }` now warns and refuses clean, and the warning names the fix: `{ 'u/dist', import = 'dist.plugins.extra' }`, which installs the plugin if needed and imports on the first start. A bare string in a spec list installs as a plugin, where it was reported as invalid and skipped. A `nil` in a spec list no longer ends the list: LazyVim's AI extras put `vim.g.ai_cmp and { ... } or nil` mid-list. lazy.nvim stops at it (decision zpack_nvim-77y). A function-form `cond` at an import gets no plugin, as in lazy.nvim, in place of the unreleased stand-in from #51, so one that reads its argument throws and `:ZPack clean` refuses. **Versions:** one `version = true` or `tag = 3` no longer fails every plugin in the `vim.pack.add` call. A soft version that follows the default branch says when a restart is needed to reach a tag, and a plugin restored from the lockfile says when it needs a restart to update. **Setup:** `setup()`'s options no longer count as spec fields. `{ 'u/a', 'u/b', defaults = ... }`, as documented, installed only `u/a` and warned, and an `{ import = ... }` beside the string was skipped; now it's a list. `{ 'u/a', config = fn }` stays one spec. A string is the module to import, in `setup('custom.plugins')` and `{ spec = 'custom.plugins' }`, where it reported an error and `:ZPack clean` deleted that module's plugins. A single spec with no array part, `{ import = 'custom.plugins', defaults = ... }` or `{ url = ..., name = ... }`, is now read as a spec, where it silently fell back to the default import with the same result. **Loading:** an error in a `plugin/` script is reported and the load carries on to `config`. An eager plugin a lazy dependent loads runs `config` once. A function-form trigger that throws leaves its plugin lazy, and one that loaded the plugin doesn't get a proxy over its own key or command. `require` finds the plugin holding a module in a shared namespace. **Structure:** `merge.lua`, `utils.lua` and `import.lua` are split by concern as pure moves, and state only one module reads moves there. `lua/` is 7 lines longer than main, with the fixes above included. The unreachable profiler is gone: `setup({ profiling = ... })` is ignored without a word, and `module_loader`'s `print_profile` and `get_profile` are removed. **Checked against real configs:** LazyVim's spec files (core, all 115 extras, 40 seeded subsets) and my own config, between the state before review and this one, shortly before the last one-line fix: no `lazy=` flips and no `P` or `E` line changes, only the missing-import `W` lines gaining the fix and five DEBUG notes on all-extras going away. Against lazy.nvim's resolver, the plugin set and edges differ only by the harness's `<expr>` placeholders. The suite passes on nightly and v0.12.0. BEGIN_COMMIT_OVERRIDE perf: stop tracing zpack's startup code fix(commands)!: `:ZPack clean` and `:ZPack sync` refuse when a spec can't be read or a plugin failed to add, rather than delete what they can't see fix(import)!: a `nil` in a spec list no longer ends the list, so what follows it installs fix(lazy)!: a plugin whose function-form trigger throws stays lazy instead of loading at startup fix(setup): `setup()`'s options no longer count as spec fields, so specs written beside them all install fix(import): a bare string in a spec list installs as a plugin, rather than being reported as invalid and skipped fix(import): the warning for an `import` whose module isn't found names the fix, the `import` on the plugin's own spec refactor(module_loader)!: remove the unreachable profiler; `setup({ profiling = ... })` is ignored, and `print_profile` and `get_profile` are gone fix(commands): `:ZPack clean` leaves a plugin active this session for next time rather than raising fix(utils): a `version` or `tag` of the wrong type is dropped with a warning instead of failing every plugin fix(validate): warn about a `sem_version` that isn't a semver range, and fall through to `branch` and `defaults.version` fix(pack): a soft version installs from the default branch after another plugin's clone failed fix(pack): restore a locked plugin at its revision when no tag fits its soft version fix(pack): say when a plugin needs a restart to reach a tag or to update fix(pack): read a tag as `vim.pack` does when deciding if one fits fix(loader): report a `plugin/` script's error without failing the load fix(startup): run an eager plugin's `config` once when a lazy dependent loads it fix(lazy): no proxy over what a function-form trigger already loaded fix(merge): removals resting on other removals no longer depend on import order fix(module_loader): load the plugin holding a module in a shared namespace, including a compiled one fix(cmd): keep a numeric first argument on a lazy command's first use fix(import): take a plugin's `lua/` off `package.path` after startup fix(setup): a string is the module to import, in `setup('custom.plugins')` and in `spec`, rather than reported and replaced by the default import fix(setup): a single spec with no array part, such as `{ import = ..., defaults = ... }`, is read as a spec rather than ignored for the default import fix(setup): any other `setup()` argument that isn't a table is reported, and the default import goes on fix(health): warn when a spec was lost or a plugin failed to add docs(tips): add Upgrading to 3.0 END_COMMIT_OVERRIDE
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 a review of main after #49, plus a bug hunt and a differential run against ported lazy.nvim code (157k input shapes).
Spec lists: a list may hold bare strings, nested lists and empty tables anywhere. A non-table
specswarns instead of abortingsetup().{ 'u/x', import = ... }is both a plugin and an import.enabledandconddecide whether to follow an import, as in lazy.nvim, so at an importcondworks likeenabled: skipped plugins aren't installed, and:ZPack cleansees them as unused. Adependenciesentry may be an import. A module or function listed under several parents stays while any listing does.Bare names (zpack_nvim-2fu): a
[1]without an owner refers to a plugin by name and never gives a source. It joins the plugin of that name, or is skipped with a warning if none exists, and what it declares underdependenciesandspecsstays, as in lazy.nvim. Under LazyVim this was installing gitsigns, snacks, mini.diff and others fromhttps://github.com/<name>.optional(zpack_nvim-tp7): it now applies to specs underdependencies, so LazyVim's blink setup no longer installs blink.compat.Keys: merged lists stay separate keys. A later key replaces an earlier one per mode, and a
falseorvim.NILrhs disables it. One key spelled two ways is one key. "Later" means merge order: specs underdependenciesmerge before top-level ones whatever the import order, unlike lazy.nvim, so one can't replace or disable a top-level key (decision bead, documented).Triggers: function-form triggers merge with other specs, receive the list so far, and can edit it in place. They aren't called before the plugin exists. EventSpecs keep their identity, event lists may nest,
falseclears a trigger, and a malformed trigger reports its plugin instead of abortingsetup(). A bad event name loses only itself, so{ 'LazyFile', 'VeryLazy' }still loads onVeryLazy. A trigger nothing can fire (cmd = { 123 },cmd = 'foo',event = { {} },event = 'BufRead,BufNewFile', an unusablepattern, a key with no modes) leaves the plugin eager, not lazy with no way to load.mode = ''is a real map-mode and keeps a plugin lazy.enabledandcond(zpack_nvim-5n0): the last spec to set one wins, in merge order, and only the deciding function runs. They were ANDed, so nothing could undo a distro's or shared spec'senabled = false.Disabled dependencies: a disabled dependency no longer disables its parent, as in lazy.nvim. On LazyVim the cascade was removing nvim-cmp over one snippet engine, and an inline
dependencies = { { 'x', enabled = cond } }would remove its parent. The parent now stays, with a DEBUG note that it may not work, unless its own spec or one withoptional = trueturned the dependency off. lazy.nvim is silent here, and turning off a distro's plugin is routine, so a warning would repeat on every startup. DEBUG notes only appear with'verbose'set, becausevim.notify's default handler echoes every level.Missing events and imports: an
eventwith no real event in it, such as'LazyFile', leaves the plugin eager with a warning, where it used to never load. An import that finds no specs warns (first half of zpack_nvim-na9).Install sessions (zpack_nvim-8cy): lazy plugins get their triggers in a session that installs a plugin. The guard that skipped them dates from when a pending build loaded every plugin, which stopped being true in de89d10. Pending builds now run first and what they loaded gets no trigger, so a command proxy can't outlive the load.
Imports from plugins (zpack_nvim-na9):
{ 'u/dist', import = 'dist.plugins' }looks in that plugin if the config andruntimepathdon't have the module, and{ import = 'dist.plugins.extra' }specs find it too. An installed plugin is read from disk without activating it, becausevim.packkeeps the first spec it gets for a plugin. A missing one is installed from its merged spec, soversion,urlandnameon other specs apply and a disabled or removed plugin isn't installed, and then the specs are read again, as lazy.nvim does after an install. Aversionorurlset only inside the plugin's own modules can't be known before the install: it applies from the next session and the next:ZPack update, as in lazy.nvim, which also installs before it reloads the specs (decision zpack_nvim-9do). It goes onpackage.path, so a lazy plugin's modules still load it. A plugin listed without animportof its own isn't searched, unlike lazy.nvim.Load order (zpack_nvim-yvt): plugins source
plugin/dependency first, as in lazy.nvim, eager and lazy. A lazy plugin goes onruntimepathfirst, because a dependency's scripts can require it, as nvim-cmp sources do. Siblings load in the order they're listed instead of a random order per process.configorder of an eager plugin and its dependencies doesn't change.setup()after startup now sourcesplugin/, which it never did for eager plugins.Failures and versions: a plugin
vim.packcan't add reports the error and is dropped, andsetup()carries on, so one bad plugin no longer stops the others' config and:ZPack. Aversionstring git can't take as a ref (*,1.*,^1.0) is a semver range, as a distro's specs expect; anything that could be a ref, such as1.x, stays one. A range written as a string (here, insem_versionor indefaults.version) follows the default branch if no tag fits, as in lazy.nvim, for both install and:ZPack update(zpack_nvim-uzm); that reads each such plugin's tags at startup, about 2 ms over 57 plugins, and costs nothing otherwise. Avim.version.range()object keepsvim.pack's error. Modules import in module name order withinit.luaas the module itself, as in lazy.nvim, which matters now that the last spec wins.Loads after startup: a
requireor build that loads a plugin after startup sources itsplugin/andafter/plugin, where it used to mark the plugin loaded without running them, losing its commands for the session. A plugin's command proxies go once no other pending plugin claims them, before it loads, as in lazy.nvim, so its own:command Foodoesn't fail; a real command that replaced a proxy stays. A dependency that throws no longer stops its parent.after/pluginis left to native startup only for a load duringinit.lua, where it was sourced twice; any later load sources it, including one from the file on the command line, and so doessetup()after startup.Spec shapes (zpack_nvim-dso, zpack_nvim-nvx): a shared spec's import stays while any listing does, and a nested list with a named field is a list.
Also: a dependency of a lazy plugin's dependency is lazy.
{ name = 'foo', dev = true }is a plugin everywhere instead of being dropped underdependenciesandspecs. On a spec with both a source and animport,enabledruns once andcondgets the plugin'snameandspec. A bad spec listed in several places warns once, and a non-stringnameorsem_versionno longer abortssetup().init = falseis valid. Underdev.fallbackwith no checkout,{ name = 'foo', dev = true }joins the plugin of that name.:ZPack cleanonly spares zpack itself. Fold and cascade warnings are accurate. Removed the unusedenabledmerge strategy and an unreachable branch.Behaviour changes:
{ '<a>', '<b>' }is two keys.{ lhs, '<cmd>...' },<Plug>,:,<Nop>, empty and...<CR>forms stay one key, but a lone<CR>or<C-x><CR>is a key, so{ '<Tab>', '<CR>' }is two. This silently splits other two-string keys such as{ '<leader>d', 'dd' }, which were one key before: they need a field or nesting. There's no warning, because it would fire on valid two-key lists, so this is in the release notes.enabledorcondreplaces an earlier one, where anyfalseused to win. Specs underdependenciesmerge first, so a top-level spec has the last word (zpack_nvim-86p).event = 'LazyFile'alone loads at startup, because zpack doesn't define that LazyVim event. Use{ 'BufReadPost', 'BufNewFile', 'BufWritePre' }.plugin/scripts of eager plugins source in the opposite order, and dependencies now come before their parents inruntimepath, so which of the two wins a shadowed runtime file flips.event = {}used to leave it lazy with nothing to load it.versionstring containing*or starting with^,~,<,>or=is a semver range, not a git ref.foo.luacomes beforefoo-extra.luaand the later spec's value wins.setup()no longer throws whenvim.pack.addfails; it reports the error and continues without those plugins.plugin/after them, not before.commit,tag,version,sem_version,branch, thendefaults.version. It wasversion,sem_version,branch,tag,commit. So a user'scommitbeats a distro'sversion = '*', andversion = falsekeeps abranch,tagorcommit, which LazyVim's nvim-treesitter spec relies on.optionalskip that main echoes on every startup, need'verbose'.Not changed: a dropped spec's
namecan still decide the same-name fold (decision zpack_nvim-1we). A plugin whose only keys are{ lhs, false }or{ lhs, '<Nop>' }stays lazy, as in lazy.nvim, and loads when something requires it; documented indocs/tips.md(decision zpack_nvim-0ax).Startup cost: on the 150-plugin bench, merged with #50, this PR costs 3.6 and 4.7 ms of CPU over main in two paired 40-round realistic runs at load 4, on a total of about 60 ms, and 0.8 ms on the eager scenario. The fixes from the last review add under 0.1 ms. It isn't reduced. It pays for tracking where each spec was declared, re-merging what pruning trims and key identity, and I have no profile that singles out a hot spot. It should be profiled now that #50 is merged, tracked in zpack_nvim-8to.
Checked against real configs: LazyVim's spec files (core, all 115 extras, seeded random subsets) and my own config, against main and lazy.nvim's resolver. My config installs the same plugins with the same options. On LazyVim, install sources are corrected, the plugin set and dependency edges match lazy.nvim's apart from the harness's
<expr>placeholders, and the only plugins going from lazy to eager are nine withevent = 'LazyFile'and no other trigger, which never loaded before. The last round's fixes changed nothing there exceptversion = '*'becoming a range, and one<expr>warning going away. Realvim.packinstalls were checked on a small spec from local repos only, not on these configs: fresh install and restart, triggers, build,plugin/order, imports from a plugin and:ZPack cleanon nightly, and the suite on v0.12.0 and nightly. LazyVim's function bodies aren't run, because they're third-party code, so function-form triggers are covered by tests and that small spec.Closes zpack_nvim-uzm, zpack_nvim-2fu, zpack_nvim-tp7, zpack_nvim-5n0, zpack_nvim-8cy, zpack_nvim-na9, zpack_nvim-dso, zpack_nvim-nvx, zpack_nvim-yvt
BEGIN_COMMIT_OVERRIDE
feat: close lazy.nvim parity gaps in spec lists, keys, triggers and bare names
fix(keys)!: two strings in
keysare two keys unless the second can only be an rhs, so{ '<leader>d', 'dd' }needs a field or nesting to stay one keyfix(keys)!: a later spec's key replaces an earlier one
fix(import)!: a bare name nothing defines is skipped rather than installed
fix(merge)!: a disabled dependency no longer disables the plugin listing it
fix(merge)!: the last spec to set
enabledorcondwins, instead of anyfalsefix(lazy)!: an
eventwith no real event in it, such asLazyFile, leaves the plugin eager with a warningfix(utils)!: a
versionstring git can't take as a ref, such as*, is a semver rangefix(import)!: spec modules import in module name order
fix(version)!: version fields follow lazy.nvim's priority: commit, tag, version, sem_version, branch, default
fix(version): a semver string no tag fits follows the default branch
fix(loader): a require or build after startup sources plugin/, and a failing dependency doesn't stop its parent
fix(import): warn when an import finds no specs
feat(import): import specs from a plugin's own modules
fix(lazy): set up triggers in a session that installs a plugin
fix(loader): source a plugin's dependencies before its plugin/, in the order they're listed
fix(setup): a plugin vim.pack can't add no longer aborts setup()
fix(setup): source plugin/ when setup() runs after startup
fix(merge): plugins under an optional or disabled parent's
specsare dropped (#49)fix(merge): a table listed at the top level and under a lazy parent's
dependenciesloads eagerly (#49)END_COMMIT_OVERRIDE