8000 fix!: follow lazy.nvim for inits, merge order, key proxies, `:colorscheme`, throwing `opts` and `ftdetect/` by zuqini · Pull Request #54 · zuqini/zpack.nvim · GitHub
[go: up one dir, main page]

Skip to content

fix!: follow lazy.nvim for inits, merge order, key proxies, :colorscheme, throwing opts and ftdetect/ - #54

Merged
zuqini merged 1 commit into
mainfrom
init-require-eager
Nov 6, 2026
Merged

zuqini merged 1 commit into
mainfrom
init-require-eager

Conversation

@zuqini
@zuqini zuqini commented Nov 4, 2026 •
Copy link
Copy Markdown
Owner

Title: fix!: follow lazy.nvim for inits, merge order, key proxies, < 8000 code class="notranslate">:colorscheme, throwing opts and ftdetect/

Follow-up to #53, found in its round-3 review. For 3.0 (#48). Fixes bead zpack_nvim-0la and reverses decision zpack_nvim-86p. Together the first two changes make AstroNvim's own mason-tool-installer.lua work: under zpack it errored at every start, first in its init, then in astrocore's opts. The other six came out of this PR's review rounds, each to what lazy.nvim does: a lazy plugin's trigger works from an init, key proxies (two commits), :colorscheme of a pending plugin's scheme, a throwing function-form opts, and an ft plugin's ftdetect/ sourced once. On top, an eager plugin's ftdetect/ now runs when filetype detection is on before setup(), the one item of bead zpack_nvim-ckx fixed here.

An init can require an eager plugin

What changed: a require from an init now finds an eager plugin's module and loads that plugin there, as in lazy.nvim: its dependencies first, then its config and keys. The startup loop then skips it. Before this the require failed with "module 'x' not found", because every init runs before any eager plugin is on 'runtimepath' and the module loader only looked at lazy plugins.

Why: AstroNvim's own mason-tool-installer.lua has init = function(plugin) require("astrocore").on_load("mason.nvim", plugin.name) end, and astrocore is lazy = false, priority = 10000. Under zpack that init errored at every start. The same failed for an eager library at any priority and for a plugin's own init requiring its own module. lazy.nvim's get_unloaded_rtp lists every plugin not loaded yet, start plugins included, so auto_load loads the start plugin early (lua/lazy/core/loader.lua, util.lua). Same at 2.0.4 (ec09994).

