8000 fix(module_loader)!: load only the plugin holding a required module by zuqini · Pull Request #53 · zuqini/zpack.nvim · GitHub
[go: up one dir, main page]

fix(module_loader)!: load only the plugin holding a required module - #53

Merged
zuqini merged 7 commits into
mainfrom
module-sole-claimant
Nov 3, 2026
Merged

zuqini merged 7 commits into
mainfrom
module-sole-claimant

Conversation

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

Title: fix(module_loader)!: load only the plugin holding a required module

Found in a post-merge review of #52. For 3.0 (#48).

What changed: a require loads a lazy plugin only when the plugin holds the module's file under lua/ (.lua, /init.lua, or a compiled .so/.dll). If no pending plugin holds it, nothing loads and the require fails with Lua's usual "module 'x' not found". Before this, when only one pending plugin had the module's top-level name under lua/, any require under that name loaded it, whether the module was there or not. The change deletes that fallback (net -7 lines in module_loader.lua).

Why: lazy plu 8000 gins turning eager is zpack's main startup risk, and this fallback did it on ordinary optional-module checks:

  • { 'echasnovski/mini.pairs', event = 'InsertEnter', opts = {} } plus pcall(require, 'mini.icons') at startup, as snacks and which-key do: mini.pairs loaded at startup and its setup() ran.
  • { 'nvim-treesitter/nvim-treesitter', event = 'BufReadPost' } on the main branch, which has no configs.lua, plus pcall(require, 'nvim-treesitter.configs'): nvim-treesitter loaded at startup.

lazy.nvim's loader.lua M.loader loads a plugin only when Cache.find finds the module's file (cache.lua: lsmod(path)[topmod], then a stat of /lua/<mod>.lua or /init.lua). docs/tips.md already said "As in lazy.nvim", which wasn't true for this case. The same happens at 85ba638 and at 2.0.4 (ec09994), where any pending plugin with the top-level name loaded, so this is old behaviour, not a #52 regression.

Why the fallback was there: it came in with #52's shared-namespace fix (e3ace75: "a sole claimant still loads for a module it doesn't hold") to keep 2.x's behaviour when only one plugin claims the name. No real plugin was named as needing it, and the ledger (zpack_nvim-8n1 (f)) already applied the lazy.nvim rule to several claimants. The cases it could serve:

  • A module a plugin/ script puts in package.preload: it didn't actually help. The loader returns only package.loaded[mod] or the plugin's own file, so the first require failed anyway. perf: stop tracing zpack's startup code #52 already says such a plugin needs a trigger of its own.
  • A module that shows up after an in-session update: still found without the fallback, because PackChanged resets the lookup cache and the file is stat'ed fresh (pinned by "a top-level module an in-session update brings to a pending plugin").
  • A module generated on disk at load time: needs a trigger, as in lazy.nvim. It already needed one when two plugins share the namespace.

Behaviour change: a lazy plugin whose lua/ has only a module's top-level name no longer loads when that module is required, and the require fails. Its own triggers and its own modules still load it. A plugin that creates a module at load time needs a trigger. docs/tips.md "Upgrading to 3.0" and :h zpack-Spec.module say so.

Also from the review loop:

  • fix(module_loader): a slash-form require('lspconfig/util'), still written in nvim-lspconfig's own configs, now loads the pending plugin holding lua/lspconfig/util.lua. 2.x and 3.0 so far never did. As lazy.nvim's Cache.find and vim.loader.find do, / becomes . and leading dots go before the lookup. require('') fails with "module '' not found" again instead of an error inside the loader (from perf: stop tracing zpack's startup code #52).
  • fix(module_loader): when two pending plugins hold the module's file, the one named like the top-level module loads, as in lazy.nvim's get_unloaded_rtp: require('mini.ai') with both mini.nvim and a standalone mini.ai pending loads mini.nvim. 3.0 so far took the first in spec order, and 2.x took mini.ai, named like the whole module.
  • fix(module_loader): when a lazy plugin's module throws as its own opts or config requires it, a later require raises that error, then "loop or previous error", as in lazy.nvim. It returned true, so another plugin's pcall(require, …) probe took the broken plugin for present and indexed a boolean. Same in 2.x.
  • refactor(merge): has_opts is derived in merge_entry, not by hand after two of its three callers.
  • docs(tips): "A disabled or optional plugin's dependencies and specs no longer count" now names optional parents. LazyVim's lang extras have that shape (an optional nvim-lint carrying mason's ensure_installed) but lose nothing: 2.x never added that entry, and 3.0 adds it when nvim-lint is listed without optional. The throwing-trigger entry now says the plugin's other triggers or a require still load it, not only a require.

Tests: "still loads a sole claimant for a submodule it doesn't hold" pinned the old rule. It and "loads what's left once another trigger has loaded the other claimant" are replaced by real-process tests of the new rule:

  • the mini.pairs + mini.icons and nvim-treesitter main + nvim-treesitter.configs shapes, both required from an eager plugin's config at startup: the plugin stays pending, pcall returns false with "module '…' not found:", and mini.pairs still loads and runs setup() on InsertEnter.
  • the plugin holding the module still loads, for a file, a directory with init.lua and a submodule.
  • in a shared namespace, once one claimant is loaded, a module neither holds leaves the other pending, and that plugin's own module still loads it.

