8000 fix: close lazy.nvim parity gaps in spec lists, keys, triggers and bare names by zuqini · Pull Request #51 · zuqini/zpack.nvim · GitHub
[go: up one dir, main page]

Skip to content

fix: close lazy.nvim parity gaps in spec lists, keys, triggers and bare names - #51

Merged
zuqini merged 7 commits into
mainfrom
zpack-parity-followup
Oct 31, 2026
Merged

zuqini merged 7 commits into
mainfrom
zpack-parity-followup

Conversation

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

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 specs warns instead of aborting setup(). { 'u/x', import = ... } is both a plugin and an import. enabled and cond decide whether to follow an import, as in lazy.nvim, so at an import cond works like enabled: skipped plugins aren't installed, and :ZPack clean sees them as unused. A dependencies entry 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 under dependencies and specs stays, as in lazy.nvim. Under LazyVim this was installing gitsigns, snacks, mini.diff and others from https://github.com/<name>.

optional (zpack_nvim-tp7): it now applies to specs under dependencies, 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 false or vim.NIL rhs disables it. One key spelled two ways is one key. "Later" means merge order: specs under dependencies merge 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, false clears a trigger, and a malformed trigger reports its plugin instead of aborting setup(). A bad event name loses only itself, so { 'LazyFile', 'VeryLazy' } still loads on VeryLazy. A trigger nothing can fire (cmd = { 123 }, cmd = 'foo', event = { {} }, event = 'BufRead,BufNewFile', an unusable pattern, 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.

enabled and cond (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's enabled = 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 with optional = true turned 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, because vim.notify's default handler echoes every level.

Missing events and imports: an event with 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 and runtimepath don't have the module, and { import = 'dist.plugins.extra' } specs find it too. An installed plugin is read from disk without activating it, because vim.pack keeps the first spec it gets for a plugin. A missing one is installed from its merged spec, so version, url and name on 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. A version or url set 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 on package.path, so a lazy plugin's modules still load it. A plugin listed without an import of 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 on runtimepath first, 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. config order of an eager plugin and its dependencies doesn't change. setup() after startup now sources plugin/, which it never did for eager plugins.

Failures and versions: a plugin vim.pack can't add reports the error and is dropped, and setup() carries on, so one bad plugin no longer stops the others' config and :ZPack. A version string 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 as 1.x, stays one. A range written as a string (here, in sem_version or in defaults.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. A vim.version.range() object keeps vim.pack's error. Modules import in module name order with init.lua as the module itself, as in lazy.nvim, which matters now that the last spec wins.

Loads after startup: a require or build that loads a plugin after startup sources its plugin/ and after/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 Foo doesn't fail; a real command that replaced a proxy stays. A dependency that throws no longer stops its parent. after/plugin is left to native startup only for a load during init.lua, where it was sourced twice; any later load sources it, including one from the file on the command line, and so does setup() 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 under dependencies and specs. On a spec with both a source and an import, enabled runs once and cond gets the plugin's name and spec. A bad spec listed in several places warns once, and a non-string name or sem_version no longer aborts setup(). init = false is valid. Under dev.fallback with no checkout, { name = 'foo', dev = true } joins the plugin of that name. :ZPack clean only spares zpack itself. Fold and cascade warnings are accurate. Removed the unused enabled merge 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.
  • The later spec's key wins (it was the first).
  • A bare name nothing defines is skipped, not installed from a URL that doesn't exist.
  • A plugin with a disabled dependency stays, where it used to be removed with a warning.
  • A later enabled or cond replaces an earlier one, where any false used to win. Specs under dependencies merge 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 in runtimepath, so which of the two wins a shadowed runtime file flips.
  • A trigger resolving to nothing leaves the plugin eager, where event = {} used to leave it lazy with nothing to load it.
  • A version string containing * or starting with ^, ~, <, > or = is a semver range, not a git ref.
  • Spec modules import in module name order, so foo.lua comes before foo-extra.lua and the later spec's value wins.
  • setup() no longer throws when vim.pack.add fails; it reports the error and continues without those plugins.
  • A lazy plugin with dependencies sources its plugin/ after them, not before.
  • When a plugin's specs set several version fields, the order is lazy.nvim's: commit, tag, version, sem_version, branch, then defaults.version. It was version, sem_version, branch, tag, commit. So a user's commit beats a distro's version = '*', and version = false keeps a branch, tag or commit, which LazyVim's nvim-treesitter spec relies on.
  • DEBUG notes, including the optional skip that main echoes on every startup, need 'verbose'.

Not changed: a dropped spec's name can 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 in docs/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 with event = 'LazyFile' and no other trigger, which never loaded before. The last round's fixes changed nothing there except version = '*' becoming a range, and one <expr> warning going away. Real vim.pack installs 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 clean on 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 keys are two keys unless the second can only be an rhs, so { '<leader>d', 'dd' } needs a field or nesting to stay one key

fix(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 enabled or cond wins, instead of any false

fix(lazy)!: an event with no real event in it, such as LazyFile, leaves the plugin eager with a warning

fix(utils)!: a version string git can't take as a ref, such as *, is a semver range

fix(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 specs are dropped (#49)

fix(merge): a table listed at the top level and under a lazy parent's dependencies loads eagerly (#49)
END_COMMIT_OVERRIDE

…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
zuqini force-pushed the zpack-parity-followup branch from f7db509 to 5751072 Compare October 31, 2026 07:12
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
zuqini merged commit 85ba638 into main Oct 31, 2026
4 checks passed
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
@zuqini
zuqini deleted the zpack-parity-followup 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

3432
0