How:

  • The module loader looks at eager plugins too, from before lazy.setup_triggers (so before the inits) until they are on 'runtimepath' (after the batched add, or the :packadd pass when setup() runs late). After that require finds their modules on 'runtimepath' first, so they drop out of the scan, and a missing-module probe in a config costs what it did.
  • The plugin loads through plugin_loader.process_spec, the path lazy plugins already take. When setup() runs from init.lua that means :packadd!, so its plugin/ still runs at native startup, after every init, and sees the vim.g settings later inits set, its own included.
  • The early :packadd! put the plugin ahead of its place on 'runtimepath'. It comes off again just before the batched add, so plugin/ order and shadowed runtime files stay as docs/tips.md describes for 3.0: higher priority first, an eager dependency before the plugin listing it. A lazy dependency it loads stays behind the eager plugins, as any lazy plugin an init loads already did at 241c9ba.
  • When setup() runs late (at VimEnter, or from a plugin/ script), the :packadd pass skips a plugin already loaded, so its plugin/ isn't sourced twice, also when its own lazy dependency's config loaded it through a plugin listing it. That also covers a lazy dependency's config requiring a later eager plugin during that pass, which failed the same way before, and fixes the "E after A" order of zpack_nvim-uqx. The pass also marks a plugin sourced as it sources it, and plugin_loader.process_spec then runs only its config: so the "E before A" order of uqx (an eager plugin sourced earlier in the pass, then listed by a lazy dependency loading there), and a plugin/ requiring a lower-priority eager plugin that lists it under dependencies, no longer source its plugin/ a second time. When setup() runs from init.lua, every eager plugin is on 'runtimepath' by the time any config runs, so that require returns the module before the plugin's config, as at 241c9ba and 2.0.4; lazy.nvim loads it first. That's zpack_nvim-3ph, not changed here. The module loader leaves a plugin already on 'runtimepath' to the searchers after it, so a lazy dependency's config requiring its eager parent's compiled module during that pass doesn't load the parent inside that config (the sourced mark covers the parent's own plugin/).

An init that runs vim.cmd.colorscheme for its own eager plugin (kickstart.nvim's old tokyonight spec) works too now; see the :colorscheme section below for a Vim colors file that calls its plugin's autoload/.

Behaviour change: an init guarded with pcall(require, ...) now gets the module, and that plugin's config runs during the init, before the other eager plugins' config whatever their priority, and before its own init if that comes later (inits run highest priority first; lazy.nvim runs them in hash order, so it can do the same). docs/tips.md "Upgrading to 3.0" and :h zpack-Spec.init say so. Its plugin/ still runs at native startup when setup() runs from init.lua, as for any eager plugin, so a command it defines isn't there yet in the init; lazy.nvim sources plugin/ as it loads the plugin. :h zpack-Spec.init and the tips.md compatibility notes now say that. Since 241c9ba, an init requiring a module of an eager import owner ({ 'u/dist', import = 'dist.plugins' }) not loaded yet loads the owner there, with its config, as in lazy.nvim. It used to get the module through the import path without loading the owner.

Tests: real-process tests in tests/startup_process_test.lua, on nightly and v0.12.0:

  • an init requiring an eager plugin's module at priority 50, 1000 and 10000, and a plugin's own init requiring its own module: the require returns the module, the plugin's config ran before it returned, and config, plugin/ and after/plugin each ran once.
  • the AstroNvim shape (eager priority 10000 library, a cmd-lazy plugin whose init requires it and lists it under dependencies with function opts): the library loads, the lazy plugin stays pending.
  • setup() at VimEnter and from the config's plugin/ (v:vim_did_init is 1 there, as in real startup): loaded once.
  • a lazy dependency's config requiring a later eager plugin during the late :packadd pass: loaded once. The same requiring a plugin that lists its parent under dependencies, at VimEnter and from plugin/: the parent's plugin/ and config run once.
  • the other eager plugins keep priority order for config, and the early one keeps its place in plugin/ and after/plugin order; it sources before a plugin listing it as a dependency.
  • every plugin/ sees the vim.g the inits set, including one set by the early plugin's own later init.
  • a lazy and an eager dependency of the early plugin load first, once; the eager one's plugin/ runs before the plugin's, the lazy one's after every eager plugin's.
  • setup() at VimEnter or from plugin/, an eager plugin whose plugin/ (or its lazy dependency's config) requires its compiled module: plugin/ and config run once, and the dependency's config finishes before the parent's plugin/.
  • zpack_nvim-0de's "stay in reach of an init" pin still passes.

make_repo's plugin/ script also logs whether vim.g.<name>_flag was set, and start() can put setup() in the config's plugin/. Mutation checks: scanning only lazy plugins again fails all 13 init tests; dropping the 'runtimepath' check in the module loader fails "doesn't load it again from its lazy dependency's config" (the sourced mark covers the two plugin/ ones); dropping the skip in the :packadd pass fails the five late-setup() ones, and checking before the lazy dependencies load fails the two that load the parent from its dependency; dropping the 'runtimepath' re-placement fails the two order ones and "loads its lazy dependency first, once".

A plugin's specs merge in import order

What changed: a plugin's specs merge in import order, as in lazy.nvim, with a spec under dependencies right after the spec listing it. Before this every spec under some plugin's dependencies merged first and the top-level ones after, so a top-level spec always won (merge_fields.sort_specs, the same since 2.x). The change drops that tier from the sort (-5 lines).

Why: AstroNvim's mason-tool-installer.lua lists astrocore under dependencies with opts = function(_, opts) local maps = opts.mappings; maps.n["<Leader>pM"] = ... end. opts.mappings comes from astrocore's own spec in _astrocore_mappings.lua, imported earlier. Merged first, the function found maps nil, astrocore's config threw at every start and none of AstroNvim's core mappings were set; with the first change the error also surfaced from mason-tool-installer's init. lazy.nvim merges a plugin's fragments in the order meta._.frags holds them, which is import order (lua/lazy/core/meta.lua _rebuild, fragments.lua add). zpack_nvim-86p kept the old rule so the result didn't depend on lua/plugins/*.lua order; the owner reversed it for this.

Behaviour change: a later spec under dependencies can now override a top-level field, replace or disable its key, clear its trigger with false and set enabled, and its function-form opts gets the top-level opts before it. opts_extend lists append in this order too. Which spec's source wins when specs of two owners fold into one plugin hasn't changed: fold.lua's tiers (dev, explicit source, top-level, dependency) pick only the source, and keep #39's shape independent of import order. docs/tips.md "Upgrading to 3.0" gains an entry, its compatibility note on merge order is gone, and :h zpack-Spec.dependencies describes the order, with enabled, opts_extend, event and keys pointing to it. One shape still differs from lazy.nvim: a plugin listed inside its own subtree (x -> y -> x under dependencies) merges the outer spec first, lazy.nvim the inner one.

Tests: six tests pinned the old rule and now pin the new one, each rewritten on purpose: "a standalone spec's key beats a dependency's" (now "a later spec under dependencies replaces a top-level spec's key", plus the reverse case), "give a top-level spec the last word over a later spec under dependencies" (now "let a later spec under dependencies disable a top-level plugin"), "sort_specs puts dependencies before standalone", "opts_extend appends lists from dependencies specs before top-level ones" and two opts_extend order assertions in lazy_parity_test.lua ({ 'delve', 'stylua' } is now { 'stylua', 'delve' }, lazy.nvim's result). The resolve fuzz digest changes too: 769 of 1000 seeds decide something different, all from merge order (fragment order, then what enabled, name and the merged fields give). New:

  • real-process: the AstroNvim shape (an eager priority 10000 plugin with function opts setting opts.mappings, and a cmd-lazy plugin whose init requires it and lists it under dependencies with function opts writing into opts.mappings): config gets both. A later spec under dependencies overrides a top-level key's desc, clears a top-level cmd with false (the plugin turns eager) and disables a top-level enabled = true plugin. A later top-level spec still overrides an earlier one under dependencies.
  • in-process: event, cmd, ft and keys cleared by a later false under dependencies.

Mutation check: with the dependency tier back, the four order-sensitive real-process tests and all the rewritten and new in-process ones fail; the later-top-level one passes both ways, as it should.

A lazy plugin's trigger works from an init

What changed: a lazy plugin's cmd, event, ft and keys are set up before the inits, as in lazy.nvim, so an init running one loads the plugin. Before this vim.cmd('Foo') from an init, Foo being a lazy plugin's cmd, failed with "Not an editor command", and an event, filetype or key an init triggered loaded nothing: the triggers were set up after the eager plugins' config. Same at 241c9ba and 2.0.4. Bead zpack_nvim-1hr, found in round 5.

Why: lazy.nvim's Loader.setup() runs Handler.setup(), which enables every unloaded plugin's handlers, before Loader.startup() runs the inits and loads the start plugins (lua/lazy/core/loader.lua, handler/init.lua).

How: lazy.setup_triggers (was lazy.process_all) runs in init.lua's process_all before startup.process_all, after the module loader is in and its cache covers the eager plugins, so a function-form trigger that requires one loads it as an init would (see the behaviour change below). A plugin loaded later in startup (a dependency of an eager plugin, a require, a pending build) drops its proxy command as it loads, which #52's cmd_proxy.release already did, and now its key proxies too (next section). Builds still run after the eager plugins, and the comment on why they had to come before the triggers is gone. The VeryLazy loads and the User VeryLazy emit stay where they were, after the eager plugins and builds: fire_very_lazy now also registers the VeryLazy loads that event.setup collected. lazy.nvim also registers its VeryLazy emit after startup (LazyDone), so a callback an eager config schedules still runs before a VeryLazy plugin loads when setup() runs at VimEnter.

Behaviour change: with the triggers set up earlier, configs that don't touch them in an init change in four ways, each to what lazy.nvim does, and in a fifth where lazy.nvim differs. An event an eager config triggers (a User event, ColorScheme from vim.cmd.colorscheme()) loads the lazy plugins waiting on it there. An autocmd an init or eager config creates runs after the lazy plugin waiting on the same event has loaded, not before. A mapping an init or eager config sets on a lazy plugin's keys lhs, or a command it defines with a lazy plugin's cmd name, replaces zpack's, so the key or command no longer loads the plugin; it used to be the other way round. One difference from lazy.nvim stays here: when the plugin then loads some other way, a :command without ! in its plugin/ fails with E174, since cmd_proxy.release keeps a command that isn't zpack's (as it keeps the real command another plugin claiming the name defined), where lazy.nvim deletes whatever has the name. A function-form trigger is called before the inits, so it doesn't see a vim.g an init sets. Nor does an ft plugin's ftdetect/ when filetype detection is already on as setup() runs, at VimEnter or after a filetype on in init.lua (lazy.nvim's ft handler sources it in add); when it isn't on yet, it runs after the eager plugins, or as the plugin loads if an init or eager config turned detection on before that (next paragraph). The fifth: a require in a function-form trigger loads the plugin holding the module right there, so its config runs before every init, its own included, and before the other eager plugins' config. 241c9ba loaded a lazy plugin after the eager plugins' config, and an eager one in its priority turn; lazy.nvim returns the module and loads the plugin after the inits, in its turn (auto_load waits for did_handlers). An eager plugin with module = false isn't found there, as in lazy.nvim. The fix is to require inside the key's or command's function, as LazyVim's harpoon2 keys do; an event or ft function can return a static list and leave the module to the plugin's config or an autocmd's callback. No distro or config we checked has the shape: LazyVim's four trigger functions (harpoon2, leap and mini.surround keys, elixir's render-markdown ft) require no plugin's module when called, and AstroNvim, kickstart and my config have none that do. docs/tips.md "Upgrading to 3.0" gains an entry, :h zpack-Spec.init says the triggers are set and what a require in a function-form one does, and :h zpack-Spec.event, cmd, keys and ft point there. An eager plugin's own keys on a lazy plugin's keys lhs leave zpack's proxy, as at 241c9ba (next section). A lazy plugin an init or an eager config loads this way when setup() runs from init.lua sources plugin/ at the load and again at native startup, like one init.lua loads by its trigger, and as a bare :packadd in init.lua does in plain Neovim: zpack_nvim-3iy, closed won't-fix; tips.md says so.

ftdetect/ once: a lazy ft plugin's ftdetect/ is now sourced once, as in lazy.nvim (Loader.ftdetect keeps did_ftdetect per path). zpack sourced it at trigger setup, and :packadd sourced it again when the plugin loaded after filetype detection was on; with the triggers before the inits, Neovim's filetype on also sourced it again for one that loaded during startup from init.lua (an eager plugin's dependency, a require from an eager config). So an ftdetect/ that runs set filetype= (cql-vim, pkl-neovim, vim-godot and vim-arduino in my config) fired FileType twice for every later file of that type, and ftplugin, syntax, LSP and treesitter attach ran twice. The :packadd half is the same at 241c9ba; r6 and r8 dropped it as "lazy.nvim does the same", which it doesn't. Now runtime.packadd_sourcing loads a plugin whose ftdetect/ zpack sourced with :packadd! and sources its plugin/ itself, as :packadd does (.vim files, then .lua, ignoring 'wildignore'); a plugin with no ftdetect/ still loads with :packadd. From init.lua, before filetype detection is on, ft.setup leaves ftdetect/ until the eager plugins and builds are done and then sources it for the ft plugins still pending. A lazy plugin init.lua loads after the config turned detection on (an init running syntax enable) gets its ftdetect/ sourced at the load, as :packadd would; Neovim's filetype on has run by then, so at 241c9ba it got none. One per-file sourcing helper serves plugin/, after/plugin/ and ftdetect/. docs/tips.md "Smaller changes" 8000 says so.

Tests: real-process, nightly and v0.12.0: an init running a lazy plugin's cmd (its plugin/ defines the real command), User event, ft (:setfiletype) and keys (:normal), with setup() in init.lua and at VimEnter: the plugin's config (and the real command or key) ran before the init went on. A VeryLazy plugin with setup() at VimEnter loads after a callback an eager config scheduled. An init running the static cmd of a plugin whose keys are a function. A keys function requiring an eager plugin's module, or its own lazy plugin's: that plugin's config runs right there, before the inits and the higher-priority configs, pinned so a change to it is deliberate; an eager config at priority 10, 50 or 100 requiring that lazy module gets it configured; an init removing that plugin still lets setup() finish; an init removing a lazy event = 'FileType' plugin leaves FileType firing once, not re-fired by its stale trigger (round 20; mutation: the old guard fails both modes). In-process: a cmd, a key and an ft-scoped key that a static and a function-form trigger share load both plugins. The PR's existing real-process tests pass unchanged, including plugin/ order and vim.g from inits. Mutation check: with the triggers set up after the eager plugins again, all 10 trigger tests fail; registering the VeryLazy loads in event.setup again fails the VeryLazy one; setting only the function-form triggers up after the eager plugins, or having their requires load the plugin after the inits, fails the four "before the inits" ones. A lazy ft plugin whose ftdetect/ runs set filetype=, listed under an eager plugin's dependencies or loading on its filetype, with setup() in init.lua and at VimEnter: ftdetect/ runs once and FileType fires once per file after the first. Mutation check: with :packadd again, three of the four fail; sourcing ftdetect/ at trigger setup from init.lua again fails the fourth. Also from init.lua: on its first install with a build; with an init turning filetype detection on before it loads, and an eager config turning it on after; with filetype on before setup(), where its ftdetect/ runs before the inits; with filetype plugin indent on before setup() and no ft trigger, as a lazy = true dependency or required from an init; with its own dependency's config running syntax enable; and loading on its filetype with 'wildignore' set to *.lua, which sources its plugin/ .vim first, as :packadd does. In-process: an ft plugin without ftdetect/ loads with :packadd. Mutation check: each of these fails one of them: running the builds after ftdetect/; no ftdetect/ at a load once detection is on, or only for ft plugins; sourcing it there after the dependencies; sourcing it after the eager plugins for every deferred plugin once detection is on; deferring it with detection already on; a {vim,lua} glob or one honouring 'wildignore'; marking a plugin with no ftdetect/ files.

A lazy plugin's key proxies follow lazy.nvim

What changed: a plugin drops the proxies of its keys that no other pending plugin lists as it starts to load, before its plugin/ and config, however it loads (a require, a dependency, its cmd, a key, a build). And a key keeps its proxy while a plugin listing it is pending: another plugin's key on the same lhs, mode, ft and buffer, eager or lazy, isn't mapped over it. Before this, only proxy commands were dropped (cmd_proxy.release), and a loading plugin's keys replaced any proxy. Beads zpack_nvim-cg8 and zpack_nvim-coy, found in round 6.

Why: moving the triggers before the inits made two old gaps show at startup. flash.nvim with keys = { 'f', 'F', 't', 'T', ';', ',' } maps ; and , only when they're free; loaded by an eager config's require, it found zpack's proxies there, and the first ; fell through to the native key. Pressing f first did the same at 241c9ba. And an eager plugin's keys, bound after every config, now came after the proxies: in my config snacks.nvim's eager <leader>gh replaced gitlinker.nvim's proxy, so gitlinker no longer loaded from it. lazy.nvim keeps one handler per key until every plugin claiming it has loaded (handler/init.lua M:add, M:del), and Loader._load calls Handler.disable before sourcing the plugin.

How: key_proxy.lua, next to cmd_proxy.lua. release(src) runs beside cmd_proxy.release in process_spec and deletes each global or buffer-local proxy of the plugin's keys that no other pending plugin lists, where the mapping is still that proxy (one callback per key, compared by identity), so a mapping an init or config put there stays. bind_keys leaves out the modes whose proxy a pending plugin still lists, and a pressed proxy stops counting.

Behaviour change: against 241c9ba, a lazy plugin's other key proxies are gone once it loads, and a key two lazy plugins list stays a proxy until both load: when one loaded by a require, its mapping used to replace the proxy, so the other never loaded from that key. An eager plugin's keys against a lazy plugin's give the proxy, as at 241c9ba. Which plugin's rhs a shared key ends up with can differ from lazy.nvim, whose pick depends on table order; the pending plugin loads in both (decision zpack_nvim-zhe). docs/tips.md "Smaller changes" and :h zpack-Spec.keys say so.

Tests: real-process, nightly and v0.12.0, setup() in init.lua and at VimEnter: an eager plugin's and a lazy plugin's key on one lhs (pressing it loads the lazy plugin and runs its rhs), a lazy plugin with an lhs-only key that its config maps only when free, loaded by an eager config's require and by pressing its other key. Mutation check: without unclaimed the two shared-key tests fail; without release the four free-key tests fail. Probes over shared keys across lazy plugins and modes, ft- and buffer-scoped keys, an abbreviation, and a config mapping over the proxy match lazy.nvim apart from the rhs pick and that last one (lazy.nvim deletes the user's mapping).

:colorscheme loads the plugin holding the scheme

What changed: :colorscheme x first loads the pending plugin that holds colors/x.vim or colors/x.lua, as in lazy.nvim: a lazy one at any time, an eager one before its config has run (during the inits, or from an earlier plugin's config with setup() in init.lua). Before this :colorscheme found the file through 'packpath' and sourced it without loading the plugin, so a Vim colors file calling its plugin's autoload/ failed with E117 from an init, and for a lazy plugin always (gruvbox-material, everforest and sonokai all start with <name>#get_configuration()). Same at 241c9ba and 2.0.4. Bead zpack_nvim-las.

Why: lazy.nvim's Loader.setup() registers a ColorSchemePre autocmd before the inits that loads the unloaded plugin holding the scheme (lua/lazy/core/loader.lua M.colorscheme).

How: lazy_trigger/colorscheme.lua sets up one ColorSchemePre autocmd with the lazy triggers, before the inits. It stats colors/<name>.{lua,vim} in each eager plugin not loaded yet that cond lets load, then, if the name isn't on 'runtimepath', in each such lazy one, and loads the first match through plugin_loader.process_spec. An eager plugin is on 'runtimepath' before its config runs, which lazy.nvim's isn't, so the name being there doesn't stop it. No work at startup beyond the autocmd; about 0.3 ms per :colorscheme of a name off 'runtimepath' with 150 pending plugins.

Behaviour change: a lazy colorscheme plugin now loads, with its config and plugin/, when its scheme is set (from a picker, say). Before, only its colors file ran. A Lua colorscheme's plugin now loads before its colors file, not from the file's require. An eager colorscheme plugin set from an earlier eager config (a higher priority, or the same one listed first), as themery.nvim's README spec restores the saved scheme with a user's eager gruvbox or catppuccin listed after it, now loads there with its config, before its turn, as in lazy.nvim (where LazyVim's colorscheme option, at priority 10000, relies on it); at 241c9ba its colors file ran first and its setup(opts) came in its turn, so the opts were lost at startup (round 20). With setup() at VimEnter or from plugin/ its scripts are sourced before any config, so it still gets only its colors file there; tips.md says to give it the higher priority. docs/tips.md "Smaller changes" and :h zpack-Spec.lazy say so. Not changed: with setup() inside a non-nested VimEnter autocmd, ColorSchemePre doesn't fire, so an init's :colorscheme there still gives E117 (lazy.nvim: E185), and a cond = false plugin's scheme is still sourced through 'packpath' (lazy.nvim: E185).

Tests: 7 real-process tests on nightly and v0.12.0: an eager plugin's own init and a lazy plugin set from another init, each with setup() in init.lua and at VimEnter; an eager plugin set from a priority-10000 config, whose colors file sees its config ran; a lazy plugin set after startup; a name no plugin holds (E185) and a builtin one, with the lazy plugin left pending. Mutation check: without the autocmd, 5 fail; with only lazy plugins considered, the 2 own-init tests fail; with the 'runtimepath' check ahead of the eager plugins again, the priority-10000 one fails; without it, the builtin one fails.

A function-form opts that throws no longer stops a load

What changed: when a plugin's function-form opts throws, the error is reported once ("Failed to run config for x") and the plugin loads without its config or setup(), as in lazy.nvim. Before this the throw left the plugin pending: a require of a lazy plugin raised, the key that loaded it did nothing, an install's build was skipped, and an eager plugin stayed unloaded with no keys mapped. Bead zpack_nvim-dvi.

Why: lazy.nvim's M.config resolves Plugin.values inside its Util.try (lua/lazy/core/loader.lua). The first change made the old behaviour reachable from an init: an eager plugin an init required, whose opts threw, was loaded again by the startup loop, so its opts ran and were reported twice, its plugin/ was sourced twice when setup() ran late (round 3, R3-2), and with setup() in init.lua it stayed behind every batched plugin on 'runtimepath' (round 4, R4-C3). The same double run happens at 241c9ba through a lazy dependency.

How: plugin_loader.run_config pcalls resolve_opts and returns after the report, as it already did for a throwing config. The startup loop's pcall and pending reset around run_config go, since nothing in it throws now (+9/-12).

Behaviour change: such a plugin is loaded, so its keys are mapped, what loaded it goes on, :ZPack load no longer lists it, and build runs. docs/tips.md "Upgrading to 3.0" (the entry that was "A dependency that fails no longer stops a lazy parent") and :h zpack-Spec.opts say so.

Tests: three tests pinned the old behaviour and change on purpose: lifecycle's "startup run_config loop continues after a function-form opts throws" now asserts the plugin loads without its config, reported once; zload's load! order test and lazy_parity's "a dependency that throws doesn't stop its parent" used a throwing opts only to fail a load and now use a throwing :packadd (the load! order now includes the two startup plugins). New: real-process, an init requiring an eager plugin whose opts throw, with setup() in init.lua and at VimEnter (opts, plugin/, after/plugin once, the require returns the module) and R4-C3's shape (plugin/ in priority order); in-process, a lazy plugin's require, a dependency, and an install's build. Mutation check: with the old run_config and startup loop, the six new tests and the rewritten lifecycle one fail.

An eager plugin's ftdetect/ runs when filetype detection is on first

What changed: with filetype on, filetype plugin indent on or syntax on before setup() in init.lua, zpack now sources each eager plugin's ftdetect/ once the eager plugins are on 'runtimepath', in load order, as lazy.nvim does. Before this it never ran: Neovim sources ftdetect/ only for what's on 'runtimepath' as detection comes on, and the eager plugins got there later. So LazyVim's vimtex spec (lazy = false) opened a .cls as st, not tex, and left g:tex_flavor unset. The same for a plugin under an eager plugin's dependencies without lazy = true, and when an init turned detection on. Same at 241c9ba. Bead zpack_nvim-ckx (4).

Why: Vim-migrated configs put filetype plugin indent on at the top of init.lua. lazy.nvim sources each plugin's ftdetect/ as it loads (Loader.packadd, Loader.ftdetect).

How: (+11/-1 lua) after the :packadd! batch, when g:did_load_filetypes is already set, startup.process_all runs runtime.source_ftdetect_files for each eager plugin that was added and that no init loaded first. One an init loaded got its ftdetect/ at that load, from zpack or from the filetype on the init ran; without the check it ran twice.

Behaviour change: an eager vim-godot with detection on first now gets its ftdetect/, and its set filetype= fires FileType a second time per .gd file after Neovim's setfiletype, as with a plain start package. docs/tips.md "Smaller changes" says so, and its plugin/ order now promises only the order among eager plugins: a lazy plugin that loads during startup has no set place among them (bead mle, closed won't-fix). The other five ckx items stay as they are (decision zpack_nvim-1ms).

Tests: real-process, nightly and v0.12.0, with filetype plugin indent on before setup(): an eager plugin, and one under an eager plugin's dependencies without lazy = true, each ftdetect/ once and FileType once per file; with filetype on first, an init deleting an eager plugin still lets setup() finish; with detection off, an init requiring an eager plugin and then turning detection on sources its ftdetect/ once; with detection off, and with setup() at VimEnter, an eager plugin's ftdetect/ once (Neovim's filetype on or :packadd sources it). Mutation check: without the loop the two filetype plugin indent on tests fail; without the init-loaded check the init one fails; without the failed-add check the deletion one fails; without the did_load_filetypes or the ctx.load check one of the last two fails.

Also: an init that removes a plugin with an init of its own no longer aborts setup() in hooks.try_call_hook (same at 241c9ba and 2.0.4; two real-process tests fail without the guard). zpack escapes the path of each plugin/, after/plugin/ and ftdetect/ file it sources itself, so a # or % in the data path no longer fails with E194/E484 and loses the file (same at 241c9ba and 2.0.4; two real-process tests fail without it). A plugin that a require, a :colorscheme or a lazy dependency's load brings in after an earlier config ran filetype on or syntax on gets its ftdetect/ once when it was on 'runtimepath' already (an eager plugin a lazy one lists, an eager scheme plugin), since Neovim's filetype on sourced it then; this branch sourced it again, so a set filetype= there fired FileType twice per file (round 21; three real-process tests, all failing without the 'runtimepath' check). Removing a lazy plugin (an init running vim.pack.del, :ZPack delete, :packdel) drops its cmd and keys proxies: its command is gone (E492; after an init this branch reported "Failed to re-fire", at runtime the stale proxy was a no-op), its key unmapped (the first press was swallowed), and a command it shared with another lazy plugin is free for that plugin's own :command, which failed with E174 and skipped its config (bead 7a2, same at 241c9ba; six real-process and four in-process tests, each failing without its release line).

Checks

  • suite: 1471/0 on nightly and 1470/0 on v0.12.0. luacheck clean, LuaLS no problems.
  • the ckx (4) change, 3c15a39 vs this branch: zpack-lazyvim-compare SEEDS=40 gives no differing line on all-extras, any subset or user, opts included; my config in file mode: 55 plugins, 33 lazy, identical; real2k on my config (nightly and v0.12.0, with and without a file argument): identical. Startup, paired d-cpu vs 3c15a39 with an A/A: realistic and ftd at 150 and 300 plugins within the A/A spread or under 0.8 ms; with filetype plugin indent on first, -0.9 at 150 and +1.3 at 300 (zpack now sources the eager plugins' ftdetect/ that used to be lost); 3 to 25 ms ahead of lazy.nvim in every row.
  • zpack-lazyvim-compare, 241c9ba vs this branch, SEEDS=40, with the lazy.nvim oracle: no P, E or W line differs on all-extras, any subset or my config, so no lazy= flips and no src= changes. Merged opts differ only in mason.nvim's ensure_installed order (all-extras and 22 subsets), and match lazy.nvim's Plugin.values on every plugin of all-extras, the 40 subsets and my config; at 241c9ba those mason lines were the only ones that didn't. Run over the same spec tables, zpack's merge order equals lazy.nvim's fragment order for every plugin with several specs (41 on all-extras, 769 across the subsets; 241c9ba: 3 and 79 differ, mason.nvim, plenary.nvim, which-key.nvim and others). My config through file mode with { import = 'plugins' }: 55 plugins, 33 lazy, identical. 0bf4c57 vs this branch: no line differs, opts included, on all-extras, the 40 subsets and my config. That harness doesn't run init or :colorscheme.
  • AstroNvim's _astrocore_mappings.lua and mason-tool-installer.lua verbatim, with astrocore's own spec, over stub repos in a real startup: astrocore's config gets <Leader>pM and sets the core mappings, and the init calls on_load. At 241c9ba and with only the first change, both error.
  • real startup of my config with XDG isolated (real2.py, 5 probes each): status, 'runtimepath', package.loaded, globals, autocmds, commands and messages identical, 241c9ba vs branch. With a dump of every mode's keymaps, with and without a file argument: identical to 241c9ba and 0bf4c57; before the key proxy fix <leader>gh was snacks.nvim's instead of gitlinker.nvim's proxy. The :colorscheme and opts changes against their parent: identical apart from the new zpack.lazy_trigger.colorscheme module and its ColorSchemePre autocmd (my config sets its scheme after setup(), when its themes have loaded).
  • startup, 150 synthetic plugins, 200 shuffled paired rounds with an A/A, paired d-cpu vs 241c9ba: realistic +0.5 ms [+0.2, +1.0] at load 3 (A/A -0.1 [-0.4, +0.3]) and +0.1 [-0.1, +0.3] in 300 rounds at load 1.3 (A/A -0.0 [-0.2, +0.2]), opening a file +0.0 [-0.4, +0.3] (A/A -0.1 [-0.4, +0.0]) and all eager +0.3 [-0.1, +0.6] (A/A +0.2 [-0.1, +0.5]), so under 1 ms; vs 0bf4c57 +0.2, -0.0 and -0.1, inside the A/A. -3.6 [-4.0, -3.3], -5.3 [-5.6, -4.8] and -65.8 [-66.6, -65.2] vs lazy.nvim. With 50 inits probing 2 missing modules each: +1.0 [+0.7, +1.3] vs 241c9ba (A/A +0.0 [-0.2, +0.2]; +0.2 [-0.2, +0.5] vs 0bf4c57), all of it from the module loader also scanning the 20 eager plugins there, which is how an init finds one (about 12 µs per missing module; lazy.nvim scans every unloaded plugin too); -4.2 [-4.6, -3.9] vs lazy.nvim. Without the narrowing back to lazy plugins in the first change, 100 eager configs probing 3 missing modules each cost +10.4 ms [+5.8, +14.1].

BEGIN_COMMIT_OVERRIDE
fix(startup): a require from an init loads the eager plugin holding the module, as in lazy.nvim, instead of failing with "module not found"
fix(merge)!: a plugin's specs merge in import order, as in lazy.nvim, so a later spec under dependencies overrides a top-level one and its function opts sees the top-level opts
fix(startup): a lazy plugin's cmd, event, ft and keys are set up before the inits, as in lazy.nvim, so an init running one loads the plugin
fix(keys): a plugin drops its key proxies as it loads, as in lazy.nvim, so a plugin that maps a key only when it's free (flash.nvim's ; and ,) gets it
fix(keys): a key keeps its proxy while a plugin listing it is pending, as in lazy.nvim, so another plugin's keys on the same lhs don't stop it loading
fix(startup): :colorscheme loads the pending plugin holding the scheme first, as in lazy.nvim, so a Vim colors file can call its plugin's autoload/ from an init or for a lazy plugin, and an eager scheme plugin set from an earlier plugin's config (themery.nvim's restore) gets its own config first
fix(loader): a function-form opts that throws is reported once and the plugin still loads without its config, as in lazy.nvim, instead of staying pending
fix(ft): a lazy ft plugin's ftdetect/ is sourced once, as in lazy.nvim, so one that runs set filetype= fires FileType once per file
fix(startup): an eager plugin's ftdetect/ runs when filetype detection is on before setup(), as in lazy.nvim, so LazyVim's vimtex opens a .cls as tex
fix(loader): a plugin already on 'runtimepath' that loads after a config turned detection on gets its ftdetect/ once, not twice
fix(runtime): zpack escapes the path of each file it sources itself, so a # or % in the data path no longer fails with E194/E484 and loses the file
fix(hooks): removing a plugin drops its command and key proxies, so its command gives E492 and a command it shared with another lazy plugin works for that plugin; an init removing a plugin no longer re-fires its event or crashes setup() when the plugin has its own init
END_COMMIT_OVERRIDE

@zuqini
zuqini force-pushed the init-require-eager branch from 0bf4c57 to 286eae8 Compare November 4, 2026 06:56
@zuqini zuqini changed the title fix!: an init can require an eager plugin, and a plugin's specs merge in import order fix!: follow lazy.nvim for inits, merge order, key proxies, :colorscheme and throwing opts Nov 4, 2026
@zuqini zuqini changed the title fix!: follow lazy.nvim for inits, merge order, key proxies, :colorscheme and throwing opts fix!: follow lazy.nvim for inits, merge order, key proxies, :colorscheme, throwing opts and ftdetect/ Nov 4, 2026
@zuqini
zuqini force-pushed the init-require-eager branch 2 times, most recently from 3c15a39 to 0d9c975 Compare November 5, 2026 09:34
…heme`, throwing `opts` and `ftdetect/`

- fix(startup): a `require` from an `init` loads the eager plugin holding the module, as in lazy.nvim, instead of failing with "module not found"
- fix(merge)!: a plugin's specs merge in import order, as in lazy.nvim, so a later spec under `dependencies` overrides a top-level one and its function `opts` sees the top-level opts
- fix(startup): a lazy plugin's `cmd`, `event`, `ft` and `keys` are set up before the inits, as in lazy.nvim, so an `init` running one loads the plugin
- fix(keys): a plugin drops its key proxies as it loads, as in lazy.nvim, so a plugin that maps a key only when it's free (flash.nvim's `;` and `,`) gets it
- fix(keys): a key keeps its proxy while a plugin listing it is pending, as in lazy.nvim, so another plugin's `keys` on the same lhs don't stop it loading
- fix(startup): `:colorscheme` loads the pending plugin holding the scheme first, as in lazy.nvim, so a Vim colors file can call its plugin's `autoload/` from an `init` or for a lazy plugin, and an eager scheme plugin set from an earlier plugin's `config` (themery.nvim's restore) gets its own `config` first
- fix(loader): a function-form `opts` that throws is reported once and the plugin still loads without its `config`, as in lazy.nvim, instead of staying pending
- fix(ft): a lazy `ft` plugin's `ftdetect/` is sourced once, as in lazy.nvim, so one that runs `set filetype=` fires `FileType` once per file
- fix(startup): an eager plugin's `ftdetect/` runs when filetype detection is on before `setup()`, as in 
9C2A
lazy.nvim, so LazyVim's vimtex opens a `.cls` as `tex`
- fix(loader): a plugin already on 'runtimepath' that loads after a `config` turned detection on gets its `ftdetect/` once, not twice
- fix(runtime): zpack escapes the path of each file it sources itself, so a `#` or `%` in the data path no longer fails with E194/E484 and loses the file
- fix(hooks): removing a plugin drops its command and key proxies, so its command gives E492 and a command it shared with another lazy plugin works for that plugin; an `init` removing a plugin no longer re-fires its event or crashes `setup()` when the plugin has its own `init`
@zuqini
zuqini force-pushed the init-require-eager branch from 59071b2 to 0a9e71f Compare November 5, 2026 19:44
@zuqini
zuqini merged commit bfd84e3 into main Nov 6, 2026
4 checks passed
@zuqini
zuqini deleted the init-require-eager 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