Putting the fallback back fails three of them. The other shared-namespace tests are unchanged.

Checks:

  • suite: 1359/0 on nightly and 1358/0 on v0.12.0. luacheck clean, LuaLS no problems.
  • zpack-lazyvim-compare, 936649e vs this branch, SEEDS=40: structural=0, opts=0 on all-extras, and no subset lines differ. My config, run through file mode with { import = 'plugins' }: 55 plugins, 33 lazy, 14 edges, identical. The lazy.nvim oracle shows only the harness's <expr> placeholders. This harness doesn't run requires, so no difference was expected.
  • real startup of my config with XDG isolated (real2.py, 5 probes each): every plugin's status, 'runtimepath', package.loaded, globals, autocmds, commands and messages are identical, base vs branch. That's expected: the namespaces this config shares, lualine and mini, belong to plugins that are already loaded at startup, so the fallback never fired here.
  • startup, 150 synthetic plugins, 60 shuffled rounds, paired d-cpu: realistic -5.9 ms [-9.4, -2.5] and opening a file -9.5 ms [-14.8, -5.3] vs lazy.nvim; vs 936649e +1.7 [-1.8, +2.7] and -0.1 [-6.4, +5.1], inside the A/A. At 400 plugins: -74 to -1117 ms vs lazy.nvim.

BEGIN_COMMIT_OVERRIDE
fix(module_loader)!: a require loads a lazy plugin only when the plugin holds the module's file, as in lazy.nvim; one with only the top-level name stays lazy

fix(module_loader): a slash-form require('lspconfig/util') loads the pending plugin holding the module

fix(module_loader): when several pending plugins hold a module, the one named like it loads, as in lazy.nvim

fix(module_loader): a later require of a module whose load threw raises that error instead of returning true
END_COMMIT_OVERRIDE

With one pending lazy plugin whose lua/ has a module's top-level name,
a require of any module under that name loaded the plugin, whether it
held the module or not. An optional probe at startup turned lazy plugins
eager: pcall(require, 'mini.icons'), as snacks and which-key do, loaded
and configured { 'echasnovski/mini.pairs', event = 'InsertEnter' }, and
pcall(require, 'nvim-treesitter.configs') loaded nvim-treesitter's main
branch, which has no configs.lua. 2.x did the same, for any number of
claimants.

The fallback came in with the shared-namespace fix to keep 2.x's
behaviour for a sole claimant; no plugin was found that needs it. It
didn't serve a module a plugin/ script puts in package.preload either:
the loader hands back only package.loaded or the plugin's file, so that
first require failed anyway. A module brought in by an update is still
found, since the lookup cache resets on PackChanged.

As in lazy.nvim, whose loader loads a plugin only when Cache.find finds
the module's file, a require now loads the first pending plugin holding
it (.lua, /init.lua, .so or .dll) and otherwise fails with the usual
"module not found", the same rule as for several claimants. A plugin
that makes a module at load time needs a trigger of its own.

Refs zpack_nvim-50v, zpack_nvim-8n1
require('lspconfig/util'), still written in nvim-lspconfig's own
configs, never loaded a pending plugin holding lua/lspconfig/util.lua:
the top-level name came out as 'lspconfig/util', which no plugin
claims. 2.x did the same. As lazy.nvim's Cache.find and vim.loader.find
do, turn '/' into '.' and drop leading dots before the lookup.

The top-level name may now be empty, so require('') and require('/')
fail with "module not found" rather than an error inside the loader.
prune and resolve_all each set entry.has_opts by hand after their own
merge, and fold's re-merge left it to resolve_all's later loop. Any new
re-merge that forgot the line would leave a stale has_opts, and setup()
would run or skip on specs that are gone. Every merge now sets it.
…ders

When two pending plugins hold a required module's file, load the one
whose normalised name matches the top-level module, else the first
listed. lazy.nvim's get_unloaded_rtp puts that plugin first, so
require('mini.ai') with mini.nvim and a standalone mini.ai both pending
loads mini.nvim in either order. zpack took the first in spec order.

Once a first holder is found, only plugins named like the module are
scanned, so a hit still lists no other plugin's lua/ directory.
…quire threw

When a lazy plugin's own opts or config requires its module and the
module throws, require leaves its loading sentinel in package.loaded.
The loader handed that back, so the first require and every later one
returned true. Fall through to the plugin's file instead, as lazy.nvim
does, so the require raises the error and later ones give "loop or
previous error". A module that loaded is still returned as is, whatever
its type, so a non-table module isn't run twice.
…wording

On CI, leafo/gh-actions-lua installs Lua into ./.lua, so Lua's own
./?.lua searcher opens that directory for '' and '/' and reports
"error loading module" rather than "not found". Assert what 5a2e0a6
fixed instead: the error doesn't come from inside zpack.
@zuqini
zuqini merged commit 241c9ba into main Nov 3, 2026
4 checks passed
@zuqini
zuqini deleted the module-sole-claimant 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