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]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
8000 Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions doc/zpack.txt
8000
Original file line number Diff line number Diff line change
Expand Up @@ -1048,8 +1048,15 @@ module (boolean, optional)
loaded when its modules are `require()`d.

zpack automatically loads lazy plugins when their modules are
required: the first plugin holding the module, or failing that
the only pending plugin with its top-level name under `/lua`.
required: a pending plugin holding the module's file under
`/lua` (`.lua`, `/init.lua`, or a compiled `.so` or `.dll`).
Of several such plugins, the one named like the top-level
module loads, as in lazy.nvim, so `require('mini.ai')` loads
`mini.nvim` rather than a standalone `mini.ai`. Otherwise the
first listed loads, higher `priority` first. A plugin that has
only the module's top-level name, such as `mini.pairs` for a
`require` of `mini.icons`, stays lazy, and the `require` fails
as usual.

Default: true (module loading enabled)

Expand Down
8 changes: 4 additions & 4 deletions docs/tips.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,9 @@ A dependency whose function-form `opts` throws used to stop a lazy plugin listin
{ 'u/p', enabled = false, dependencies = { 'u/d' } }
```

### A disabled plugin's `dependencies` and `specs` no longer count
### A disabled or `optional` plugin's `dependencies` and `specs` no longer count

`opts` in a disabled plugin's `dependencies` spec used to reach that dependency when another spec kept it. Now that spec goes with the plugin listing it. Move what should stay to a spec that stays:
`opts` in a `dependencies` spec of a plugin that is disabled, or left out as `optional`, used to reach that dependency when another spec kept it. Now that spec goes with the plugin listing it. LazyVim's lang extras have this shape but lose nothing: an `optional` nvim-lint or nvim-dap carrying `{ 'mason-org/mason.nvim', opts = { ensure_installed = ... } }` never added to mason's list in 2.x, which ignored `opts_extend`, so LazyVim's own mason spec replaced the list. 3.0 adds it when another spec lists that plugin without `optional`. Move what should stay to a spec that stays:

```lua
-- 2.x: u/d gets { a = 1, b = 2 }. 3.0: only { b = 2 }
Expand Down Expand Up @@ -272,10 +272,10 @@ A helper in an imported directory, such as `lua/plugins/util.lua` returning `{ o
- `lua/plugins/*.lua` import by sorted module name, where it used to be file system order, and later specs win: `foo` comes before `foo-extra`, so `foo-extra`'s `opts` now override `foo`'s.
- A table listed both at the top level and under a lazy plugin's `dependencies` used to stay lazy. Now it's a plugin of its own and loads at startup, in `local d = { 'u/d' }` with `{ d, { 'u/p', cmd = 'P', dependencies = { d } } }`. `lazy = true` on the table keeps it lazy.
- A `nil` in a spec list used to end the list. `{ { 'u/x' }, flag and { 'u/y' } or nil, { 'u/d' } }` now installs `u/d` when `flag` is false.
- A function-form `event`, `cmd`, `ft` or `keys` that throws used to leave the plugin loading at startup. The error is still reported, but the plugin now stays lazy, and only a `require` of its modules loads it.
- A function-form `event`, `cmd`, `ft` or `keys` that throws used to leave the plugin loading at startup. The error is still reported, but the plugin now stays lazy, and only its other triggers or a `require` of its modules load it.
- `opts_extend` used to be ignored. Now it applies, so lists that used to replace each other are joined: `{ 'u/x', opts_extend = { 'l' }, opts = { l = { 'a' } } }` beside `{ 'u/x', opts = { l = { 'b' } } }` gave `{ 'b' }` and now gives `{ 'a', 'b' }`.
- A bare string in a spec list used to be reported as an invalid spec and skipped. `{ 'u/a', { 'u/b' } }` now installs both. A string without an owner, as in a module returning `{ 'lua_ls', 'rust_analyzer' }`, is still skipped with a warning.
- A `require` used to load a lazy plugin whose name or `main` looked like the module's name, even with no such module. As in lazy.nvim, it now takes that top-level name under the plugin's `lua/`.
- A `require` used to load a lazy plugin whose name or `main` looked like the module's name, or whose `lua/` had only the module's top-level name. So an optional `pcall(require, 'mini.icons')` at startup loaded and configured a lazy `mini.pairs`, and `pcall(require, 'nvim-treesitter.configs')` loaded nvim-treesitter's main branch, which has no such module. As in lazy.nvim, a `require` now loads only the plugin holding the module's file under `lua/`, and fails as usual when none does. When two do, the one named like the top-level module loads, so `require('mini.ai')` loads `mini.nvim`, not a standalone `mini.ai` listed beside it. A plugin that makes a module at load time, from `plugin/` or `package.preload`, needs a trigger of its own.
- `setup({ profiling = ... })` is ignored without a word, and `print_profile` and `get_profile` are gone from `zpack.module_loader`.

## Upgrading from 1.x to 2.0
Expand Down
1 change: 0 additions & 1 deletion lua/zpack/merge.lua
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ function M.resolve_all(graph, enabled_results, notify)

for src, entry in pairs(state.spec_registry) do
fields.resolve_enabled(enabled_results, entry, src)
entry.has_opts = fields.contributes_opts(entry)
-- cond_result is intentionally left nil here. It is written later by
-- registration.register_all's vim.pack.add load callback (which needs
-- the live plugin arg for function-form conds). Readers must treat
Expand Down
14 changes: 6 additions & 8 deletions lua/zpack/merge_fields.lua
Original file line number Diff line number Diff line change
Expand Up @@ -159,21 +159,19 @@ function M.merge_spec_array(specs)
return result
end

---`opts` is deliberately not stored on merged_spec; `has_opts` lets
---plugin_loader / startup answer "does any spec contribute opts?" without
---re-scanning.
function M.merge_entry(entry)
entry.sorted_specs = M.sort_specs(entry.specs)
entry.merged_spec = M.merge_spec_array(entry.sorted_specs)
end

---`opts` is deliberately not stored on merged_spec; this boolean lets
---plugin_loader / startup answer "does any spec contribute opts?" without
---re-scanning.
function M.contributes_opts(entry)
entry.has_opts = false
for _, s in ipairs(entry.sorted_specs) do
if s.opts ~= nil then
return true
entry.has_opts = true
break
end
end
return false
end

---lazy.nvim parity: the last spec to set `enabled`, in merge order, decides, so
Expand Down
32 changes: 19 additions & 13 deletions lua/zpack/module_loader.lua
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ local module_lookup_cache = {}
---@type vim.pack.Spec[]
local lazy_pack_specs = {}

---What require leaves in package.loaded while a module loads, and keeps there if the load throws.
local LOADING = package.loaded[...]

---@param path string A plugin's directory
---@param modname string
---@return string? file
Expand All @@ -35,29 +38,31 @@ local function claims_topmod(path, topmod)
return false
end

---Find the pending plugin to load for a module: the first claimant of the top-level
---name holding its file, or failing that a sole pending claimant
---Find the pending plugin to load for a module: of the claimants of the top-level name
---holding its file, the one named like that name as in lazy.nvim, else the first.
---Once the first is found, only plugins named like it are scanned.
---@param modname string
---@return string? src plugin source if found
local function find_src_by_scanning(modname)
local topmod = modname:match("^[^.]+")
local first_pending, pending_count = nil, 0
local topmod = modname:match("^[^.]*")
local norm = util.normalize_name(topmod)
local first

for _, pack_spec in ipairs(lazy_pack_specs) do
local src = pack_spec.src
local registry_entry = state.spec_registry[src]
local plugin = registry_entry and registry_entry.plugin
if plugin and registry_entry.load_status == "pending" and registry_entry.merged_spec.module ~= false
and claims_topmod(plugin.path, topmod) then
if module_file(plugin.path, modname) then
return src
if plugin and registry_entry.load_status == "pending" and registry_entry.merged_spec.module ~= false then
local named = util.normalize_name(plugin.spec.name) == norm
if (named or not first) and claims_topmod(plugin.path, topmod) and module_file(plugin.path, modname) then
if named then
return src
end
first = src
end
first_pending = first_pending or src
pending_count = pending_count + 1
end
end

return pending_count == 1 and first_pending or nil
return first
end

---Initialize module loader with lazy plugins
Expand Down Expand Up @@ -107,6 +112,7 @@ end
---@param modname string
---@return function|string|nil
function M.loader(modname)
modname = modname:gsub("/", "."):gsub("^%.+", "")
local src = find_src_for_module(modname)
if not src then
return nil
Expand All @@ -125,7 +131,7 @@ function M.loader(modname)
require('zpack.plugin_loader').process_spec(pack_spec, { bang = true })

local mod = package.loaded[modname]
if mod ~= nil then
if mod ~= nil and mod ~= LOADING then
return function()
return mod
end
Expand Down
1 change: 0 additions & 1 deletion lua/zpack/prune.lua
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ local function refresh_entry(prune, src, entry)
fields.merge_entry(entry)
entry.merged_spec.name = name
fields.resolve_enabled(prune.enabled_results, entry, src)
entry.has_opts = fields.contributes_opts(entry)
end
end

Expand Down
140 changes: 133 additions & 7 deletions tests/startup_process_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1399,6 +1399,37 @@ describe("sessions over one data directory", function()
assert.is_false(loads(result, 'bb'))
end)

describe("when both hold the file", function()
local names = { mininvim = 'mini.nvim', miniai = 'mini.ai' }
local function both_args(order)
return ("{ spec = { { url = %s, name = '%s', lazy = true }, { url = %s, name = '%s', lazy = true } }, defaults = { confirm = false } }")
:format(order[1], names[order[1]], order[2], names[order[2]])
end

before_each(function()
for repo, plugin in pairs(names) do
make_repo(repo, {
['lua/mini/ai.lua'] = { ("return { from = '%s' }"):format(plugin) },
['lua/other/x.lua'] = { ("return { from = '%s' }"):format(plugin) },
})
end
end)

for name, order in pairs({ ['listed first'] = { 'mininvim', 'miniai' }, ['listed second'] = { 'miniai', 'mininvim' } }) do
it("loads the one named like the top-level module when it's " .. name, function()
local result = session(both_args(order), report('mini.ai'))
assert.is_true(vim.list_contains(result.log, 'true=mini.nvim'), vim.inspect(result.log))
assert.is_false(loads(result, 'miniai'))
end)

it("loads the first listed when neither is named like it, with " .. names[order[1]] .. ' first', function()
local result = session(both_args(order), report('other.x'))
assert.is_true(vim.list_contains(result.log, 'true=' .. names[order[1]]), vim.inspect(result.log))
assert.is_false(loads(result, order[2]))
end)
end
end)

it("loads neither for a module neither holds, and a later require still works", function()
local result = session(setup_args('aa', 'bb'), report('shared.nope') .. report('shared.nope') .. report('shared.inb'))
assert.is_true(vim.list_contains(result.log, 'false=false'))
Expand All @@ -1416,17 +1447,77 @@ describe("sessions over one data directory", function()
assert.is_false(loads(result, 'cc'))
end)

it("loads what's left once another trigger has loaded the other claimant", function()
local result = session(setup_args('aa', 'bb'), report('shared.nope') .. "vim.cmd('ZPack load bb'); " .. report('shared.nope2'))
assert.is_true(loads(result, 'aa'))
it("loads neither for such a module once another trigger has loaded the other claimant", function()
local result = session(setup_args('aa', 'bb'), report('shared.nope') .. "vim.cmd('ZPack load bb'); "
.. report('shared.nope2') .. "table.insert(_G.LOG, 'aa=' .. require('zpack.api').get_plugin('aa').status); "
.. report('shared.ina'))
assert.are.same({ 'false=false', 'false=false', 'aa=pending', 'true=aa' }, vim.tbl_filter(function(entry)
return entry:find('=', 1, true) ~= nil
end, result.log))
end)
end)

describe("a module no lazy plugin holds, required at startup", function()
local probe = "config = function() local ok, err = pcall(require, %q); table.insert(_G.LOG, 'probe=' .. tostring(ok)); table.insert(_G.LOG, tostring(err)) end"
local status = "table.insert(_G.LOG, 'status=' .. require('zpack.api').get_plugin(%q).status); "

local function setup_args(lazy_spec, module)
return ("{ spec = { %s, { url = ee, name = 'ee', %s } }, defaults = { confirm = false } }"):format(lazy_spec, probe:format(module))
end

local function assert_not_found(result, module)
assert.is_true(vim.list_contains(result.log, 'probe=false'), vim.inspect(result.log))
local err = result.log[vim.fn.index(result.log, 'probe=false') + 2]
assert.are.equal(1, (err:find(("module '%s' not found:"):format(module), 1, true)), err)
end

before_each(function()
make_repo('ee')
make_repo('pairs', { ['lua/mini/pairs.lua'] = { "return { setup = function() table.insert(_G.LOG, 'pairs.setup') end }" } })
make_repo('ts', {
['lua/nvim-treesitter/init.lua'] = { 'return {}' },
['lua/nvim-treesitter/config.lua'] = { 'return {}' },
})
end)

it("leaves a plugin sharing its namespace lazy, as mini.pairs beside a require of mini.icons", function()
local result = session(
setup_args("{ url = pairs, name = 'mini.pairs', event = 'InsertEnter', opts = {} }", 'mini.icons'),
status:format('mini.pairs') .. "vim.api.nvim_exec_autocmds('InsertEnter', {}); " .. status:format('mini.pairs')
)
assert_not_found(result, 'mini.icons')
assert.are.same({ 'status=pending', 'pairs.plugin', 'pairs.setup', 'status=loaded' },
vim.list_slice(result.log, #result.log - 3))
end)

it("still loads a sole claimant for a submodule it doesn't hold", function()
it("leaves a plugin lazy whose old submodule is gone, as nvim-treesitter's main branch without configs", function()
local result = session(
"{ spec = { { url = bb, name = 'bb', lazy = true } }, defaults = { confirm = false } }",
report('only_bb.nope')
setup_args("{ url = ts, name = 'nvim-treesitter', event = 'BufReadPost' }", 'nvim-treesitter.configs'),
status:format('nvim-treesitter')
)
assert.is_true(loads(result, 'bb'))
assert_not_found(result, 'nvim-treesitter.configs')
assert.is_false(vim.list_contains(result.log, 'ts.plugin'))
assert.are.equal('status=pending', result.log[#result.log])
end)

for label, module in pairs({ ['its file'] = 'mini.pairs', ['a directory with init.lua'] = 'nvim-treesitter' }) do
it("still loads the plugin holding " .. label, function()
local result = session(("{ spec = { %s, %s, { url = ee, name = 'ee', %s } }, defaults = { confirm = false } }"):format(
"{ url = pairs, name = 'mini.pairs', event = 'InsertEnter', opts = {} }",
"{ url = ts, name = 'nvim-treesitter', event = 'BufReadPost' }",
probe:format(module)
))
assert.is_true(vim.list_contains(result.log, 'probe=true'), vim.inspect(result.log))
assert.are.equal(module == 'mini.pairs', vim.list_contains(result.log, 'pairs.setup'))
assert.are.equal(module == 'mini.pairs', vim.list_contains(result.log, 'pairs.plugin'))
assert.are.equal(module ~= 'mini.pairs', vim.list_contains(result.log, 'ts.plugin'))
end)
end

it("still loads the plugin holding a submodule", function()
local result = session(setup_args("{ url = ts, name = 'nvim-treesitter', event = 'BufReadPost' }", 'nvim-treesitter.config'))
assert.is_true(vim.list_contains(result.log, 'probe=true'), vim.inspect(result.log))
assert.is_true(vim.list_contains(result.log, 'ts.plugin'))
end)
end)

Expand All @@ -1447,6 +1538,24 @@ describe("sessions over one data directory", function()
end)
end

for label, name in pairs({ ["a slash-form name, as require('lspconfig/util')"] = 'mine/sub', ['a leading dot'] = '.mine.sub' }) do
it("loads for " .. label, function()
make_repo('aa', { ['lua/mine/sub.lua'] = { "return { from = 'aa' }" } })
local result = session(setup_args("{ url = aa, name = 'aa', lazy = true }"), report(name))
assert.are.equal('aa', result.log[#result.log])
end)
end

for _, name in ipairs({ '', '/' }) do
it(("leaves %q to Lua's own searchers"):format(name), function()
make_repo('aa', { ['lua/mine.lua'] = { "return { from = 'aa' }" } })
local result = session(setup_args("{ url = aa, name = 'aa', lazy = true }"), report(name))
local msg = result.log[#result.log]
assert.is_string(msg)
assert.is_falsy(msg:find('/lua/zpack/', 1, true), msg)
end)
end

it("comes from the plugin it loaded, ahead of a dependency holding the same one", function()
make_repo('parent', { ['lua/same.lua'] = { "return { from = 'parent' }" } })
make_repo('dep', { ['lua/same.lua'] = { "return { from = 'dep' }" } })
Expand All @@ -1462,6 +1571,23 @@ describe("sessions over one data directory", function()
assert.is_truthy(result.log[#result.log]:find('aa/lua/mine.lua', 1, true), result.log[#result.log])
end)

it("raises its error when its own auto-setup's require of it threw", function()
make_repo('aa', { ['lua/mine/init.lua'] = { "error('boom in mine')" } })
local probe = "local ok, mod = pcall(require, 'mine'); table.insert(_G.LOG, tostring(ok) .. ' ' .. tostring(mod))"
local result = session(setup_args("{ url = aa, name = 'aa', main = 'mine', lazy = true, opts = {} }"), probe .. '; ' .. probe)
assert.is_truthy(result.log[#result.log - 1]:find('boom in mine', 1, true), result.log[#result.log - 1])
assert.is_truthy(result.log[#result.log]:find("loop or previous error loading module 'mine'", 1, true), result.log[#result.log])
end)

it("is what its config required, run once, when it returns no table", function()
make_repo('aa', { ['lua/mine.lua'] = { "table.insert(_G.LOG, 'mine.run')", 'return function() end' } })
local result = session(
setup_args("{ url = aa, name = 'aa', lazy = true, config = function() _G.FROM_CONFIG = require('mine') end }"),
"table.insert(_G.LOG, tostring(require('mine') == _G.FROM_CONFIG))")
assert.are.same({ 'mine.run', 'true' }, vim.list_slice(result.log, #result.log - 1))
assert.are.equal(1, #vim.tbl_filter(function(l) return l == 'mine.run' end, result.log))
end)

for _, vim_loader in ipairs({ true, false }) do
it("opens a compiled one with vim_loader = " .. tostring(vim_loader), function()
make_repo('aa', { ['lua/nat/compiled.so'] = { "not a real library" } })
Expand Down
Loading
0