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.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ Plugin-level settings always take precedence over `defaults`.
-- automatically import specs from `./lua/plugins/`
require('zpack').setup()

-- or import from a custom directory e.g. `./lua/a/b/plugins/`
-- or import from a custom path `./lua/a/b/plugins.lua` or `./lua/a/b/plugins/`
require('zpack').setup({ { import = 'a.b.plugins' } })

-- or add your specs inline in setup
Expand Down
14 changes: 11 additions & 3 deletions doc/zpack.txt
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ Plugin-level settings always take precedence over `defaults`.
-- or import from a custom directory e.g. `./lua/a/b/plugins/`
require('zpack').setup({ { import = 'a.b.plugins' } })

-- or point at a single spec file e.g. `./lua/plugins/telescope.lua`
require('zpack').setup({ { import = 'plugins.telescope' } })

-- or add your specs inline in setup
require('zpack').setup({
{ 'neovim/nvim-lspconfig', config = function() ... end },
Expand Down Expand Up @@ -856,9 +859,14 @@ module (boolean, optional)
*zpack-Spec.import*
import (string|function, optional)
Module path to import specs from (e.g., 'plugins' or
'plugins.lsp'). Imports all .lua files from lua/{path}/
and all subdirectories with init.lua (lua/{path}/*/init.lua).
Each file should return a spec or list of specs.
'plugins.lsp'). If lua/{path}.lua exists it is loaded as a
single spec module; otherwise zpack imports all .lua files
from lua/{path}/ and all subdirectories with init.lua
(lua/{path}/*/init.lua). A lua/{path}.lua file takes
precedence over a lua/{path}/ directory (mirroring Lua's own
`require`, where foo.lua shadows foo/init.lua); the directory
walk is skipped. Each file should return a spec or list of
specs.

lazy.nvim parity: `import` also accepts a function. The
function is called inside pcall, and a table return value
Expand Down
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
specs = { { 'companion/plugin' } }, -- Companion plugin specs grouped with this one

-- Spec imports
import = "plugins.lsp", -- Import from lua/{path}/*.lua and lua/{path}/*/init.lua
import = "plugins.lsp", -- A single lua/{path}.lua spec module, else lua/{path}/*.lua and lua/{path}/*/init.lua
-- import = function() return { ... } end, -- Or a function returning a spec list (lazy.nvim parity)
}
```
Expand Down
10 changes: 8 additions & 2 deletions lua/zpack/import.lua
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,10 @@ local load_spec_module = function(full_module, ctx)
end
end

---Import specs from a module directory
---@param module_path string Module path (e.g., 'plugins' imports from lua/plugins/*.lua)
---Import specs from a module file or directory (lazy.nvim parity).
---`import = 'plugins'` resolves `lua/plugins.lua` as a single spec module if it
---exists, otherwise walks `lua/plugins/*.lua` and `lua/plugins/*/init.lua`.
---@param module_path string Module path (e.g., 'plugins' imports from lua/plugins.lua or lua/plugins/)
---@param ctx zpack.ProcessContext
local import_from_module = function(module_path, ctx)
if imported_modules[module_path] then
Expand All @@ -187,6 +189,10 @@ local import_from_module = function(module_path, ctx)

local lua_path = vim.fn.stdpath('config') .. '/lua/' .. module_path:gsub('%.', '/')

-- A `lua/<path>.lua` file takes precedence over a `lua/<path>/` directory:
-- the file is the single, explicitly-named spec module and the directory walk
-- is skipped. This mirrors Lua's own `require` resolution (`foo.lua` shadows
-- `foo/init.lua`) so `import = 'plugins.telescope'` loads exactly that file.
if vim.uv.fs_stat(("%s.lua"):format(lua_path)) then
load_spec_module(module_path, ctx)
return
Expand Down
13 changes: 13 additions & 0 deletions tests/helpers.lua
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ function M.setup_test_env()
table.insert(_G.test_state.notifications, { msg = msg, level = level })
end

-- Tests that exercise import path resolution mock `vim.fn.stdpath` and
-- `vim.uv.fs_stat` (note `vim.uv == vim.loop`, so a leaked mock corrupts
-- both namespaces). Capture them here so teardown restores them in
-- `after_each` even when an assertion throws mid-test.
_G.test_state.original_stdpath = vim.fn.stdpath
_G.test_state.original_fs_stat = vim.uv.fs_stat

_G.test_state.original_vim_pack_add = vim.pack.add
vim.pack.add = function(specs, opts)
table.insert(_G.test_state.vim_pack_calls, specs)
Expand Down Expand Up @@ -160,6 +167,12 @@ function M.cleanup_test_env()
if _G.test_state.original_notify then
vim.notify = _G.test_state.original_notify
end
if _G.test_state.original_stdpath then
vim.fn.stdpath = _G.test_state.original_stdpath
end
if _G.test_state.original_fs_stat then
vim.uv.fs_stat = _G.test_state.original_fs_stat
end
end

_G.test_state = nil
Expand Down
129 changes: 109 additions & 20 deletions tests/import_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ describe("Spec Import", function()
it("import loads *.lua files from directory", function()
local utils = require('zpack.utils')
local original_lsdir = utils.lsdir
local original_stdpath = vim.fn.stdpath
vim.fn.stdpath = function() return '/mock/config' end
utils.lsdir = function(path)
if path == '/mock/config/lua/test_plugins' then
Expand All @@ -30,15 +29,13 @@ describe("Spec Import", function()
assert.is_not_nil(state.spec_registry['https://github.com/test/bar-plugin'], "bar-plugin should be registered")

utils.lsdir = original_lsdir
vim.fn.stdpath = original_stdpath
package.loaded['test_plugins.foo'] = nil
package.loaded['test_plugins.bar'] = nil
end)

it("import loads */init.lua files from subdirectories", function()
local utils = require('zpack.utils')
local original_lsdir = utils.lsdir
local original_stdpath = vim.fn.stdpath
local original_fs_stat = vim.uv.fs_stat
vim.fn.stdpath = function() return '/mock/config' end
utils.lsdir = function(path)
Expand Down Expand Up @@ -66,15 +63,12 @@ describe("Spec Import", function()
"mini-plugin should be registered")

utils.lsdir = original_lsdir
vim.fn.stdpath = original_stdpath
vim.uv.fs_stat = original_fs_stat
package.loaded['test_plugins.mini'] = nil
end)

it("import loads both *.lua and */init.lua", function()
local utils = require('zpack.utils')
local original_lsdir = utils.lsdir
local original_stdpath = vim.fn.stdpath
local original_fs_stat = vim.uv.fs_stat
vim.fn.stdpath = function() return '/mock/config' end
utils.lsdir = function(path)
Expand Down Expand Up @@ -104,16 +98,122 @@ describe("Spec Import", function()
assert.is_not_nil(state.spec_registry['https://github.com/test/mini'], "mini should be registered")

utils.lsdir = original_lsdir
vim.fn.stdpath = original_stdpath
vim.uv.fs_stat = original_fs_stat
package.loaded['test_plugins.telescope'] = nil
package.loaded['test_plugins.mini'] = nil
end)

it("import resolves a single spec file directly (lua/<path>.lua)", function()
local utils = require('zpack.utils')
local original_lsdir = utils.lsdir
local original_fs_stat = vim.uv.fs_stat
local lsdir_called = false
vim.fn.stdpath = function() return '/mock/config' end
utils.lsdir = function(path)
if path == '/mock/config/lua/test_plugins/telescope' then
lsdir_called = true
end
return {}
end
vim.uv.fs_stat = function(path)
if path == '/mock/config/lua/test_plugins/telescope.lua' then
return { type = 'file' }
end
return original_fs_stat(path)
end

package.loaded['test_plugins.telescope'] = { 'test/telescope' }

local state = require('zpack.state')
require('zpack').setup({ { import = 'test_plugins.telescope' } })
helpers.flush_pending()

assert.is_not_nil(state.spec_registry['https://github.com/test/telescope'],
"single-file import should register the file's spec")
assert.is_false(lsdir_called,
"a single-file import must not walk a same-named directory")

utils.lsdir = original_lsdir
package.loaded['test_plugins.telescope'] = nil
end)

it("a lua/<path>.lua file takes precedence over a lua/<path>/ directory", function()
local utils = require('zpack.utils')
local original_lsdir = utils.lsdir
local original_fs_stat = vim.uv.fs_stat
vim.fn.stdpath = function() return '/mock/config' end
-- Both `lua/test_plugins.lua` and `lua/test_plugins/foo.lua` exist; the file
-- wins and the directory entry must be ignored.
utils.lsdir = function(path)
if path == '/mock/config/lua/test_plugins' then
return {
{ name = 'foo.lua', type = 'file' },
}
end
return {}
end
vim.uv.fs_stat = function(path)
if path == '/mock/config/lua/test_plugins.lua' then
return { type = 'file' }
end
return original_fs_stat(path)
end

package.loaded['test_plugins'] = { 'test/file-spec' }
package.loaded['test_plugins.foo'] = { 'test/dir-spec' }

local state = require('zpack.state')
require('zpack').setup({ { import = 'test_plugins' } })
helpers.flush_pending()

assert.is_not_nil(state.spec_registry['https://github.com/test/file-spec'],
"the file spec should be registered")
assert.is_nil(state.spec_registry['https://github.com/test/dir-spec'],
"the directory spec must be ignored when a same-named .lua file exists")

utils.lsdir = original_lsdir
package.loaded['test_plugins'] = nil
package.loaded['test_plugins.foo'] = nil
end)

it("single-file import with enabled=false skips import", function()
local utils = require('zpack.utils')
local original_lsdir = utils.lsdir
local original_fs_stat = vim.uv.fs_stat
local resolved = false
local lsdir_called = false
vim.fn.stdpath = function() return '/mock/config' end
utils.lsdir = function()
lsdir_called = true
return {}
end
vim.uv.fs_stat = function(path)
if path == '/mock/config/lua/test_plugins/foo.lua' then
resolved = true
return { type = 'file' }
end
return original_fs_stat(path)
end

package.loaded['test_plugins.foo'] = { 'test/foo-plugin' }

local state = require('zpack.state')
require('zpack').setup({ { import = 'test_plugins.foo', enabled = false } })
helpers.flush_pending()

assert.is_nil(state.spec_registry['https://github.com/test/foo-plugin'],
"single-file import should not register when enabled=false")
assert.is_false(resolved,
"enabled=false must short-circuit before any single-file resolution")
assert.is_false(lsdir_called,
"enabled=false must short-circuit before any directory walk")

utils.lsdir = original_lsdir
package.loaded['test_plugins.foo'] = nil
end)

it("import only goes 1 level deep for init.lua", function()
local utils = require('zpack.utils')
local original_lsdir = utils.lsdir
local original_stdpath = vim.fn.stdpath
local original_fs_stat = vim.uv.fs_stat
vim.fn.stdpath = function() return '/mock/config' end
utils.lsdir = function(path)
Expand Down Expand Up @@ -141,15 +241,12 @@ describe("Spec Import", function()
"level1-plugin should be registered")

utils.lsdir = original_lsdir
vim.fn.stdpath = original_stdpath
vim.uv.fs_stat = original_fs_stat
package.loaded['test_plugins.level1'] = nil
end)

it("import with enabled=false skips import", function()
local utils = require('zpack.utils')
local original_lsdir = utils.lsdir
local original_stdpath = vim.fn.stdpath
vim.fn.stdpath = function() return '/mock/config' end
utils.lsdir = function(path)
if path == '/mock/config/lua/test_plugins' then
Expand All @@ -170,7 +267,6 @@ describe("Spec Import", function()
"foo-plugin should NOT be registered when enabled=false")

utils.lsdir = original_lsdir
vim.fn.stdpath = original_stdpath
package.loaded['test_plugins.foo'] = nil
end)

Expand All @@ -181,7 +277,6 @@ describe("Spec Import", function()
it("import with throwing enabled is skipped, sibling specs still register", function()
local utils = require('zpack.utils')
local original_lsdir = utils.lsdir
local original_stdpath = vim.fn.stdpath
vim.fn.stdpath = function() return '/mock/config' end
utils.lsdir = function(path)
if path == '/mock/config/lua/test_plugins' then
Expand Down Expand Up @@ -217,14 +312,12 @@ describe("Spec Import", function()
assert.is_true(saw_notify, "throwing import enabled should surface a structured notify")

utils.lsdir = original_lsdir
vim.fn.stdpath = original_stdpath
package.loaded['test_plugins.foo'] = nil
end)

it("nested import works (init.lua with import)", function()
local utils = require('zpack.utils')
local original_lsdir = utils.lsdir
local original_stdpath = vim.fn.stdpath
local original_fs_stat = vim.uv.fs_stat
vim.fn.stdpath = function() return '/mock/config' end
utils.lsdir = function(path)
Expand Down Expand Up @@ -261,8 +354,6 @@ describe("Spec Import", function()
"mini.surround should be registered")

utils.lsdir = original_lsdir
vim.fn.stdpath = original_stdpath
vim.uv.fs_stat = original_fs_stat
package.loaded['test_plugins.mini'] = nil
package.loaded['test_plugins.mini.ai'] = nil
package.loaded['test_plugins.mini.surround'] = nil
Expand All @@ -271,7 +362,6 @@ describe("Spec Import", function()
it("duplicate import is skipped", function()
local utils = require('zpack.utils')
local original_lsdir = utils.lsdir
local original_stdpath = vim.fn.stdpath
local lsdir_call_count = 0
vim.fn.stdpath = function() return '/mock/config' end
utils.lsdir = function(path)
Expand All @@ -295,7 +385,6 @@ describe("Spec Import", function()
assert.are.equal(1, lsdir_call_count)

utils.lsdir = original_lsdir
vim.fn.stdpath = original_stdpath
package.loaded['test_plugins.foo'] = nil
end)

Expand Down
Loading