A super lightweight layer on top of Neovim's native vim.pack, adding support for lazy-loading and the widely adopted lazy.nvim-like declarative spec.
-- ./lua/plugins/fundo.lua
return {
{ 'kevinhwang91/promise-async' },
{
'kevinhwang91/nvim-fundo',
version = 'main',
build = function() require('fundo').install() end,
config = function()
vim.o.undofile = true
require('fundo').setup()
end,
},
}The built-in plugin manager itself is currently a work in progress, so please expect breaking changes.
Why zpack? | Examples | Dependency Handling | Spec Reference | Migrating from lazy.nvim
- Neovim 0.12.0+
-- install with vim.pack directly
vim.pack.add({ 'https://github.com/zuqini/zpack.nvim' })-- Make sure to setup `mapleader` and `maplocalleader` before
-- loading zpack.nvim so that mappings are correct.
vim.g.mapleader = " "
vim.g.maplocalleader = "\\"
-- automatically import specs from `/lua/plugins/`
require('zpack').setup()
-- or import from a custom directory e.g. `/lua/a/b/plugins/`
require('zpack').setup({ { import = 'a.b.plugins' } })
-- or add your specs inline in setup
require('zpack').setup({
{ 'neovim/nvim-lspconfig', config = function() ... end },
{ import = 'plugins.mini' }, -- additionally import from `/lua/plugins/mini/`
})
-- or via the spec field
require('zpack').setup({
spec = {
{ 'neovim/nvim-lspconfig', config = function() ... end },
{ import = 'plugins.mini' }, -- additionally import from `/lua/plugins/mini/`
},
})zpack provides the following commands (default prefix: Z, customizable via cmd_prefix option):
:ZUpdate [plugin]- Update all plugins, or a specific plugin if provided (supports tab completion). See:h vim.pack.update():ZClean- Remove plugins that are no longer in your spec:ZBuild[!] [plugin]- Run build hook for a specific plugin, or all plugins with!(supports tab completion):ZDelete[!] [plugin]- Remove a specific plugin, or all plugins with!(supports tab completion):ZLoad[!] [plugin]- Load a specific unloaded plugin, or all unloaded plugins with!(supports tab completion)
Note:
- Deleting active plugins in your spec with
:ZDeletecan result in errors in your current session. Restart Neovim to re-install them. - When manually loading plugins with
:ZLoad, please ensure any dependencies are already loaded (either manually with:ZLoador see Dependency Handling).
Under the default setting, create plugin specs in lua/plugins/:
lua/
plugins/
treesitter.lua
telescope.lua
lsp.lua
Each file returns a spec or list of specs (see examples or spec reference):
-- lua/plugins/telescope.lua
return {
'nvim-telescope/telescope.nvim',
cmd = 'Telescope',
keys = {
{ '<leader>ff', function() require('telescope.builtin').find_files() end, desc = 'Find files' },
},
opts = {},
}require('zpack').setup({
-- { import = 'plugins' } -- default import spec if not explicitly passed in via [1] or spec
defaults = {
confirm = true, -- set to false to skip vim.pack install prompts (default: true)
cond = nil, -- global condition for all plugins, e.g. not vim.g.is_vscode (default: nil)
},
performance = {
vim_loader = true, -- enables vim.loader for faster startup (default: true)
},
cmd_prefix = 'Z', -- command prefix: :ZUpdate, :ZClean, etc. (default: 'Z')
})Plugin-level settings always take precedence over defaults.
Neovim 0.12+ includes a built-in package manager (vim.pack) that handles plugin installation, updates, and version management. zpack is a thin layer that adds lazy-loading capabilities and support for a lazy.nvim-like declarative spec while leveraging the native infrastructure.
zpack might be for you if:
- you're a lazy.nvim user, love its declarative spec, and its wide adoption by plugin authors, but you don't need most of its advanced features
- you're a lazy.nvim user, want to try
vim.pack, but don't want to rewrite your entire plugins spec from scratch - you're mostly happy with a core plugin manager like
vim.packwithout bells and whistles, but would like just a few additional features like:- lazy-loading triggers for a faster startup on slower machines
- a minimalist set of commands and tools to manage your plugin's lifecycle e.g. updates, cleaning, and builds
- a declarative plugin spec to keep your main neovim config neat and tidy
As a thin layer, zpack does not provide:
- UI dashboard for your plugins
- Profiling, dev mode, etc.
- Implicit dependency inference (see Dependency Handling for the explicit approach)
Although you can achieve most of these through other means. See Migrating from lazy.nvim. If something you need isn't achievable natively or through zpack, please submit an issue or PR!
For more examples, refer to my personal config:
return {
'nvim-tree/nvim-tree.lua',
cmd = { 'NvimTreeToggle', 'NvimTreeFocus' },
opts = {},
}return {
'folke/flash.nvim',
keys = {
{ 's', function() require('flash').jump() end, mode = { 'n', 'x', 'o' }, desc = 'Flash' },
{ 'S', function() require('flash').treesitter() end, mode = { 'n', 'x', 'o' }, desc = 'Flash Treesitter' },
},
opts = {},
}return {
'windwp/nvim-autopairs',
event = 'InsertEnter', -- Also supports 'VeryLazy'
opts = { check_ts = true },
}-- Inline pattern (same as lazy.nvim)
return {
'rust-lang/rust.vim',
event = 'BufReadPre *.rs',
init = function() vim.g.rustfmt_autosave = 1 end,
}
-- Or using EventSpec for multiple patterns
return {
'polyglot-plugin',
event = {
event = 'BufReadPre',
pattern = { '*.lua', '*.rs' },
},
opts = {},
}Load plugin when opening files of specific types. Automatically re-triggers BufReadPre, BufReadPost, and FileType events to ensure LSP clients and Treesitter attach properly:
return {
'rust-lang/rust.vim',
ft = { 'rust', 'toml' },
init = function() vim.g.rustfmt_autosave = 1 end,
}Use enabled to skip vim.pack.add entirely, or cond to conditionally load after calling vim.pack.add:
-- enabled: Checked at setup time, vim.pack.add never called if false
return {
'linux-only-plugin',
enabled = vim.fn.has('linux') == 1,
opts = {},
}
-- cond: Checked at load time, vim.pack.add called but won't load if false
return {
'project-specific-plugin',
cond = function() return vim.fn.filereadable('.project-marker') == 1 end,
opts = {},
}All lifecycle hooks (init, config, build, cond) and lazy-loading triggers (event, cmd, keys, ft) can be functions that receive a zpack.Plugin object containing the resolved plugin path and spec:
return {
'some/plugin',
build = function(plugin)
-- plugin.path: absolute path to the plugin directory
-- plugin.spec: the vim.pack.Spec with resolved name, src, version
vim.fn.system({ 'make', '-C', plugin.path })
end,
}Control plugin load order with priority (higher values load first; default: 50):
-- Startup plugin: load colorscheme early
return {
'folke/tokyonight.nvim',
priority = 1000,
config = function() vim.cmd('colorscheme tokyonight') end,
}vim.pack.add expects string|vim.VersionRange:
return {
'mrcjkb/rustaceanvim',
version = vim.version.range('^6'), -- semver version
-- version = 'main', -- branch
-- version = 'v1.0.0', -- tag
-- version = 'abc123', -- commit
}See :h vim.pack.Spec, :h vim.version.range(), and :h vim.VersionRange.
return {
'mrcjkb/rustaceanvim',
sem_version = '^6', -- corresponds to lazy.nvim spec's `version`, auto-wrapped to vim.version.range()
-- branch = 'main',
-- tag = 'v1.0.0',
-- commit = 'abc123',
}When you need custom configuration logic, use a config function. The resolved opts table is passed as the second argument:
return {
'nvim-lualine/lualine.nvim',
opts = { theme = 'tokyonight' },
config = function(plugin, opts)
opts.sections = { lualine_a = { 'mode' } }
require('lualine').setup(opts)
end,
}If automatic module detection fails, specify the module explicitly with main:
return {
'some/plugin-with-unusual-structure',
main = 'plugin.core',
opts = { enabled = true },
}return {
'nvim-telescope/telescope-fzf-native.nvim',
build = 'make',
}Build hooks run after plugin installation or update. When a build hook runs, zpack loads all plugins first (in priority order) to ensure any cross-plugin dependencies are available.
return {
{ 'nvim-lua/plenary.nvim' },
{ 'nvim-tree/nvim-web-devicons' },
{ 'nvim-lualine/lualine.nvim', opts = { theme = 'auto' } },
}Unlike lazy.nvim, zpack does not have a dependencies field to automatically infer plugin load order. Instead, you explicitly control dependencies using one of two approaches:
The simplest approach is to load dependency plugins at startup (without lazy-loading triggers) while keeping the dependent plugin lazy-loaded. For most plugins, loading small dependencies at startup has negligible impact on startup time while keeping your config simple.
lazy.nvim:
return {
'nvim-telescope/telescope.nvim',
dependencies = { 'nvim-lua/plenary.nvim' },
cmd = 'Telescope',
}zpack:
return {
{ 'nvim-lua/plenary.nvim' }, -- Loads at startup
{
'nvim-telescope/telescope.nvim',
cmd = 'Telescope', -- Lazy-loaded on command
}
}If you want both plugins lazy-loaded, use the same trigger with priority to control load order (higher = earlier):
-- /lua/plugins/plenary.lua
local telescope_triggers = 'Telescope'
local harpoon_triggers = '<leader>a'
return {
{
'nvim-lua/plenary.nvim',
cmd = telescope_triggers,
keys = harpoon_triggers,
priority = 1000,
},
{
'nvim-telescope/telescope.nvim',
cmd = telescope_triggers,
},
{
"ThePrimeagen/harpoon",
version = "harpoon2",
keys = harpoon_triggers,
opts = {},
}
}Note: For non-lazy plugins, packages are all loaded via packadd in priority order before executing their config hooks, thus all dependencies are available without having to explicitly set priority. There should almost never be a need to define dependency priority for non-lazy plugins unless configs need to be called in specific orders.
{
-- Plugin source (provide exactly one)
[1] = "user/repo", -- Plugin short name. Expands to https://github.com/{user/repo}
src = "https://...", -- Custom git URL or local path
dir = "/path/to/plugin", -- Local plugin directory (lazy.nvim compat, mapped to src)
url = "https://...", -- Custom git URL (lazy.nvim compat, mapped to src)
-- Plugin metadata
name = "my-plugin", -- Custom plugin name (optional, overrides auto-derived name)
-- Source control (version for `vim.pack.add`, string|vim.VersionRange)
version = "main", -- Git branch, tag, or commit
-- version = vim.version.range("1.*"), -- Or semver range via vim.version.range()
-- Source control (lazy.nvim compat, mapped to version)
sem_version = "^1.0.0", -- Semver string (corresponds to lazy.nvim spec's version), auto-wrapped to vim.version.range()
branch = "main", -- Git branch
tag = "v1.0.0", -- Git tag
commit = "abc123", -- Git commit
-- Loading control
enabled = true|false|function, -- Enable/disable plugin
cond = true|false|function(plugin), -- Condition to load plugin
lazy = true|false, -- Force eager loading when false (auto-detected)
priority = 50, -- Load priority (higher = earlier, default: 50)
-- Lifecycle hooks
init = function(plugin) end, -- Runs before plugin loads, useful for certain vim plugins
config = function(plugin, opts) end, -- Runs after plugin loads, receives resolved opts
-- config = true, -- Calls require(main).setup({})
build = string|function(plugin), -- Build command or function
-- Plugin configuration
opts = {}, -- Options passed to setup(), triggers auto-setup
-- opts = function(plugin) return {} end, -- Can also be a function
main = "module.name", -- Explicit main module (auto-detected if not set)
-- Lazy loading triggers (auto-sets lazy=true unless overridden)
-- All triggers can also be functions that receive zpack.Plugin and return the respective type
event = string|string[]|zpack.EventSpec|(string|zpack.EventSpec)[]|function(plugin), -- Autocommand event(s). Supports 'VeryLazy' and inline patterns: "BufReadPre *.lua"
pattern = string|string[], -- Global fallback pattern(s) for all events
cmd = string|string[]|function(plugin), -- Command(s) to create
keys = zpack.KeySpec|zpack.KeySpec[]|function(plugin), -- Keymap(s) to create
ft = string|string[]|function(plugin), -- FileType(s) to lazy load on
-- Spec imports
import = "plugins.lsp", -- Import from lua/{path}/*.lua and lua/{path}/*/init.lua
}The plugin data object passed to hooks and trigger functions:
{
spec = vim.pack.Spec, -- The resolved vim.pack spec (name, src, version)
path = string, -- Absolute path to the plugin directory
}{
event = string|string[], -- Event name(s) to trigger on
pattern = string|string[], -- Pattern(s) for the event (optional)
}{
[1] = "<leader>ff", -- LHS keymap (required)
[2] = function() end, -- RHS function
desc = "description", -- Keymap description
mode = "n"|{"n","v"}, -- Mode(s), default: "n"
remap = true|false, -- Allow remapping, default: false
nowait = true|false, -- Default: false
}Most of your lazy.nvim plugin specs will work as-is with zpack, however, as a thin layer, zpack specs have some differences to minimize complexity and maintain compatibility with vim.pack.
key differences:
- dependencies: zpack does not have a
dependenciesfield. See Dependency Handling - version pinning: lazy.nvim's
versionfield maps to zpack'ssem_version. See Version Pinning - other unsupported fields: Remove lazy.nvim-specific fields like
dev,module, etc. See the Spec Reference for supported fields - spec merging: zpack does not merge duplicate specs. Please only define each plugin spec once.
Due to the lack of implicit dependency inference, when using blink.cmp with lazydev, add lazydev to per_filetype instead of default sources.
This approach also ensures lazydev loads only in Lua files, rather than every time blink.cmp loads (which happens even with lazy.nvim if lazydev is part of the default sources).
require('blink.cmp').setup({
sources = {
per_filetype = {
lua = { inherit_defaults = true, 'lazydev' }
},
providers = {
lazydev = { name = "LazyDev", module = "lazydev.integrations.blink", fallbacks = { "lsp" } },
},
},
})Pass a local directory to your plugin spec's src.
return {
src = vim.fn.expand('~/projects/my_plugin.nvim')
}Use the builtin profiling argument when starting Neovim with:
nvim --startuptime startuptime.log
While zpack does not provide any UI dashboards, its builtin commands should cover most of the plugin management functionalities. You can also use :h vim.pack commands directly.
zpack's spec design and several features are inspired by lazy.nvim. Credit to folke for the excellent plugin manager that influenced this project.