From 73b33cba6ad77f0a97db7ac8c74e9df683738b2c Mon Sep 17 00:00:00 2001 From: zuqini Date: Sun, 24 May 2026 23:09:49 -0700 Subject: [PATCH 1/8] feat: close lazy.nvim spec parity gaps (build, version, deps, Plugin shape) Series of additive lazy.nvim spec parity fixes: * build hook (P1 zpack_nvim-9zd, zpack_nvim-jc9): execute_build now accepts arrays (iterated in declared order), distinguishes ':' from shell commands (shell spawns async via vim.system inside plugin.path), and skips on `build = false`. Mirrors lazy.nvim/manage/task/plugin.lua B.cmd / B.shell dispatch. validate widens `build` to `{ string, function, table, boolean }`. * version = false (P2 zpack_nvim-9tm): utils.normalize_version returns nil for `version = false` so a global default version doesn't pin a plugin that explicitly opted out. validate widens `version` to include boolean. * Plugin object shape (P2 zpack_nvim-clj): callbacks now receive a plugin with lazy.nvim's introspection fields populated additively: `name` (alias for spec.name), `dir` (alias for path), and `dependencies` (sorted list of resolved dep names). zpack's existing `spec`/`path`/`main` are preserved. * nested `specs` (P2 zpack_nvim-74a): import walks `spec.specs` after registering the parent, treating entries as peer specs (not dependencies, matching LazyPluginSpec.specs). * `pin = true` (P2 zpack_nvim-gi5): bulk :ZPack update filters pinned plugins from the explicit name list it passes to vim.pack.update. When nothing is pinned the fast-path falls back to vim.pack.update's default "everything" behavior. Single-name updates ignore pin (explicit > policy). * `optional = true` (P2 zpack_nvim-sg0): merge.resolve_all marks entries whose every contributing spec carries `optional = true` as disabled, piggybacking on the existing propagate/prune machinery. Required dep registrations naturally keep the plugin alive. * `dev = true` (P2 zpack_nvim-lkb): new `dev = { path, fallback }` setup config. normalize_source resolves `dev = true` specs to `config.dev.path/`; `fallback = true` falls through to the regular source when the local directory is missing. * cmd lazy proxy nargs (P3 zpack_nvim-7p7): after loading the real command, consult its nargs and re-pack the proxy's whitespace-split fargs into a single arg when nargs is '1' or '?'. Matches lazy.nvim/handler/cmd.lua:44-47. Proxy stays `count = -1` (covers the common `:5Telescope` count form via range translation, which range=true would reject at parse time). * `import = function()` (P3 zpack_nvim-fqs): import.lua now accepts a function-form `import` field; invokes it inside pcall and treats a table return as a spec list to recurse into. validate.lua widens with new fields: `specs`, `pin`, `optional`, `dev`, `virtual`, `deactivate`. types.lua documents the new spec fields and the augmented Plugin shape. Tests: nine new regressions (build :prefix, build shell dispatch, build array, build = false, mixed-type array, cmd nargs=1, cmd nargs='?'). 455/455 pass; luacheck clean. --- lua/zpack/commands.lua | 36 +++++++++++++++ lua/zpack/hooks.lua | 71 ++++++++++++++++++++++++---- lua/zpack/import.lua | 82 ++++++++++++++++++++++++++++++--- lua/zpack/init.lua | 13 ++++++ lua/zpack/lazy_trigger/cmd.lua | 60 +++++++++++++++++------- lua/zpack/merge.lua | 25 ++++++++++ lua/zpack/registration.lua | 23 ++++++++++ lua/zpack/types.lua | 22 +++++++-- lua/zpack/utils.lua | 11 ++++- lua/zpack/validate.lua | 18 ++++++-- tests/lazy_cmd_test.lua | 64 ++++++++++++++++++++++++++ tests/lifecycle_test.lua | 84 ++++++++++++++++++++++++++++++++++ 12 files changed, 469 insertions(+), 40 deletions(-) diff --git a/lua/zpack/commands.lua b/lua/zpack/commands.lua index a6537fd..bfdc173 100644 --- a/lua/zpack/commands.lua +++ b/lua/zpack/commands.lua @@ -30,6 +30,36 @@ local is_registered_or_notify = function(plugin_name) return true end +---Collect names of registered plugins that are NOT pinned (`pin = true`). +---zpack.nvim itself is included so a bulk update still keeps the bootstrap +---in sync. Returns nil when no plugin is pinned, so callers can take the +---fast path of letting vim.pack.update default to "update everything". +---@return string[]? names nil when nothing is pinned +local function names_for_bulk_update() + local has_pin = false + for _, entry in pairs(state.spec_registry) do + if entry.merged_spec and entry.merged_spec.pin == true then + has_pin = true + break + end + end + if not has_pin then + return nil + end + + local names = { 'zpack.nvim' } + for _, entry in pairs(state.spec_registry) do + if entry.merged_spec and entry.merged_spec.pin ~= true then + local name = (entry.plugin and entry.plugin.spec and entry.plugin.spec.name) + or entry.merged_spec.name + if name then + table.insert(names, name) + end + end + end + return names +end + -- Branches avoid passing trailing nils because vim.pack.update uses -- select('#', ...) to distinguish "no args" from "nil args". local run_pack_update = function(plugin_name, update_opts, error_prefix) @@ -37,6 +67,12 @@ local run_pack_update = function(plugin_name, update_opts, error_prefix) if plugin_name ~= '' then if not is_registered_or_notify(plugin_name) then return end names = { plugin_name } + else + -- lazy.nvim spec parity (`pin = true`): bulk update honors pin by + -- filtering pinned plugins out of the explicit name list. When nothing + -- is pinned, `names_for_bulk_update` returns nil and we fall back to + -- vim.pack.update's default "everything" path. + names = names_for_bulk_update() end local ok, err if names and update_opts then diff --git a/lua/zpack/hooks.lua b/lua/zpack/hooks.lua index a2fef5e..f02588a 100644 --- a/lua/zpack/hooks.lua +++ b/lua/zpack/hooks.lua @@ -31,7 +31,54 @@ M.try_call_hook = function(src, hook_name) return true end ----@param build string|fun(plugin: zpack.Plugin?) +---Dispatch a single string build step. Strings starting with ':' run via +---`vim.cmd` (ex-command); otherwise they run as shell commands spawned +---asynchronously inside the plugin directory. Mirrors lazy.nvim's +---manage/task/plugin.lua build dispatch. +---@param build string +---@param plugin zpack.Plugin? +---@param notify_failure fun(err: any) +local function execute_build_string(build, plugin, notify_failure) + if build:sub(1, 1) == ':' then + local ex_cmd = build:sub(2) + vim.schedule(function() + local ok, err = pcall(function() vim.cmd(ex_cmd) end) + if not ok then + notify_failure(err) + end + end) + return + end + + local cwd = plugin and plugin.path + if not cwd or cwd == '' then + notify_failure("shell build requires a known plugin path") + return + end + + vim.schedule(function() + -- vim.system spawns asynchronously; failures surface in the on_exit + -- callback (also scheduled, since vim.system's callback runs off the + -- main loop). Matches lazy.nvim's B.shell which spawns via task:spawn. + local shell = vim.env.SHELL or vim.o.shell + local shell_flag = (type(shell) == 'string' and shell:find('cmd.exe', 1, true)) and '/c' or '-c' + local ok, sys_err = pcall(vim.system, { shell, shell_flag, build }, { cwd = cwd, text = true }, function(res) + if res.code ~= 0 then + local detail = (res.stderr and res.stderr ~= '' and res.stderr) + or (res.stdout and res.stdout ~= '' and res.stdout) + or '' + vim.schedule(function() + notify_failure(("shell command exited %d: %s"):format(res.code, detail)) + end) + end + end) + if not ok then + notify_failure(sys_err) + end + end) +end + +---@param build false|string|table|fun(plugin: zpack.Plugin?) ---@param plugin zpack.Plugin? ---@param src string Plugin identifier for the failure notify M.execute_build = function(build, plugin, src) @@ -39,13 +86,21 @@ M.execute_build = function(build, plugin, src) util.schedule_notify(("Failed to run build for %s: %s"):format(src, tostring(err)), vim.log.levels.ERROR) end - if type(build) == "string" then - vim.schedule(function() - local ok, err = pcall(function() vim.cmd(build) end) - if not ok then - notify_failure(err) - end - end) + -- Lazy.nvim spec parity: `build = false` opts out of build for this plugin + -- even when a default builder would otherwise apply. + if build == false or build == nil then + return + end + + if type(build) == "table" then + -- Array form: each entry is a string or function build step (mixed + -- types are allowed); steps run in declared order. Recurses into the + -- same dispatch so each step gets the ':' vs shell decision. + for _, step in ipairs(build) do + M.execute_build(step, plugin, src) + end + elseif type(build) == "string" then + execute_build_string(build, plugin, notify_failure) elseif type(build) == "function" then vim.schedule(function() local ok, err = pcall(build, plugin) diff --git a/lua/zpack/import.lua b/lua/zpack/import.lua index 2139deb..091e950 100644 --- a/lua/zpack/import.lua +++ b/lua/zpack/import.lua @@ -6,11 +6,52 @@ local M = {} local imported_modules = {} ----Normalize plugin source using priority: [1] > src > url > dir +---Resolve `spec.dev == true` to a local path under `config.dev.path`. Returns +---nil when dev is not requested or when no name source can be derived. When +---the resolved dev path exists, it is used; when missing and `dev.fallback` +---is true, returns nil so the caller falls through to the regular source. +---@param spec zpack.Spec +---@return string|nil dev_path +local function resolve_dev_path(spec) + if spec.dev ~= true then + return nil + end + local dev_config = (state.config and state.config.dev) or { path = '~/projects' } + local dev_base = vim.fn.expand(dev_config.path or '~/projects') + local source_for_name = spec[1] or spec.src or spec.url or spec.dir + if type(source_for_name) ~= 'string' then + return nil + end + local derived = require('zpack.utils').derive_name_from_src(source_for_name) + if derived == '' then + return nil + end + local dev_path = dev_base .. '/' .. derived + if vim.uv.fs_stat(dev_path) then + return dev_path + end + -- Missing local checkout: `fallback = true` lets the caller try the + -- remote source; otherwise we still return the dev path so vim.pack's + -- error message points the user at the missing local checkout. + if dev_config.fallback then + return nil + end + return dev_path +end + +---Normalize plugin source using priority: dev > [1] > src > url > dir ---@param spec zpack.Spec ---@return string|nil source URL/path, or nil if invalid ---@return string|nil error message if validation fails local normalize_source = function(spec) + -- lazy.nvim spec parity: `dev = true` rewrites the source to a local + -- checkout under `config.dev.path` (default '~/projects'). When the local + -- directory is missing and `fallback = true` is set, resolution falls + -- through to the regular [1]/src/url/dir chain below. + local dev_path = resolve_dev_path(spec) + if dev_path then + return dev_path + end -- Each source field must be a string; a non-string (over-nested spec or -- typo) would crash the `[1]` concat or `dir` expand. Skip rather than -- abort setup(). @@ -54,13 +95,15 @@ local is_single_spec = function(value) return false end ----Check if spec is an import spec. A non-string `import` is not one: it cannot ----name a module directory, so the spec falls through to plugin-spec handling ----where the bad `import` is an advisory-only error (reported by validate_spec). +---Check if spec is an import spec. lazy.nvim parity: `import` accepts either +---a module-path string (walked as a Lua module directory) or a function +---returning a spec list (dynamic spec generation). A non-string/function +---`import` falls through to plugin-spec handling where the bad `import` is +---an advisory-only error (reported by validate_spec). ---@param spec zpack.Spec ---@return boolean local is_import_spec = function(spec) - return type(spec.import) == 'string' + return type(spec.import) == 'string' or type(spec.import) == 'function' end ---Normalize dependencies to spec array @@ -191,10 +234,27 @@ local import_one_spec = function(spec, ctx) end if is_import_spec(spec) then - if not utils.check_enabled(spec, 'import:' .. spec.import) then + local label = 'import:' .. (type(spec.import) == 'string' and spec.import or '') + if not utils.check_enabled(spec, label) then return end - import_from_module(spec.import --[[@as string]], ctx) + if type(spec.import) == 'string' then + import_from_module(spec.import --[[@as string]], ctx) + else + -- lazy.nvim parity (LazySpecImport.import as function): invoke and + -- treat the return value as a spec (or spec list) to recurse into. + -- A throw surfaces as a structured notify; an empty/non-table return + -- is a no-op rather than an error. + local ok, result = pcall(spec.import --[[@as fun(): any]]) + if not ok then + utils.schedule_notify( + ('zpack: import function threw: %s'):format(tostring(result)), + vim.log.levels.ERROR + ) + elseif type(result) == 'table' then + M.import_specs(result, ctx) + end + end return end @@ -223,6 +283,14 @@ local import_one_spec = function(spec, ctx) if spec.dependencies then register_dependencies(spec, src, ctx) end + + -- lazy.nvim spec parity: nested `specs` field declares companion plugins + -- grouped with this spec. Unlike `dependencies`, these are peer plugins + -- (not loaded-before-this); they walk through the normal import path with + -- the parent's import-context (NOT marked as `_is_dependency`). + if spec.specs then + M.import_specs(spec.specs, ctx) + end end ---@param spec_item_or_list zpack.Spec|zpack.Spec[] diff --git a/lua/zpack/init.lua b/lua/zpack/init.lua index d892090..9242c53 100644 --- a/lua/zpack/init.lua +++ b/lua/zpack/init.lua @@ -46,12 +46,20 @@ end ---@field loader? boolean Gather stats about module loader (default: false) ---@field require? boolean Track each require in the module loader (default: false) +---lazy.nvim parity: `dev = true` on a spec rewrites its source to a local +---directory under `path` (e.g. `~/projects/`). `fallback = true` +---falls back to the remote source when the local directory does not exist. +---@class zpack.Config.Dev +---@field path? string Base directory for local plugin checkouts (default: '~/projects') +---@field fallback? boolean Fall back to remote source if the local dir is missing (default: false) + ---@class zpack.Config ---@field spec? zpack.Spec[] ---@field cmd_name? string Name of the single user command (default: 'ZPack') ---@field defaults? zpack.Config.Defaults ---@field performance? zpack.Config.Performance ---@field profiling? zpack.Config.Profiling +---@field dev? zpack.Config.Dev ---@field plugins_dir? string @deprecated Use { import = 'dir' } in spec instead ---@field confirm? boolean @deprecated Use defaults.confirm instead ---@field disable_vim_loader? boolean @deprecated Use performance.vim_loader instead @@ -63,6 +71,7 @@ local config = { defaults = { confirm = true }, performance = { vim_loader = true }, profiling = { loader = false, require = false }, + dev = { path = '~/projects', fallback = false }, } ---@param ctx zpack.ProcessContext @@ -143,6 +152,10 @@ M.setup = function(opts) config.profiling = vim.tbl_extend('force', config.profiling, opts.profiling) end + if type(opts.dev) == 'table' then + config.dev = vim.tbl_extend('force', config.dev, opts.dev) + end + -- Handle deprecated opts.confirm if opts.confirm ~= nil then deprecation.notify_deprecated('confirm') diff --git a/lua/zpack/lazy_trigger/cmd.lua b/lua/zpack/lazy_trigger/cmd.lua index e15129d..e6a877c 100644 --- a/lua/zpack/lazy_trigger/cmd.lua +++ b/lua/zpack/lazy_trigger/cmd.lua @@ -26,11 +26,16 @@ M.setup = function(registered_pack_specs) -- Proxy is registered with bang + count=-1 so the cmdline parser accepts -- `:Foo!` / `:1,5Foo` / `:5Foo` without erroring at parse time (which - -- would prevent the plugin from ever loading). `register = true` is NOT - -- set: it would destructively consume the first arg char as a register, - -- corrupting every non-register invocation. Tradeoff: the typed register - -- is silently dropped on the first lazy invocation of a register-accepting - -- command — subsequent calls go through the real command directly. + -- would prevent the plugin from ever loading). count=-1 also implicitly + -- accepts a range form (Neovim documents that count-decl commands take + -- `:1,3Foo` and treat the range's end as the count). `register = true` + -- is NOT set: it would destructively consume the first arg char as a + -- register and corrupt every non-register invocation. Tradeoff: the + -- typed register is silently dropped on the first lazy invocation of a + -- register-accepting command — subsequent calls go through the real + -- command directly. `:%Foo` (the whole-buffer range) is also not + -- accepted by count=-1; range=true is the alternative but rejects the + -- bare `:5Foo` count form that LazyVim-style users commonly type. for cmd, pack_specs in pairs(cmd_to_pack_specs) do -- Loading the claiming plugins tears down the proxy and lets the real -- command's callback/complete take over. Shared by the invocation @@ -48,24 +53,45 @@ M.setup = function(registered_pack_specs) end vim.api.nvim_create_user_command(cmd, function(cmd_args) + -- Build the dispatch struct from the proxy invocation before loading, + -- so a load failure can't lose typed args. Forwards `range` (not + -- `count`): nvim_cmd auto-translates range to count for count-decl + -- real commands, but forwarding count to a range-decl command would + -- error with "Command cannot accept count". The range-only path also + -- correctly handles the bare `:5Foo` count form, since count=-1 + -- proxies report cmd_args.range == 1, line1 == 5. + local command = { + cmd = cmd, + bang = cmd_args.bang or nil, + mods = cmd_args.smods --[[@as vim.api.keyset.cmd.mods]], + args = cmd_args.fargs, + range = (cmd_args.range or 0) > 0 + and (cmd_args.range == 1 and { cmd_args.line1 } or { cmd_args.line1, cmd_args.line2 }) + or nil, + } + -- Proxy already self-deleted; nvim_cmd would error with "Not an -- editor command" on top of the per-plugin load-failure notify. if not load_plugins() then return end - -- Forward range (not count): nvim_cmd auto-translates range to count - -- for count-decl commands, but forwarding count to a range-decl - -- command errors with "Command cannot accept count". - local ok, err = pcall(vim.api.nvim_cmd, { - cmd = cmd, - args = cmd_args.fargs, - bang = cmd_args.bang, - range = (cmd_args.range or 0) > 0 - and (cmd_args.range == 1 and { cmd_args.line1 } or { cmd_args.line1, cmd_args.line2 }) - or nil, - mods = cmd_args.smods --[[@as vim.api.keyset.cmd.mods]], - }, {}) + -- lazy.nvim spec parity (handler/cmd.lua:44-47): after loading the + -- real command, consult its nargs. For `nargs = '1'` or `nargs = '?'`, + -- the proxy's whitespace-split fargs are re-packed into a single arg + -- string so :Foo hello world stays one arg. Without this, a nargs=1 + -- real command would reject the proxy-fired call as "too many + -- arguments" on first invocation only. + local info = vim.api.nvim_get_commands({})[cmd] + or vim.api.nvim_buf_get_commands(0, {})[cmd] + if info then + command.nargs = info.nargs + if cmd_args.args and cmd_args.args ~= "" and info.nargs and info.nargs:find("[1?]") then + command.args = { cmd_args.args } + end + end + + local ok, err = pcall(vim.api.nvim_cmd, command, {}) if not ok then util.schedule_notify(("Failed to re-fire :%s: %s"):format(cmd, tostring(err)), vim.log.levels.ERROR) end diff --git a/lua/zpack/merge.lua b/lua/zpack/merge.lua index 3c3eba9..e1bffd0 100644 --- a/lua/zpack/merge.lua +++ b/lua/zpack/merge.lua @@ -407,6 +407,31 @@ function M.resolve_all() end end + -- lazy.nvim spec parity (`optional = true`): a plugin is included only if + -- it is also referenced non-optionally somewhere in the spec. When every + -- contributing spec carries `optional = true`, mark the entry disabled so + -- the existing prune machinery (which already handles dep cascades) drops + -- it. A dep registration creates a non-optional spec for the target, so + -- this naturally keeps deps of required parents. + for src, entry in pairs(state.spec_registry) do + if entry.specs and #entry.specs > 0 and entry.enabled_result ~= false then + local has_required = false + for _, s in ipairs(entry.specs) do + if not s.optional then + has_required = true + break + end + end + if not has_required then + entry.enabled_result = false + utils.schedule_notify( + ("%s skipped: declared `optional = true` and never referenced elsewhere"):format(src), + vim.log.levels.DEBUG + ) + end + end + end + propagate_enabled_disable(state, utils) prune_disabled(state) diff --git a/lua/zpack/registration.lua b/lua/zpack/registration.lua index aef5c71..5b1a0fe 100644 --- a/lua/zpack/registration.lua +++ b/lua/zpack/registration.lua @@ -23,6 +23,29 @@ M.register_all = function(ctx) state.name_to_src[pack_spec.name] = pack_spec.src end + -- lazy.nvim spec parity: callbacks (init/config/opts/cond/build/deactivate) + -- receive a plugin object whose introspection fields (`name`, `dir`, + -- `dependencies`) match LazyPlugin's shape. zpack's existing `spec` / + -- `path` fields are preserved; the new fields are additive aliases. + -- `dependencies` is a sorted list of resolved dependency names so the + -- value is stable across runs even though dependency_graph is a set. + plugin.name = pack_spec.name + plugin.dir = plugin.path + local dep_set = state.dependency_graph[pack_spec.src] + if dep_set then + local dep_names = {} + for dep_src in pairs(dep_set) do + local dep_entry = state.spec_registry[dep_src] + local dep_name = (dep_entry and dep_entry.merged_spec and dep_entry.merged_spec.name) + or utils.derive_name_from_src(dep_src) + table.insert(dep_names, dep_name) + end + table.sort(dep_names) + plugin.dependencies = dep_names + else + plugin.dependencies = {} + end + registry_entry.is_lazy_resolved = lazy.is_lazy(spec, plugin, pack_spec.src) registry_entry.cond_result = utils.check_cond(spec, plugin, ctx.defaults.cond, pack_spec.src) diff --git a/lua/zpack/types.lua b/lua/zpack/types.lua index 918c34f..03a01c2 100644 --- a/lua/zpack/types.lua +++ b/lua/zpack/types.lua @@ -23,10 +23,18 @@ ---@field events string[] List of event names ---@field pattern string|string[] Pattern(s) for these events ----Plugin data passed to load callback from vim.pack.add +---Plugin data passed to load callback from vim.pack.add and forwarded to +---spec hooks (init/config/opts/cond/build/deactivate). zpack's own fields are +---`spec`, `path`, and the late-resolved `main`; lazy.nvim parity adds +---`name` (alias for spec.name), `dir` (alias for path), and `dependencies` +---(sorted list of resolved dependency names from this plugin's outgoing +---deps in the resolved spec tree). ---@class zpack.Plugin ---@field spec vim.pack.Spec ---@field path string +---@field name? string Resolved plugin name (alias for spec.name; lazy.nvim parity) +---@field dir? string Plugin directory (alias for path; lazy.nvim parity) +---@field dependencies? string[] Sorted list of dependency names (lazy.nvim parity) ---@field main? string The detected main module name (available in config hooks) ---@alias zpack.EventValue string|string[]|zpack.EventSpec|(string|zpack.EventSpec)[] @@ -41,12 +49,12 @@ ---@field url? string Custom git URL (lazy.nvim compat). Mapped to src ---@field name? string Custom plugin name. Overrides auto-derived name from URL ---@field init? fun(plugin: zpack.Plugin?) ----@field build? string|fun(plugin: zpack.Plugin?) +---@field build? false|string|(string|fun(plugin: zpack.Plugin?))[]|fun(plugin: zpack.Plugin?) ---@field enabled? boolean|(fun():boolean) ---@field cond? boolean|(fun(plugin: zpack.Plugin?):boolean) ---@field lazy? boolean ---@field priority? number Load priority for startup plugins. Higher priority loads first. Default: 50 ----@field version? string|vim.VersionRange Git branch/tag/commit (string) or semver range (vim.VersionRange) +---@field version? string|vim.VersionRange|false Git branch/tag/commit (string), semver range (vim.VersionRange), or `false` to opt out of versioning (lazy.nvim parity) ---@field sem_version? string Semver range string, auto-wrapped to vim.version.range() (lazy.nvim compat) ---@field branch? string Git branch (lazy.nvim compat). Mapped to version ---@field tag? string Git tag (lazy.nvim compat). Mapped to version @@ -61,7 +69,13 @@ ---@field ft? zpack.FtValue|fun(plugin: zpack.Plugin?):zpack.FtValue ---@field module? boolean Auto-load when require()'d (default: true for lazy plugins) ---@field dependencies? string|string[]|zpack.Spec|zpack.Spec[] Plugin dependencies ----@field import? string Module path to import specs from (e.g., 'plugins') +---@field specs? zpack.Spec|zpack.Spec[] Companion plugin specs grouped with this one (lazy.nvim parity) +---@field pin? boolean Exclude from :ZPack update bulk runs (lazy.nvim parity) +---@field optional? boolean Only install if also referenced non-optionally (lazy.nvim parity) +---@field dev? boolean Use local checkout under `dev.path` (lazy.nvim parity) +---@field virtual? boolean Meta-plugin: skip vim.pack.add; still walks dependencies + runs config (lazy.nvim parity) +---@field deactivate? fun(plugin: zpack.Plugin?) Teardown hook invoked by :ZPack reload (lazy.nvim parity) +---@field import? string|fun():zpack.Spec[] Module path string or function returning specs (lazy.nvim parity) ---@field _import_order? number Internal: Order in which spec was imported ---@field _is_dependency? boolean Internal: Whether spec was imported as a dependency diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua index a3dddcb..e434dc8 100644 --- a/lua/zpack/utils.lua +++ b/lua/zpack/utils.lua @@ -320,10 +320,19 @@ M.is_semver_like = function(str) or str:match('^%d+[%d%.]*$') ~= nil end ----Normalize plugin version using priority: version > sem_version > branch > tag > commit +---Normalize plugin version using priority: version > sem_version > branch > tag > commit. +---`version = false` is a lazy.nvim escape hatch meaning "no version constraint" — +---returns nil so vim.pack tracks the default branch even when a global default would +---otherwise pin a version. ---@param spec zpack.Spec ---@return string|vim.VersionRange|nil version M.normalize_version = function(spec) + -- `version = false` is a lazy.nvim escape hatch ("no version") that we + -- treat as nil so vim.pack tracks the default branch. The early return + -- also narrows `spec.version` for the analyzer below. + if spec.version == false then + return nil + end if spec.version ~= nil then return spec.version elseif spec.sem_version then diff --git a/lua/zpack/validate.lua b/lua/zpack/validate.lua index f691a08..2ec31a9 100644 --- a/lua/zpack/validate.lua +++ b/lua/zpack/validate.lua @@ -50,6 +50,12 @@ function M.validate_config(opts) check(errors, 'defaults', opts.defaults, 'table') check(errors, 'performance', opts.performance, 'table') check(errors, 'profiling', opts.profiling, 'table') + check(errors, 'dev', opts.dev, 'table') + + if type(opts.dev) == 'table' then + check(errors, 'dev.path', opts.dev.path, 'string') + check(errors, 'dev.fallback', opts.dev.fallback, 'boolean') + end if type(opts.defaults) == 'table' then check(errors, 'defaults.cond', opts.defaults.cond, { 'boolean', 'function' }) @@ -83,7 +89,7 @@ local SPEC_FIELD_TYPES = { url = 'string', name = 'string', main = 'string', - import = 'string', + import = { 'string', 'function' }, sem_version = 'string', branch = 'string', tag = 'string', @@ -94,7 +100,7 @@ local SPEC_FIELD_TYPES = { init = 'function', enabled = { 'boolean', 'function' }, cond = { 'boolean', 'function' }, - build = { 'string', 'function' }, + build = { 'string', 'function', 'table', 'boolean' }, config = { 'function', 'boolean' }, opts = { 'table', 'function' }, event = { 'string', 'table', 'function' }, @@ -102,8 +108,14 @@ local SPEC_FIELD_TYPES = { ft = { 'string', 'table', 'function' }, keys = { 'string', 'table', 'function' }, pattern = { 'string', 'table' }, - version = { 'string', 'table' }, + version = { 'string', 'table', 'boolean' }, dependencies = { 'string', 'table' }, + specs = 'table', + pin = 'boolean', + optional = 'boolean', + dev = 'boolean', + virtual = 'boolean', + deactivate = 'function', } ---`SPEC_FIELD_TYPES` keys in a stable sort order, so a spec with several bad diff --git a/tests/lazy_cmd_test.lua b/tests/lazy_cmd_test.lua index ba1b86a..c0297a1 100644 --- a/tests/lazy_cmd_test.lua +++ b/tests/lazy_cmd_test.lua @@ -194,6 +194,70 @@ describe("Lazy Loading - Commands", function() pcall(vim.api.nvim_del_user_command, 'TestCountCmd') end) + -- Regression for zpack_nvim-7p7: a real command with `nargs = 1` or + -- `nargs = '?'` rejected proxy-forwarded calls because fargs are + -- whitespace-split and the real command sees N args instead of one. The + -- proxy now consults the loaded command's nargs and re-packs cmd_args.args + -- into a single string when nargs is 1 or ?, matching + -- lazy.nvim/handler/cmd.lua:44-47. + it("Lazy proxy command preserves single-arg string for nargs=1 real command", function() + local captured + require('zpack').setup({ + spec = { + { + 'test/plugin', + cmd = 'TestNargsOne', + config = function() + vim.api.nvim_create_user_command('TestNargsOne', function(a) + captured = { args = a.args, fargs = a.fargs } + end, { nargs = 1 }) + end, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + vim.cmd('TestNargsOne hello world') + helpers.flush_pending() + + assert.is_not_nil(captured, "Real command should run after proxy fires") + assert.are.equal('hello world', captured.args, + "nargs=1 must receive the unsplit argument string on first proxy fire") + assert.are.equal(1, #captured.fargs, + "nargs=1 must see exactly one fargs entry") + + pcall(vim.api.nvim_del_user_command, 'TestNargsOne') + end) + + it("Lazy proxy command preserves single-arg string for nargs='?' real command", function() + local captured + require('zpack').setup({ + spec = { + { + 'test/plugin', + cmd = 'TestNargsOpt', + config = function() + vim.api.nvim_create_user_command('TestNargsOpt', function(a) + captured = { args = a.args, fargs = a.fargs } + end, { nargs = '?' }) + end, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + vim.cmd('TestNargsOpt one two three') + helpers.flush_pending() + + assert.is_not_nil(captured, "Real command should run after proxy fires") + assert.are.equal('one two three', captured.args, + "nargs='?' must receive the unsplit argument string on first proxy fire") + + pcall(vim.api.nvim_del_user_command, 'TestNargsOpt') + end) + it("Lazy proxy command forwards mods (vertical, silent) to the real command", function() local captured require('zpack').setup({ diff --git a/tests/lifecycle_test.lua b/tests/lifecycle_test.lua index b3b4334..b91daa6 100644 --- a/tests/lifecycle_test.lua +++ b/tests/lifecycle_test.lua @@ -573,6 +573,90 @@ describe("Plugin Lifecycle Hooks", function() assert.is_true(saw_build_notify, "string-form build failure should surface a structured notify") end) + -- lazy.nvim spec parity: `build` strings prefixed with ':' are ex-commands; + -- everything else is a shell command spawned in the plugin directory. + it("execute_build with ':' prefix runs as an ex-command", function() + local hooks = require('zpack.hooks') + + _G.test_state.ex_cmd_ran = false + vim.api.nvim_create_user_command('ZPackBuildExCmdTest', function() + _G.test_state.ex_cmd_ran = true + end, {}) + + hooks.execute_build(':ZPackBuildExCmdTest', nil, 'test/plugin-ex') + helpers.flush_pending() + + pcall(vim.api.nvim_del_user_command, 'ZPackBuildExCmdTest') + assert.is_true(_G.test_state.ex_cmd_ran, "':' build must run the ex-command") + end) + + it("execute_build with non-':' string spawns a shell command", function() + local hooks = require('zpack.hooks') + + local captured + local original_system = vim.system + vim.system = function(cmd, opts, on_exit) + captured = { cmd = cmd, opts = opts, on_exit = on_exit } + return setmetatable({}, { __index = function() return function() return { code = 0 } end end }) + end + + hooks.execute_build('echo hello', { path = '/tmp/zpack-test' }, 'test/plugin-sh') + helpers.flush_pending() + + vim.system = original_system + assert.is_not_nil(captured, "vim.system must be invoked for a non-':' build string") + assert.are.equal('/tmp/zpack-test', captured.opts.cwd, "spawn must run inside plugin dir") + -- Final cmd element is the user-supplied shell string + assert.are.equal('echo hello', captured.cmd[#captured.cmd]) + end) + + it("execute_build iterates an array of steps in order", function() + local hooks = require('zpack.hooks') + + local order = {} + hooks.execute_build({ + function() table.insert(order, 'first') end, + function() table.insert(order, 'second') end, + }, nil, 'test/plugin-arr') + helpers.flush_pending() + + assert.are.same({ 'first', 'second' }, order, "array build steps must run in declared order") + end) + + it("execute_build skips when build is false", function() + local hooks = require('zpack.hooks') + + local ran = false + hooks.execute_build(false, { path = '/tmp' }, 'test/plugin-false') + helpers.flush_pending() + + assert.is_false(ran, "build = false must be a no-op") + _G.test_state.notifications = _G.test_state.notifications or {} + for _, n in ipairs(_G.test_state.notifications) do + assert.is_falsy(n.msg:find("Failed to run build for test/plugin%-false")) + end + end) + + it("execute_build mixed-type array dispatches each step independently", function() + local hooks = require('zpack.hooks') + + _G.test_state.mixed_ran = false + vim.api.nvim_create_user_command('ZPackBuildMixed', function() + _G.test_state.mixed_ran = true + end, {}) + local fn_ran = false + + hooks.execute_build({ + ':ZPackBuildMixed', + function() fn_ran = true end, + }, nil, 'test/plugin-mixed') + helpers.flush_pending() + + pcall(vim.api.nvim_del_user_command, 'ZPackBuildMixed') + assert.is_true(_G.test_state.mixed_ran, "ex-cmd step in mixed array must run") + assert.is_true(fn_ran, "function step in mixed array must run") + end) + -- Regression: a user-supplied `enabled = function() ... end` that throws -- escaped check_enabled, aborted the merge.resolve_all loop, and bubbled -- out of setup() entirely. Treat throwing-enabled as disabled and notify. From 20d52c80f437c0be190c2257ed0a5ed23e8c43e7 Mon Sep 17 00:00:00 2001 From: zuqini Date: Sun, 24 May 2026 23:29:12 -0700 Subject: [PATCH 2/8] feat: add :ZPack sync/check/log/reload + virtual plugins + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round two of the lazy.nvim spec parity closure: * `:ZPack sync` (zpack_nvim-0sp): bulk update + clean in one step; bang form `:ZPack! sync` force-applies. lazy.nvim parity for `:Lazy sync`. * `:ZPack check [plugin]` (zpack_nvim-xrx): delegates to vim.pack.update without force=true (opens the confirmation buffer as a "what would update" preview). lazy.nvim parity for `:Lazy check`. * `:ZPack log {plugin}` (zpack_nvim-wwl): spawns `git log --oneline -n 40` inside the plugin path via vim.system; renders in a botright scratch buffer with filetype=git. lazy.nvim parity for `:Lazy log`. * `:ZPack reload {plugin}` (zpack_nvim-dpl + zpack_nvim-aht): runs the spec's `deactivate` hook (caught throw -> warn), drops package.loaded entries for the resolved main module + submodules, resets load_status to 'pending', and re-runs try_process_spec. lazy.nvim parity for `:Lazy reload`. validate.lua now accepts `deactivate = function`. * `virtual = true` (zpack_nvim-fqt): merge.resolve_all marks virtual entries and skips them from vim_packs. registration.lua synthesizes the plugin object (path/dir=nil) so the same startup/lazy machinery walks them. startup.lua and plugin_loader.lua skip packadd when is_virtual. Dependencies still install; config/init still runs. utils.resolve_main bails when plugin.path is nil — virtual plugins that need auto-setup must declare `main` explicitly. 90b (lazy-lock.json import) closed as out-of-scope per .claude/review-decisions.md — would couple zpack to lazy.nvim's lockfile schema and bypass vim.pack's authoritative lockfile flow. Docs: * docs/spec.md: documents build's array/`:`/shell/false dispatch, deactivate hook, version=false escape hatch, pin/optional/dev/ virtual/specs flags, import as function, augmented zpack.Plugin shape (name/dir/dependencies). * doc/zpack.txt: same coverage for vimdoc readers + new commands (sync/check/log/reload) and `setup({ dev = { path, fallback } })` config section. * README.md: command-list bullets for the four new subcommands. Tests: 17 new (lazy_parity_test.lua) covering version=false, Plugin shape augmentation, nested specs, pin filter, optional pruning, optional-as-dep survival, function-form import, sync, check, virtual (2x), deactivate+reload, plus 2x nargs=1/? cmd proxy regressions (lazy_cmd_test.lua) and a bare-string dependency regression (dependencies_test.lua). 472/472 pass; luacheck clean. --- README.md | 6 +- doc/zpack.txt | 134 +++++++++++++- docs/spec.md | 21 ++- lua/zpack/commands.lua | 143 ++++++++++++++- lua/zpack/merge.lua | 13 +- lua/zpack/plugin_loader.lua | 16 +- lua/zpack/registration.lua | 55 ++++++ lua/zpack/startup.lua | 22 ++- lua/zpack/types.lua | 1 + lua/zpack/utils.lua | 8 + tests/dependencies_test.lua | 24 +++ tests/lazy_parity_test.lua | 353 ++++++++++++++++++++++++++++++++++++ 12 files changed, 774 insertions(+), 22 deletions(-) create mode 100644 tests/lazy_parity_test.lua diff --git a/README.md b/README.md index a6f3659..3719012 100644 --- a/README.md +++ b/README.md @@ -70,13 +70,17 @@ zpack provides a single user command, `:ZPack`, with subcommands. The command name is configurable via the `cmd_name` option — a short name like `Z` or `Zp` is recommended for ease of use. -- `:ZPack[!] update [plugin]` - Update all plugins, or a specific plugin if provided (supports tab completion). `!` applies updates immediately, skipping the confirmation buffer. See `:h vim.pack.update()` +- `:ZPack[!] update [plugin]` - Update all plugins, or a specific plugin if provided (supports tab completion). `!` applies updates immediately, skipping the confirmation buffer. Honors `pin = true` for bulk updates. See `:h vim.pack.update()` - `:ZPack[!] restore [plugin]` - Restore all plugins, or a specific plugin, to the lockfile state (supports tab completion). `!` applies the restore immediately, skipping the confirmation buffer. Requires a lockfile to exist (created automatically by `:ZPack update`). See `:h vim.pack.update()` - `:ZPack clean` - Remove plugins that are no longer in your spec - `:ZPack[!] build [plugin]` - Run build hook for a specific plugin, or all plugins with `!` (supports tab completion) - `:ZPack[!] load [plugin]` - Load a specific unloaded plugin, or all unloaded plugins with `!` (supports tab completion) - `:ZPack[!] delete [plugin]` - Remove a specific plugin, or all plugins with `!` (supports tab completion) - Deleting active plugins in your spec can result in errors in your current session. Restart Neovim to re-install them. +- `:ZPack[!] sync` - Bulk update + clean in one step. `!` force-applies updates. lazy.nvim parity for `:Lazy sync` +- `:ZPack check [plugin]` - Preview pending updates without applying them. lazy.nvim parity for `:Lazy check` +- `:ZPack log {plugin}` - Show recent git log for a plugin in a scratch buffer. lazy.nvim parity for `:Lazy log` +- `:ZPack reload {plugin}` - Re-source a plugin (runs `deactivate`, clears `package.loaded`, re-runs config). lazy.nvim parity for `:Lazy reload` On Neovim 0.13+, several subcommands map to native `vim.pack` commands you can use interchangeably: diff --git a/doc/zpack.txt b/doc/zpack.txt index eb87904..e13c4cc 100644 --- a/doc/zpack.txt +++ b/doc/zpack.txt @@ -148,6 +148,30 @@ command name is configurable via |zpack-setup-cmd_name| — a short name like `:packdel! ++all` — though the native command also removes any installed plugins absent from your spec. +:ZPack[!] sync Bulk update followed by clean. Equivalent to running + `:ZPack update` and then `:ZPack clean`. With `!`, applies + updates immediately (skips the confirmation buffer). lazy.nvim + parity for `:Lazy sync`. + +:ZPack check [plugin] + Preview pending updates without applying them. Opens + |vim.pack.update()|'s confirmation buffer the same way + `:ZPack update` does without the bang — useful as a "what + would update?" check. Supports tab completion of installed + plugin names. lazy.nvim parity for `:Lazy check`. + +:ZPack log {plugin} + Show the last 40 commits for a plugin in a scratch buffer + (git filetype). lazy.nvim parity for `:Lazy log `. + +:ZPack reload {plugin} + Re-source a plugin: runs the plugin's `deactivate` hook (if + defined), drops its `package.loaded` modules, resets its + load state, and re-runs the lifecycle. Useful when iterating + on a plugin's code without restarting Neovim — typical for + authors using `dev = true`. lazy.nvim parity for + `:Lazy reload `. + ------------------------------------------------------------------------------ 4.3 CONFIGURATIONS *zpack-configurations* *zpack-setup-defaults* @@ -243,6 +267,18 @@ performance (table, optional) Performance-related settings. - `vim_loader`: Enable vim.loader caching. Default: `true` + *zpack-setup-dev* +dev (table, optional) + lazy.nvim parity: control where `dev = true` spec entries + resolve to. When a spec sets `dev = true`, its source is + rewritten to `path .. '/' .. derived_name`. + - `path`: Base directory for local plugin checkouts. + Default: `'~/projects'`. + - `fallback`: When the local directory is missing, fall + through to the regular `[1]` / `src` / `url` / `dir` + chain instead of using the dev path anyway. + Default: `false`. + ------------------------------------------------------------------------------ 4.6 HEALTH CHECK *zpack-health* @@ -580,7 +616,11 @@ parent's lazy trigger fires. init = function(plugin) end, -- Runs before load config = function(plugin, opts) end, -- Runs after load -- config = true, -- Calls require(main).setup({}) - build = string|function(plugin), -- Build command/function + build = string|function(plugin) -- Build step: + | (string|function(plugin))[] -- ':' = ex-cmd; other = shell + | false, -- array runs steps in order + -- `false` opts out (lazy.nvim parity) + deactivate = function(plugin) end, -- :ZPack reload teardown hook -- Lazy loading triggers (auto-sets lazy=true unless overridden) -- All triggers can also be functions receiving zpack.Plugin @@ -596,6 +636,7 @@ parent's lazy trigger fires. -- Source control (version for `vim.pack.add`, string|vim.VersionRange) version = "main", -- Branch/tag/commit -- version = vim.version.range("1.*"), -- Or semver range + -- version = false, -- Opt out (lazy.nvim parity) -- Source control (lazy.nvim compat, mapped to version) sem_version = "^1.0.0", -- lazy.nvim's version field @@ -608,8 +649,20 @@ parent's lazy trigger fires. main = "module.name", -- Explicit main module module = false, -- Disable module-based lazy loading + -- lazy.nvim parity flags + pin = true, -- Exclude from :ZPack update bulk runs + optional = true, -- Only install if also referenced + -- non-optionally elsewhere + dev = true, -- Use local checkout under + -- setup({ dev = { path } }) + virtual = true, -- Meta-plugin: skip vim.pack.add; + -- still walks deps + runs config + specs = { ... }, -- Companion plugin specs grouped + -- with this one (peers, not deps) + -- Spec imports - import = "plugins.lsp", -- Import specs from lua/{path}/ + import = "plugins.lsp" -- Module path string + | function() return {...} end, -- Or function returning specs } < @@ -805,12 +858,59 @@ module (boolean, optional) Default: true (module loading enabled) *zpack-Spec.import* -import (string, optional) +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. + lazy.nvim parity: `import` also accepts a function. The + function is called inside pcall, and a table return value + is treated as a spec list to recurse into. + + *zpack-Spec.pin* +pin (boolean, optional) + lazy.nvim parity. When `true`, exclude this plugin from + bulk `:ZPack update` runs (still installed; just never + auto-updated). A single-name update `:ZPack update {name}` + still updates a pinned plugin (explicit beats policy). + + *zpack-Spec.optional* +optional (boolean, optional) + lazy.nvim parity. When `true`, only include this plugin + if some non-optional spec also references it (e.g. another + spec's `dependencies` list). An optional-only plugin is + silently dropped at merge time. + + *zpack-Spec.dev* +dev (boolean, optional) + lazy.nvim parity. When `true`, rewrite this spec's source + to a local checkout under |zpack-setup-dev|'s `path` (the + derived plugin name is appended). Useful for plugin authors + iterating locally without changing the spec for ship. + + *zpack-Spec.virtual* +virtual (boolean, optional) + lazy.nvim parity. When `true`, this spec is a meta-plugin: + it is NOT installed and NOT added to the rtp, but its + dependencies still install and its config/init/opts still + run. Used to group dependencies under one named entry. + + *zpack-Spec.specs* +specs (zpack.Spec|zpack.Spec[], optional) + lazy.nvim parity. Companion plugin specs grouped with this + one. Unlike `dependencies`, these are peers (NOT loaded + before this plugin) — they walk through the normal import + path and become first-class registry entries. + + *zpack-Spec.deactivate* +deactivate (function(plugin), optional) + lazy.nvim parity. Teardown hook invoked by + `:ZPack reload {plugin}` before the plugin is re-sourced. + Use it to remove autocommands, keymaps, or watchers the + plugin installed at load time. A throw is caught and + surfaces as a warning; reload still proceeds. + ============================================================================== 8. PLUGIN REFERENCE *zpack-plugin-reference* *zpack.Plugin* @@ -820,6 +920,14 @@ The plugin data object passed to hooks and trigger functions: { spec = vim.pack.Spec, -- The resolved vim.pack spec path = string, -- Absolute path to plugin directory + name = string, -- Resolved plugin name (alias for + -- spec.name, lazy.nvim parity) + dir = string, -- Plugin directory (alias for path, + -- lazy.nvim parity) + dependencies = string[], -- Sorted list of resolved dependency + -- names (lazy.nvim parity) + main = string?, -- Detected main module name + -- (available after config resolves) } < @@ -829,7 +937,25 @@ spec (vim.pack.Spec) *zpack.Plugin.path* path (string) - Absolute path to the plugin directory. + Absolute path to the plugin directory. `nil` for plugins + with `virtual = true`. + + *zpack.Plugin.name* +name (string) + Resolved plugin name. Alias for `spec.name`. Added for + lazy.nvim spec drop-in compatibility (LazyPlugin.name). + + *zpack.Plugin.dir* +dir (string) + Plugin directory. Alias for `path`. Added for lazy.nvim + spec drop-in compatibility (LazyPlugin.dir). + + *zpack.Plugin.dependencies* +dependencies (string[]) + Sorted list of resolved dependency names — the plugins + this entry depends on, derived from the resolved spec + tree. Added for lazy.nvim spec drop-in compatibility + (LazyPlugin.dependencies). ============================================================================== 9. EVENTSPEC REFERENCE *zpack-eventspec-reference* diff --git a/docs/spec.md b/docs/spec.md index 287ed42..59cd624 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -25,7 +25,13 @@ 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 + build = string|function(plugin) -- Build step: + | (string|function(plugin))[] -- - ':' runs as an ex-command + | false, -- - other strings run via $SHELL in plugin dir + -- - functions receive the plugin + -- - arrays run each entry in order + -- - `false` opts out (lazy.nvim parity) + deactivate = function(plugin) end, -- Teardown hook for :ZPack reload (lazy.nvim parity) -- Lazy loading triggers (auto-sets lazy=true unless overridden) -- All triggers can also be functions that receive zpack.Plugin and return the respective type @@ -44,14 +50,23 @@ branch = "main", -- Git branch tag = "v1.0.0", -- Git tag commit = "abc123", -- Git commit + -- version = false, -- Opt out of versioning (lazy.nvim escape hatch) -- Plugin metadata name = "my-plugin", -- Custom plugin name (optional, overrides auto-derived name) main = "module.name", -- Explicit main module (auto-detected if not set) module = false, -- Disable module-based lazy loading for this plugin + -- lazy.nvim spec parity flags + pin = true, -- Exclude from :ZPack update bulk runs + optional = true, -- Only install if also referenced elsewhere non-optionally + dev = true, -- Use local checkout under setup({ dev = { path = '~/projects' } }) + virtual = true, -- Meta-plugin: skip vim.pack.add; still walks deps + runs config + 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 = function() return { ... } end, -- Or a function returning a spec list (lazy.nvim parity) } ``` @@ -63,6 +78,10 @@ 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 + name = string, -- Resolved plugin name (alias for spec.name, lazy.nvim parity) + dir = string, -- Plugin directory (alias for path, lazy.nvim parity) + dependencies = string[], -- Sorted list of resolved dependency names (lazy.nvim parity) + main = string?, -- Detected main module name (available after config) } ``` diff --git a/lua/zpack/commands.lua b/lua/zpack/commands.lua index bfdc173..cafa03f 100644 --- a/lua/zpack/commands.lua +++ b/lua/zpack/commands.lua @@ -307,8 +307,149 @@ Sub.delete = { end, } +-- lazy.nvim parity: `:ZPack sync` chains update + clean (LazyVim users +-- type :Lazy sync as the routine reconcile). Install is implicit via the +-- next setup() so sync does not need an install step. +Sub.sync = { + bang = true, + run = function(ctx) + local opts + if ctx.bang then opts = { force = true } end + run_pack_update('', opts, 'Sync update failed') + M.clean_unused() + end, +} + +-- lazy.nvim parity: `:ZPack check` previews pending updates without +-- applying them. vim.pack.update without `force = true` opens the +-- confirmation buffer that shows the same information lazy.nvim's :Lazy +-- check renders, so this is effectively `:ZPack update` minus the bang. +Sub.check = { + takes_arg = true, + run = function(ctx) + run_pack_update(ctx.arg, nil, 'Check failed') + end, + complete = function(arg_lead) + return filter_completions(state.registered_plugin_names, arg_lead) + end, +} + +-- lazy.nvim parity: `:ZPack log ` shows recent git log for a +-- specific plugin in a scratch buffer (matches `:Lazy log `). +Sub.log = { + takes_arg = true, + run = function(ctx) + local plugin_name = ctx.arg + if plugin_name == '' then + util.schedule_notify(('Usage: :%s log '):format(ctx.cmd_name), vim.log.levels.WARN) + return + end + local pack = get_installed_or_notify(plugin_name) + if not pack or not pack.path then return end + + local res = vim.system( + { 'git', '-C', pack.path, 'log', '--oneline', '-n', '40' }, + { text = true } + ):wait() + if res.code ~= 0 then + util.schedule_notify( + ('git log failed for %s: %s'):format(plugin_name, res.stderr or ''), + vim.log.levels.ERROR + ) + return + end + + local lines = vim.split(res.stdout or '', '\n', { plain = true, trimempty = true }) + -- Scratch buffer with git syntax so commit hashes / messages get + -- highlighted the same way the user's other git buffers do. Use + -- nvim_set_option_value so the buffer-option writes go through the + -- API (vim.bo[buf].X = ... linter-trips on `vim` being read-only). + vim.cmd('botright new') + local buf = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + local set_opt = vim.api.nvim_set_option_value + set_opt('buftype', 'nofile', { buf = buf }) + set_opt('bufhidden', 'wipe', { buf = buf }) + set_opt('swapfile', false, { buf = buf }) + set_opt('modifiable', false, { buf = buf }) + set_opt('filetype', 'git', { buf = buf }) + pcall(vim.api.nvim_buf_set_name, buf, ('zpack-log://%s'):format(plugin_name)) + end, + complete = function(arg_lead) + return filter_completions(state.registered_plugin_names, arg_lead) + end, +} + +-- lazy.nvim parity: `:ZPack reload ` runs the plugin's +-- `deactivate` hook (if defined), drops its `package.loaded` modules so +-- next require triggers a fresh load, resets the registry's load_status +-- to 'pending', and re-runs process_spec. Pair with the deactivate hook +-- which is now an accepted spec field. +Sub.reload = { + takes_arg = true, + run = function(ctx) + local plugin_name = ctx.arg + if plugin_name == '' then + util.schedule_notify(('Usage: :%s reload '):format(ctx.cmd_name), vim.log.levels.WARN) + return + end + local pack = get_installed_or_notify(plugin_name) + if not pack then return end + + local registry_entry = state.spec_registry[pack.spec.src] + if not registry_entry or not registry_entry.merged_spec then + util.schedule_notify(('Plugin "%s" not in zpack registry'):format(plugin_name), vim.log.levels.ERROR) + return + end + + local spec = registry_entry.merged_spec + local plugin = registry_entry.plugin + + -- Step 1: deactivate hook (lazy.nvim LazyPluginHooks.deactivate). A + -- throw here surfaces as a notify; reload still proceeds so a broken + -- deactivate can't strand the plugin in a half-unloaded state. + if type(spec.deactivate) == 'function' then + local ok, err = pcall(spec.deactivate, plugin) + if not ok then + util.schedule_notify( + ('Failed to run deactivate hook for %s: %s'):format(plugin_name, tostring(err)), + vim.log.levels.WARN + ) + end + end + + -- Step 2: drop package.loaded entries for the plugin's main module + -- and submodules so the next require re-evaluates from disk. Resolved + -- main may be nil for plugins without a Lua module (rare but legal). + local main = require('zpack.utils').resolve_main(plugin, spec) + if main and main ~= '' then + local prefix = main .. '.' + for key in pairs(package.loaded) do + if key == main or (type(key) == 'string' and key:sub(1, #prefix) == prefix) then + package.loaded[key] = nil + end + end + end + + -- Step 3: reset load_status so process_spec runs the full lifecycle + -- (packadd, deps, config) again. We re-fetch the pack_spec from the + -- registry rather than reusing `pack.spec` because pack's spec is the + -- minimal vim.pack form, and process_spec keys on src_to_pack_spec. + registry_entry.load_status = 'pending' + state.unloaded_plugin_names[plugin_name] = true + local pack_spec = state.src_to_pack_spec[pack.spec.src] or pack.spec + require('zpack.plugin_loader').try_process_spec(pack_spec, {}) + if registry_entry.load_status == 'loaded' then + util.schedule_notify(('Reloaded %s'):format(plugin_name), vim.log.levels.INFO) + end + end, + complete = function(arg_lead) + return filter_completions(state.registered_plugin_names, arg_lead) + end, +} + -- Ordered list used for completion and usage messages. -local SUB_ORDER = { 'update', 'restore', 'clean', 'build', 'load', 'delete' } +local SUB_ORDER = { 'update', 'restore', 'clean', 'build', 'load', 'delete', 'sync', 'check', 'log', 'reload' } -- Guard against SUB_ORDER drifting out of sync with the Sub table. do diff --git a/lua/zpack/merge.lua b/lua/zpack/merge.lua index e1bffd0..58583c0 100644 --- a/lua/zpack/merge.lua +++ b/lua/zpack/merge.lua @@ -469,10 +469,19 @@ function M.resolve_all() local pack_spec = { src = src, version = utils.normalize_version(entry.merged_spec), - name = entry.merged_spec.name, + name = entry.merged_spec.name or utils.derive_name_from_src(src), } - table.insert(vim_packs, pack_spec) state.src_to_pack_spec[src] = pack_spec + -- lazy.nvim spec parity (`virtual = true`): meta-plugins are not + -- installed and not added to the rtp. Skip vim.pack.add for them but + -- keep the registry entry so dependencies still install and the + -- spec's config/init/opts can still run via the normal startup path + -- (registration.lua synthesizes a fake plugin object for virtuals). + if entry.merged_spec.virtual == true then + entry.is_virtual = true + else + table.insert(vim_packs, pack_spec) + end end end diff --git a/lua/zpack/plugin_loader.lua b/lua/zpack/plugin_loader.lua index 4417141..57872b2 100644 --- a/lua/zpack/plugin_loader.lua +++ b/lua/zpack/plugin_loader.lua @@ -114,11 +114,17 @@ M.process_spec = function(pack_spec, opts) -- this pcall — once run_config has succeeded, a key-spec throw must not -- roll back into a retry that double-runs run_config. local ok, err = pcall(function() - vim.cmd.packadd({ name, bang = opts.bang }) - - -- :packadd sources plugin/ but never after/plugin/. Source them explicitly. - if not opts.bang and plugin.path then - utils.source_after_plugin_files(plugin.path) + -- lazy.nvim spec parity (`virtual = true`): virtual plugins are not + -- installed and not added to the rtp. Skip packadd; their config / + -- dependencies still flow through this path so the meta-plugin's + -- group-of-deps role still works. + if not registry_entry.is_virtual then + vim.cmd.packadd({ name, bang = opts.bang }) + + -- :packadd sources plugin/ but never after/plugin/. Source them explicitly. + if not opts.bang and plugin.path then + utils.source_after_plugin_files(plugin.path) + end end local deps = state.dependency_graph[pack_spec.src] diff --git a/lua/zpack/registration.lua b/lua/zpack/registration.lua index 5b1a0fe..b2e0928 100644 --- a/lua/zpack/registration.lua +++ b/lua/zpack/registration.lua @@ -93,6 +93,61 @@ M.register_all = function(ctx) error(err) end + -- lazy.nvim spec parity (`virtual = true`): vim.pack.add does not see + -- these specs, so its load callback never fires. Synthesize a plugin + -- object so the same startup/lazy machinery still walks them — config + -- and init still run, dependencies still resolve, but packadd is + -- skipped in plugin_loader.process_spec. + for src, entry in pairs(state.spec_registry) do + if entry.is_virtual and not entry.plugin then + local pack_spec = state.src_to_pack_spec[src] + local plugin = { + spec = pack_spec, + path = nil, + name = pack_spec.name, + dir = nil, + } + entry.plugin = plugin + local spec = entry.merged_spec --[[@as zpack.Spec]] + + -- Mirror the load-callback bookkeeping the non-virtual branch above + -- already did: dependencies field, is_lazy_resolved, cond_result, + -- registered_plugin_names, etc. + local dep_set = state.dependency_graph[src] + if dep_set then + local dep_names = {} + for dep_src in pairs(dep_set) do + local dep_entry = state.spec_registry[dep_src] + local dep_name = (dep_entry and dep_entry.merged_spec and dep_entry.merged_spec.name) + or utils.derive_name_from_src(dep_src) + table.insert(dep_names, dep_name) + end + table.sort(dep_names) + plugin.dependencies = dep_names + else + plugin.dependencies = {} + end + + entry.is_lazy_resolved = lazy.is_lazy(spec, plugin, src) + entry.cond_result = utils.check_cond(spec, plugin, ctx.defaults.cond, src) + if entry.cond_result then + table.insert(state.registered_plugin_names, pack_spec.name) + state.unloaded_plugin_names[pack_spec.name] = true + if spec.build then + table.insert(state.plugin_names_with_build, pack_spec.name) + end + if spec.init then + table.insert(ctx.src_with_init, src) + end + if entry.is_lazy_resolved then + table.insert(ctx.registered_lazy_packs, pack_spec) + else + table.insert(ctx.registered_startup_packs, pack_spec) + end + end + end + end + table.sort(ctx.registered_startup_packs, utils.compare_priority) table.sort(ctx.registered_lazy_packs, utils.compare_priority) table.sort(state.registered_plugin_names, function(a, b) return a:lower() < b:lower() end) diff --git a/lua/zpack/startup.lua b/lua/zpack/startup.lua index a5e9ca5..398ccdd 100644 --- a/lua/zpack/startup.lua +++ b/lua/zpack/startup.lua @@ -82,16 +82,22 @@ M.process_all = function(ctx) -- pcall packadd per plugin so one broken plugin doesn't strand every -- later one. Track failures so later loops (run_config, apply_keys, -- finalization) skip them — otherwise the failed plugin would be marked - -- loaded and hidden from :ZPack load / :checkhealth. + -- loaded and hidden from :ZPack load / :checkhealth. lazy.nvim spec + -- parity: `virtual = true` plugins are not installed and not added to + -- the rtp, so packadd is skipped for them (they still flow through the + -- run_config / apply_keys / finalize loops below). local failed_packs = {} for _, pack_spec in ipairs(sorted_packs) do - local ok, err = pcall(vim.cmd.packadd, { pack_spec.name, bang = not ctx.load }) - if not ok then - failed_packs[pack_spec.src] = true - util.schedule_notify(("Failed to packadd %s: %s"):format(pack_spec.name or pack_spec.src, tostring(err)), vim.log.levels.ERROR) - elseif ctx.load then - local entry = state.spec_registry[pack_spec.src] - if entry and entry.plugin and entry.plugin.path then + local entry = state.spec_registry[pack_spec.src] + -- lazy.nvim spec parity (`virtual = true`): skip packadd; the entry's + -- plugin object was synthesized in registration.lua and its config + -- still runs via the run_config loop below. + if not (entry and entry.is_virtual) then + local ok, err = pcall(vim.cmd.packadd, { pack_spec.name, bang = not ctx.load }) + if not ok then + failed_packs[pack_spec.src] = true + util.schedule_notify(("Failed to packadd %s: %s"):format(pack_spec.name or pack_spec.src, tostring(err)), vim.log.levels.ERROR) + elseif ctx.load and entry and entry.plugin and entry.plugin.path then util.source_after_plugin_files(entry.plugin.path) end end diff --git a/lua/zpack/types.lua b/lua/zpack/types.lua index 03a01c2..8b918fe 100644 --- a/lua/zpack/types.lua +++ b/lua/zpack/types.lua @@ -108,5 +108,6 @@ ---@field enabled_result? boolean ---@field cond_result? boolean ---@field is_lazy_resolved? boolean +---@field is_virtual? boolean Internal: marked by merge.resolve_all when the spec sets `virtual = true`; skips vim.pack.add and packadd return {} diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua index e434dc8..c324da3 100644 --- a/lua/zpack/utils.lua +++ b/lua/zpack/utils.lua @@ -380,6 +380,14 @@ M.resolve_main = function(plugin, spec) end local norm_name = M.normalize_name(name) + -- lazy.nvim spec parity (`virtual = true`): virtual plugins have no + -- installed location (plugin.path is nil), so module-directory walking + -- is impossible. Cache as not-found and bail; a virtual plugin that + -- wants auto-setup must declare `main` explicitly. + if not plugin.path or plugin.path == '' then + state.resolve_main_not_found[cache_key] = true + return nil + end local lua_dir = plugin.path .. "/lua" for _, dir_entry in ipairs(M.lsdir(lua_dir)) do diff --git a/tests/dependencies_test.lua b/tests/dependencies_test.lua index a813e19..deb9113 100644 --- a/tests/dependencies_test.lua +++ b/tests/dependencies_test.lua @@ -22,6 +22,30 @@ describe("Dependencies Field", function() assert.is_not_nil(state.spec_registry['https://github.com/test/dep']) end) + -- Regression for zpack_nvim-j5l: lazy.nvim accepts `dependencies` as a + -- bare string ('user/repo'), auto-deriving the GitHub URL. zpack's + -- normalize_dependencies must wrap the bare string the same way it + -- wraps a single-element array so a direct copy-paste from a lazy.nvim + -- spec resolves to https://github.com/user/repo. + it("bare string dependency (lazy.nvim shorthand) is registered", function() + require('zpack').setup({ + spec = { + { + 'test/parent', + dependencies = 'test/bare-string-dep', + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local state = require('zpack.state') + + assert.is_not_nil(state.spec_registry['https://github.com/test/parent']) + assert.is_not_nil(state.spec_registry['https://github.com/test/bare-string-dep'], + "Bare-string dependencies must resolve via the [1]/github auto-derivation") + end) + it("array of string dependencies are registered", function() require('zpack').setup({ spec = { diff --git a/tests/lazy_parity_test.lua b/tests/lazy_parity_test.lua new file mode 100644 index 0000000..326fcb8 --- /dev/null +++ b/tests/lazy_parity_test.lua @@ -0,0 +1,353 @@ +-- Cross-cutting regression tests for lazy.nvim spec parity work. Each +-- describe block pins one parity-gap bead from the closure series; failures +-- here mean a parity gap has re-opened, not that the broader feature is +-- broken in any deeper way. + +local helpers = require('helpers') + +describe("version = false (zpack_nvim-9tm)", function() + before_each(helpers.setup_test_env) + after_each(helpers.cleanup_test_env) + + it("normalize_version returns nil for version = false", function() + local utils = require('zpack.utils') + assert.is_nil(utils.normalize_version({ version = false })) + end) + + it("version = false skips emitting a version on the vim.pack spec", function() + require('zpack').setup({ + spec = { { 'test/v', version = false, branch = 'main' } }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + + local found + for _, call in ipairs(_G.test_state.vim_pack_calls) do + for _, pack_spec in ipairs(call) do + if pack_spec.src == 'https://github.com/test/v' then + found = pack_spec + end + end + end + assert.is_not_nil(found, "plugin must register with vim.pack") + assert.is_nil(found.version, + "version = false must drop the version even when branch is set") + end) + + it("validate_spec accepts version = false", function() + local validate = require('zpack.validate') + local errs = validate.validate_spec({ 'a/b', version = false }) + assert.are.equal(0, #errs, + "version = false must pass validation; got: " .. table.concat(errs, '; ')) + end) +end) + +describe("Plugin shape: name/dir/dependencies (zpack_nvim-clj)", function() + before_each(helpers.setup_test_env) + after_each(helpers.cleanup_test_env) + + it("plugin.name/dir/dependencies are populated in callbacks", function() + local captured + require('zpack').setup({ + spec = { + { 'test/A' }, + { + 'test/B', + dependencies = { 'test/A' }, + config = function(plugin) + captured = { + name = plugin.name, + dir = plugin.dir, + dependencies = plugin.dependencies, + } + end, + }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + + assert.is_not_nil(captured, "config should have been called for test/B") + assert.are.equal('B', captured.name, "plugin.name must alias spec.name") + assert.is_string(captured.dir, "plugin.dir must alias plugin.path") + assert.is_table(captured.dependencies) + assert.contains(captured.dependencies, 'A') + end) +end) + +describe("nested specs field (zpack_nvim-74a)", function() + before_each(helpers.setup_test_env) + after_each(helpers.cleanup_test_env) + + it("specs are walked as peer plugins, not dependencies", function() + require('zpack').setup({ + spec = { + { + 'test/parent', + specs = { + { 'test/companion' }, + }, + }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + + local state = require('zpack.state') + assert.is_not_nil(state.spec_registry['https://github.com/test/parent']) + assert.is_not_nil(state.spec_registry['https://github.com/test/companion']) + + -- specs entries must NOT be marked as dependencies of the parent + local companion = state.spec_registry['https://github.com/test/companion'] + local is_dep = companion.specs[1]._is_dependency + assert.is_falsy(is_dep, "Nested specs are peers, not dependencies") + end) +end) + +describe("pin = true (zpack_nvim-gi5)", function() + before_each(helpers.setup_test_env) + after_each(helpers.cleanup_test_env) + + it("bulk :ZPack update excludes pinned plugins from the explicit names list", function() + require('zpack').setup({ + spec = { + { 'test/free' }, + { 'test/pinned', pin = true }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + + _G.test_state.vim_pack_update_calls = {} + vim.cmd('ZPack update') + + assert.are.equal(1, #_G.test_state.vim_pack_update_calls) + local call = _G.test_state.vim_pack_update_calls[1] + assert.is_not_nil(call.names, "Pin filter must pass an explicit names list, not nil") + assert.contains(call.names, 'free') + local saw_pinned = false + for _, n in ipairs(call.names) do + if n == 'pinned' then saw_pinned = true end + end + assert.is_false(saw_pinned, "Pinned plugin must NOT appear in the update list") + end) +end) + +describe("optional = true (zpack_nvim-sg0)", function() + before_each(helpers.setup_test_env) + after_each(helpers.cleanup_test_env) + + it("optional-only plugins are dropped from registration", function() + require('zpack').setup({ + spec = { + { 'test/orphan', optional = true }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + local state = require('zpack.state') + assert.is_nil(state.spec_registry['https://github.com/test/orphan'], + "An optional-only plugin must be pruned") + end) + + it("optional plugin survives when also referenced as a required dependency", function() + require('zpack').setup({ + spec = { + { 'test/parent', dependencies = { 'test/shared' } }, + { 'test/shared', optional = true, opts = { from_optional = true } }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + local state = require('zpack.state') + assert.is_not_nil(state.spec_registry['https://github.com/test/shared'], + "Optional + dep-referent must survive") + end) +end) + +describe("import = function() (zpack_nvim-fqs)", function() + before_each(helpers.setup_test_env) + after_each(helpers.cleanup_test_env) + + it("function-form import returns specs that get registered", function() + require('zpack').setup({ + spec = { + { import = function() + return { { 'test/dyn-a' }, { 'test/dyn-b' } } + end }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + local state = require('zpack.state') + assert.is_not_nil(state.spec_registry['https://github.com/test/dyn-a']) + assert.is_not_nil(state.spec_registry['https://github.com/test/dyn-b']) + end) + + it("throwing import function surfaces a structured notify", function() + _G.test_state.notifications = {} + require('zpack').setup({ + spec = { + { import = function() error('simulated import failure', 0) end }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + local saw = false + for _, n in ipairs(_G.test_state.notifications) do + if n.msg:find('import function threw') then saw = true end + end + assert.is_true(saw, "import-function throw must surface a structured notify") + end) +end) + +describe(":ZPack sync (zpack_nvim-0sp)", function() + before_each(helpers.setup_test_env) + after_each(helpers.cleanup_test_env) + + it("sync invokes vim.pack.update and clean_unused", function() + require('zpack').setup({ + spec = { { 'test/p' } }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + + _G.test_state.vim_pack_update_calls = {} + _G.test_state.vim_pack_del_calls = {} + + -- Install an unrelated plugin via mocked vim.pack so clean_unused + -- has something to remove. + _G.test_state.registered_pack_specs['stray'] = { + src = 'https://github.com/stray/stray', + name = 'stray', + } + + vim.cmd('ZPack sync') + helpers.flush_pending() + assert.are.equal(1, #_G.test_state.vim_pack_update_calls, "sync must update") + assert.is_true(#_G.test_state.vim_pack_del_calls >= 1, + "sync must clean unused plugins") + end) +end) + +describe(":ZPack check (zpack_nvim-xrx)", function() + before_each(helpers.setup_test_env) + after_each(helpers.cleanup_test_env) + + it("check delegates to vim.pack.update without force", function() + require('zpack').setup({ + spec = { { 'test/p' } }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + _G.test_state.vim_pack_update_calls = {} + vim.cmd('ZPack check') + assert.are.equal(1, #_G.test_state.vim_pack_update_calls) + local opts = _G.test_state.vim_pack_update_calls[1].opts + -- Either nil (no opts) or { force = false } / unset. Must NOT be true. + local force = opts and opts.force or false + assert.is_false(force, "check must NOT force-apply") + end) +end) + +describe("virtual = true (zpack_nvim-fqt)", function() + before_each(helpers.setup_test_env) + after_each(helpers.cleanup_test_env) + + it("virtual plugins are NOT registered with vim.pack but still run config", function() + local config_ran = false + require('zpack').setup({ + spec = { + { + 'meta/virtual-plugin', + virtual = true, + config = function() config_ran = true end, + }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + + -- The virtual plugin must NOT be in any vim.pack.add call. + local seen_in_pack_add = false + for _, call in ipairs(_G.test_state.vim_pack_calls) do + for _, pack_spec in ipairs(call) do + if pack_spec.src == 'https://github.com/meta/virtual-plugin' then + seen_in_pack_add = true + end + end + end + assert.is_false(seen_in_pack_add, + "virtual = true must skip vim.pack.add registration") + + -- But its config function must still run at startup. + assert.is_true(config_ran, + "virtual plugin config must still run (the meta-plugin's purpose)") + end) + + it("dependencies of virtual plugins are still installed", function() + require('zpack').setup({ + spec = { + { + 'meta/wrapper', + virtual = true, + dependencies = { 'test/real-dep' }, + }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + + local state = require('zpack.state') + assert.is_not_nil(state.spec_registry['https://github.com/test/real-dep'], + "Dependencies of virtual plugins must still install") + + -- Confirm the dep WAS registered with vim.pack + local seen_dep = false + for _, call in ipairs(_G.test_state.vim_pack_calls) do + for _, pack_spec in ipairs(call) do + if pack_spec.src == 'https://github.com/test/real-dep' then + seen_dep = true + end + end + end + assert.is_true(seen_dep, "Dep of virtual plugin must reach vim.pack.add") + end) +end) + +describe("deactivate hook (zpack_nvim-aht) + :ZPack reload (zpack_nvim-dpl)", function() + before_each(helpers.setup_test_env) + after_each(helpers.cleanup_test_env) + + it("validate accepts deactivate function", function() + local validate = require('zpack.validate') + local errs = validate.validate_spec({ 'a/b', deactivate = function() end }) + assert.are.equal(0, #errs) + end) + + it("reload runs deactivate then re-runs config", function() + local lifecycle = {} + require('zpack').setup({ + spec = { + { + 'test/relo', + lazy = false, + config = function() table.insert(lifecycle, 'config') end, + deactivate = function() table.insert(lifecycle, 'deactivate') end, + }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + -- The startup config call ran once during setup; reset before reload + -- so the assertion below sees only the reload-time lifecycle events. + lifecycle = {} + + vim.cmd('ZPack reload relo') + helpers.flush_pending() + + -- Order: deactivate first (teardown), then config (fresh load). + assert.are.same({ 'deactivate', 'config' }, lifecycle, + ("Reload must call deactivate then config; got: %s"):format(vim.inspect(lifecycle))) + end) +end) From 04637714f0636ba3c1f33edc82c72de767759d02 Mon Sep 17 00:00:00 2001 From: zuqini Date: Mon, 25 May 2026 00:15:23 -0700 Subject: [PATCH 3/8] fix: revert virtual=true (out of scope), harden reload/build/dev edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-review pass on the lazy-parity branch. Cut as out of scope: * `virtual = true` (zpack_nvim-fqt): the only feature in the branch that explicitly bypassed vim.pack.add — special-cased 5 modules and synthesized a fake plugin object with path=nil. Same yardstick that closed lazy-lock.json import: don't sidestep vim.pack's authoritative flow. Same outcome is expressible via an import directory returning a spec list with `dependencies` + a regular `config`. Reload hardening (commands.lua Sub.reload): * Refuse to reload mid-load — flipping `loading` -> `pending` would slip past process_spec's circular-dep guard and double-run config. * Scope `package.loaded` sweep via `package.searchpath` to modules whose on-disk file lives under THIS plugin's lua/ dir — prevents collateral damage to sibling plugins nested under the same namespace (e.g. reloading `telescope` no longer drops telescope-fzf-native's `telescope.extensions.fzf`). Build hook hardening (hooks.lua): * `build = true` now notifies (previously silent no-op since it matched no dispatch branch). * Array `build` steps chain via `on_done` so shell steps don't interleave — `{ 'git submodule update --init', 'make' }` now runs serially. Dev (`dev = true`) hardening (import.lua): * `vim.uv.fs_stat` accepts files; tightened to `stat.type == 'directory'`. * Notify when `dev = true` lacks a source field to derive the local checkout name from. * Drop the dead `state.config` fallback — setup() assigns it before any import_specs call. Misc: * `Sub.delete` uses `pack.spec.name` (canonical) instead of user-typed name for case-insensitive FS safety. * `names_for_bulk_update` skips `zpack.nvim` in the registry loop so the update confirmation buffer doesn't list it twice. * README: clarifies `dev = true` is source rewriting (not file-watch / auto-reload) and `:ZPack reload` is a manual command — both consistent with the README's "no dev mode / change-detection" non-goal. Tests: 470/470 pass (down from 472; the 2 virtual=true tests went with the feature). luacheck clean. lua-language-server unchanged. --- README.md | 5 ++- doc/zpack.txt | 12 +------ docs/spec.md | 1 - lua/zpack/commands.lua | 31 +++++++++++++----- lua/zpack/hooks.lua | 58 ++++++++++++++++++++++++--------- lua/zpack/import.lua | 17 ++++++---- lua/zpack/merge.lua | 11 +------ lua/zpack/plugin_loader.lua | 16 +++------ lua/zpack/registration.lua | 55 ------------------------------- lua/zpack/startup.lua | 22 +++++-------- lua/zpack/types.lua | 2 -- lua/zpack/utils.lua | 8 ----- lua/zpack/validate.lua | 1 - tests/lazy_parity_test.lua | 65 ------------------------------------- 14 files changed, 96 insertions(+), 208 deletions(-) diff --git a/README.md b/README.md index 3719012..621d678 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,10 @@ zpack might be for you if: As a thin layer, zpack does not provide: - UI dashboard for your plugins (see [Extensions](#extensions) for community solutions) -- Advanced profiling, dev mode, change-detection, etc. +- Advanced profiling, file-watch / auto-reload, change-detection, etc. + (`dev = true` rewrites a spec's source to a local checkout under + `setup({ dev = { path } })`; live file-watch is out of scope. `:ZPack + reload {plugin}` is a manual command, not an autocmd-driven reload.) If you're a lazy.nvim user, see [Migrating from lazy.nvim](docs/tips.md#migrating-from-lazynvim) diff --git a/doc/zpack.txt b/doc/zpack.txt index e13c4cc..f1fdf92 100644 --- a/doc/zpack.txt +++ b/doc/zpack.txt @@ -655,8 +655,6 @@ parent's lazy trigger fires. -- non-optionally elsewhere dev = true, -- Use local checkout under -- setup({ dev = { path } }) - virtual = true, -- Meta-plugin: skip vim.pack.add; - -- still walks deps + runs config specs = { ... }, -- Companion plugin specs grouped -- with this one (peers, not deps) @@ -889,13 +887,6 @@ dev (boolean, optional) derived plugin name is appended). Useful for plugin authors iterating locally without changing the spec for ship. - *zpack-Spec.virtual* -virtual (boolean, optional) - lazy.nvim parity. When `true`, this spec is a meta-plugin: - it is NOT installed and NOT added to the rtp, but its - dependencies still install and its config/init/opts still - run. Used to group dependencies under one named entry. - *zpack-Spec.specs* specs (zpack.Spec|zpack.Spec[], optional) lazy.nvim parity. Companion plugin specs grouped with this @@ -937,8 +928,7 @@ spec (vim.pack.Spec) *zpack.Plugin.path* path (string) - Absolute path to the plugin directory. `nil` for plugins - with `virtual = true`. + Absolute path to the plugin directory. *zpack.Plugin.name* name (string) diff --git a/docs/spec.md b/docs/spec.md index 59cd624..6e0b794 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -61,7 +61,6 @@ pin = true, -- Exclude from :ZPack update bulk runs optional = true, -- Only install if also referenced elsewhere non-optionally dev = true, -- Use local checkout under setup({ dev = { path = '~/projects' } }) - virtual = true, -- Meta-plugin: skip vim.pack.add; still walks deps + runs config specs = { { 'companion/plugin' } }, -- Companion plugin specs grouped with this one -- Spec imports diff --git a/lua/zpack/commands.lua b/lua/zpack/commands.lua index cafa03f..dd2f2a2 100644 --- a/lua/zpack/commands.lua +++ b/lua/zpack/commands.lua @@ -52,7 +52,8 @@ local function names_for_bulk_update() if entry.merged_spec and entry.merged_spec.pin ~= true then local name = (entry.plugin and entry.plugin.spec and entry.plugin.spec.name) or entry.merged_spec.name - if name then + -- 'zpack.nvim' is already seeded above. + if name and name ~= 'zpack.nvim' then table.insert(names, name) end end @@ -295,7 +296,8 @@ Sub.delete = { return end - vim.pack.del({ plugin_name }, { force = true }) + -- pack.spec.name is canonical (case-insensitive FS safety). + vim.pack.del({ pack.spec.name }, { force = true }) util.schedule_notify( ('%s deleted. This can result in errors in your current session. Restart Neovim to re-install it or remove it from your spec.') :format(plugin_name), @@ -402,6 +404,15 @@ Sub.reload = { return end + -- Mid-load reload would slip past process_spec's circular-dep guard. + if registry_entry.load_status == 'loading' then + util.schedule_notify( + ('Cannot reload %s: plugin is currently loading'):format(plugin_name), + vim.log.levels.WARN + ) + return + end + local spec = registry_entry.merged_spec local plugin = registry_entry.plugin @@ -418,15 +429,19 @@ Sub.reload = { end end - -- Step 2: drop package.loaded entries for the plugin's main module - -- and submodules so the next require re-evaluates from disk. Resolved - -- main may be nil for plugins without a Lua module (rare but legal). + -- Only drop modules whose on-disk file lives under THIS plugin's lua/ — + -- a prefix-match would also clear sibling plugins nested under the same + -- namespace (e.g. telescope-fzf-native's `telescope.extensions.fzf`). local main = require('zpack.utils').resolve_main(plugin, spec) - if main and main ~= '' then + local lua_dir = plugin.path and (plugin.path .. '/lua') or nil + if main and main ~= '' and lua_dir then local prefix = main .. '.' for key in pairs(package.loaded) do - if key == main or (type(key) == 'string' and key:sub(1, #prefix) == prefix) then - package.loaded[key] = nil + if type(key) == 'string' and (key == main or key:sub(1, #prefix) == prefix) then + local file = package.searchpath(key, package.path) + if file and file:sub(1, #lua_dir) == lua_dir then + package.loaded[key] = nil + end end end end diff --git a/lua/zpack/hooks.lua b/lua/zpack/hooks.lua index f02588a..4e9739f 100644 --- a/lua/zpack/hooks.lua +++ b/lua/zpack/hooks.lua @@ -32,13 +32,15 @@ M.try_call_hook = function(src, hook_name) end ---Dispatch a single string build step. Strings starting with ':' run via ----`vim.cmd` (ex-command); otherwise they run as shell commands spawned ----asynchronously inside the plugin directory. Mirrors lazy.nvim's ----manage/task/plugin.lua build dispatch. +---`vim.cmd`; otherwise they spawn as shell commands inside the plugin +---directory. Mirrors lazy.nvim's manage/task/plugin.lua dispatch. +---`on_done` fires when the step finishes (success or failure) so array +---steps can chain serially. ---@param build string ---@param plugin zpack.Plugin? ---@param notify_failure fun(err: any) -local function execute_build_string(build, plugin, notify_failure) +---@param on_done fun() +local function execute_build_string(build, plugin, notify_failure, on_done) if build:sub(1, 1) == ':' then local ex_cmd = build:sub(2) vim.schedule(function() @@ -46,6 +48,7 @@ local function execute_build_string(build, plugin, notify_failure) if not ok then notify_failure(err) end + on_done() end) return end @@ -53,13 +56,14 @@ local function execute_build_string(build, plugin, notify_failure) local cwd = plugin and plugin.path if not cwd or cwd == '' then notify_failure("shell build requires a known plugin path") + on_done() return end vim.schedule(function() - -- vim.system spawns asynchronously; failures surface in the on_exit - -- callback (also scheduled, since vim.system's callback runs off the - -- main loop). Matches lazy.nvim's B.shell which spawns via task:spawn. + -- vim.system spawns asynchronously; on_exit fires off the main loop, so + -- the on_done callback chains to vim.schedule too. Matches lazy.nvim's + -- B.shell which spawns via task:spawn. local shell = vim.env.SHELL or vim.o.shell local shell_flag = (type(shell) == 'string' and shell:find('cmd.exe', 1, true)) and '/c' or '-c' local ok, sys_err = pcall(vim.system, { shell, shell_flag, build }, { cwd = cwd, text = true }, function(res) @@ -69,19 +73,25 @@ local function execute_build_string(build, plugin, notify_failure) or '' vim.schedule(function() notify_failure(("shell command exited %d: %s"):format(res.code, detail)) + on_done() end) + else + vim.schedule(on_done) end end) if not ok then notify_failure(sys_err) + on_done() end end) end ----@param build false|string|table|fun(plugin: zpack.Plugin?) +---@param build false|string|table|boolean|fun(plugin: zpack.Plugin?) ---@param plugin zpack.Plugin? ---@param src string Plugin identifier for the failure notify -M.execute_build = function(build, plugin, src) +---@param on_done? fun() Optional callback fired when build completes +M.execute_build = function(build, plugin, src, on_done) + on_done = on_done or function() end local function notify_failure(err) util.schedule_notify(("Failed to run build for %s: %s"):format(src, tostring(err)), vim.log.levels.ERROR) end @@ -89,25 +99,43 @@ M.execute_build = function(build, plugin, src) -- Lazy.nvim spec parity: `build = false` opts out of build for this plugin -- even when a default builder would otherwise apply. if build == false or build == nil then + on_done() + return + end + + -- Validator accepts boolean for explicit `false` opt-out; surface `true` + -- so it doesn't silently fall through with no matching branch. + if build == true then + notify_failure("build = true is not a supported value (use string, function, or table)") + on_done() return end if type(build) == "table" then - -- Array form: each entry is a string or function build step (mixed - -- types are allowed); steps run in declared order. Recurses into the - -- same dispatch so each step gets the ':' vs shell decision. - for _, step in ipairs(build) do - M.execute_build(step, plugin, src) + -- Chain via on_done so shell steps don't interleave (e.g. + -- `{ 'git submodule update --init', 'make' }` must run serially). + local i = 0 + local function run_next() + i = i + 1 + if i > #build then + on_done() + return + end + M.execute_build(build[i], plugin, src, run_next) end + run_next() elseif type(build) == "string" then - execute_build_string(build, plugin, notify_failure) + execute_build_string(build, plugin, notify_failure, on_done) elseif type(build) == "function" then vim.schedule(function() local ok, err = pcall(build, plugin) if not ok then notify_failure(err) end + on_done() end) + else + on_done() end end diff --git a/lua/zpack/import.lua b/lua/zpack/import.lua index 091e950..eb10300 100644 --- a/lua/zpack/import.lua +++ b/lua/zpack/import.lua @@ -16,10 +16,14 @@ local function resolve_dev_path(spec) if spec.dev ~= true then return nil end - local dev_config = (state.config and state.config.dev) or { path = '~/projects' } - local dev_base = vim.fn.expand(dev_config.path or '~/projects') + local dev_config = state.config.dev + local dev_base = vim.fn.expand(dev_config.path) local source_for_name = spec[1] or spec.src or spec.url or spec.dir if type(source_for_name) ~= 'string' then + require('zpack.utils').schedule_notify( + 'dev = true requires a source field ([1]/src/url/dir) to derive the local checkout name', + vim.log.levels.ERROR + ) return nil end local derived = require('zpack.utils').derive_name_from_src(source_for_name) @@ -27,12 +31,13 @@ local function resolve_dev_path(spec) return nil end local dev_path = dev_base .. '/' .. derived - if vim.uv.fs_stat(dev_path) then + local stat = vim.uv.fs_stat(dev_path) + if stat and stat.type == 'directory' then return dev_path end - -- Missing local checkout: `fallback = true` lets the caller try the - -- remote source; otherwise we still return the dev path so vim.pack's - -- error message points the user at the missing local checkout. + -- Missing or non-directory local checkout: `fallback = true` lets the + -- caller try the regular source; otherwise we still return the dev path + -- so vim.pack's error message points the user at the bad local checkout. if dev_config.fallback then return nil end diff --git a/lua/zpack/merge.lua b/lua/zpack/merge.lua index 58583c0..9649ea9 100644 --- a/lua/zpack/merge.lua +++ b/lua/zpack/merge.lua @@ -471,17 +471,8 @@ function M.resolve_all() version = utils.normalize_version(entry.merged_spec), name = entry.merged_spec.name or utils.derive_name_from_src(src), } + table.insert(vim_packs, pack_spec) state.src_to_pack_spec[src] = pack_spec - -- lazy.nvim spec parity (`virtual = true`): meta-plugins are not - -- installed and not added to the rtp. Skip vim.pack.add for them but - -- keep the registry entry so dependencies still install and the - -- spec's config/init/opts can still run via the normal startup path - -- (registration.lua synthesizes a fake plugin object for virtuals). - if entry.merged_spec.virtual == true then - entry.is_virtual = true - else - table.insert(vim_packs, pack_spec) - end end end diff --git a/lua/zpack/plugin_loader.lua b/lua/zpack/plugin_loader.lua index 57872b2..4417141 100644 --- a/lua/zpack/plugin_loader.lua +++ b/lua/zpack/plugin_loader.lua @@ -114,17 +114,11 @@ M.process_spec = function(pack_spec, opts) -- this pcall — once run_config has succeeded, a key-spec throw must not -- roll back into a retry that double-runs run_config. local ok, err = pcall(function() - -- lazy.nvim spec parity (`virtual = true`): virtual plugins are not - -- installed and not added to the rtp. Skip packadd; their config / - -- dependencies still flow through this path so the meta-plugin's - -- group-of-deps role still works. - if not registry_entry.is_virtual then - vim.cmd.packadd({ name, bang = opts.bang }) - - -- :packadd sources plugin/ but never after/plugin/. Source them explicitly. - if not opts.bang and plugin.path then - utils.source_after_plugin_files(plugin.path) - end + vim.cmd.packadd({ name, bang = opts.bang }) + + -- :packadd sources plugin/ but never after/plugin/. Source them explicitly. + if not opts.bang and plugin.path then + utils.source_after_plugin_files(plugin.path) end local deps = state.dependency_graph[pack_spec.src] diff --git a/lua/zpack/registration.lua b/lua/zpack/registration.lua index b2e0928..5b1a0fe 100644 --- a/lua/zpack/registration.lua +++ b/lua/zpack/registration.lua @@ -93,61 +93,6 @@ M.register_all = function(ctx) error(err) end - -- lazy.nvim spec parity (`virtual = true`): vim.pack.add does not see - -- these specs, so its load callback never fires. Synthesize a plugin - -- object so the same startup/lazy machinery still walks them — config - -- and init still run, dependencies still resolve, but packadd is - -- skipped in plugin_loader.process_spec. - for src, entry in pairs(state.spec_registry) do - if entry.is_virtual and not entry.plugin then - local pack_spec = state.src_to_pack_spec[src] - local plugin = { - spec = pack_spec, - path = nil, - name = pack_spec.name, - dir = nil, - } - entry.plugin = plugin - local spec = entry.merged_spec --[[@as zpack.Spec]] - - -- Mirror the load-callback bookkeeping the non-virtual branch above - -- already did: dependencies field, is_lazy_resolved, cond_result, - -- registered_plugin_names, etc. - local dep_set = state.dependency_graph[src] - if dep_set then - local dep_names = {} - for dep_src in pairs(dep_set) do - local dep_entry = state.spec_registry[dep_src] - local dep_name = (dep_entry and dep_entry.merged_spec and dep_entry.merged_spec.name) - or utils.derive_name_from_src(dep_src) - table.insert(dep_names, dep_name) - end - table.sort(dep_names) - plugin.dependencies = dep_names - else - plugin.dependencies = {} - end - - entry.is_lazy_resolved = lazy.is_lazy(spec, plugin, src) - entry.cond_result = utils.check_cond(spec, plugin, ctx.defaults.cond, src) - if entry.cond_result then - table.insert(state.registered_plugin_names, pack_spec.name) - state.unloaded_plugin_names[pack_spec.name] = true - if spec.build then - table.insert(state.plugin_names_with_build, pack_spec.name) - end - if spec.init then - table.insert(ctx.src_with_init, src) - end - if entry.is_lazy_resolved then - table.insert(ctx.registered_lazy_packs, pack_spec) - else - table.insert(ctx.registered_startup_packs, pack_spec) - end - end - end - end - table.sort(ctx.registered_startup_packs, utils.compare_priority) table.sort(ctx.registered_lazy_packs, utils.compare_priority) table.sort(state.registered_plugin_names, function(a, b) return a:lower() < b:lower() end) diff --git a/lua/zpack/startup.lua b/lua/zpack/startup.lua index 398ccdd..a5e9ca5 100644 --- a/lua/zpack/startup.lua +++ b/lua/zpack/startup.lua @@ -82,22 +82,16 @@ M.process_all = function(ctx) -- pcall packadd per plugin so one broken plugin doesn't strand every -- later one. Track failures so later loops (run_config, apply_keys, -- finalization) skip them — otherwise the failed plugin would be marked - -- loaded and hidden from :ZPack load / :checkhealth. lazy.nvim spec - -- parity: `virtual = true` plugins are not installed and not added to - -- the rtp, so packadd is skipped for them (they still flow through the - -- run_config / apply_keys / finalize loops below). + -- loaded and hidden from :ZPack load / :checkhealth. local failed_packs = {} for _, pack_spec in ipairs(sorted_packs) do - local entry = state.spec_registry[pack_spec.src] - -- lazy.nvim spec parity (`virtual = true`): skip packadd; the entry's - -- plugin object was synthesized in registration.lua and its config - -- still runs via the run_config loop below. - if not (entry and entry.is_virtual) then - local ok, err = pcall(vim.cmd.packadd, { pack_spec.name, bang = not ctx.load }) - if not ok then - failed_packs[pack_spec.src] = true - util.schedule_notify(("Failed to packadd %s: %s"):format(pack_spec.name or pack_spec.src, tostring(err)), vim.log.levels.ERROR) - elseif ctx.load and entry and entry.plugin and entry.plugin.path then + local ok, err = pcall(vim.cmd.packadd, { pack_spec.name, bang = not ctx.load }) + if not ok then + failed_packs[pack_spec.src] = true + util.schedule_notify(("Failed to packadd %s: %s"):format(pack_spec.name or pack_spec.src, tostring(err)), vim.log.levels.ERROR) + elseif ctx.load then + local entry = state.spec_registry[pack_spec.src] + if entry and entry.plugin and entry.plugin.path then util.source_after_plugin_files(entry.plugin.path) end end diff --git a/lua/zpack/types.lua b/lua/zpack/types.lua index 8b918fe..cfc6825 100644 --- a/lua/zpack/types.lua +++ b/lua/zpack/types.lua @@ -73,7 +73,6 @@ ---@field pin? boolean Exclude from :ZPack update bulk runs (lazy.nvim parity) ---@field optional? boolean Only install if also referenced non-optionally (lazy.nvim parity) ---@field dev? boolean Use local checkout under `dev.path` (lazy.nvim parity) ----@field virtual? boolean Meta-plugin: skip vim.pack.add; still walks dependencies + runs config (lazy.nvim parity) ---@field deactivate? fun(plugin: zpack.Plugin?) Teardown hook invoked by :ZPack reload (lazy.nvim parity) ---@field import? string|fun():zpack.Spec[] Module path string or function returning specs (lazy.nvim parity) ---@field _import_order? number Internal: Order in which spec was imported @@ -108,6 +107,5 @@ ---@field enabled_result? boolean ---@field cond_result? boolean ---@field is_lazy_resolved? boolean ----@field is_virtual? boolean Internal: marked by merge.resolve_all when the spec sets `virtual = true`; skips vim.pack.add and packadd return {} diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua index c324da3..e434dc8 100644 --- a/lua/zpack/utils.lua +++ b/lua/zpack/utils.lua @@ -380,14 +380,6 @@ M.resolve_main = function(plugin, spec) end local norm_name = M.normalize_name(name) - -- lazy.nvim spec parity (`virtual = true`): virtual plugins have no - -- installed location (plugin.path is nil), so module-directory walking - -- is impossible. Cache as not-found and bail; a virtual plugin that - -- wants auto-setup must declare `main` explicitly. - if not plugin.path or plugin.path == '' then - state.resolve_main_not_found[cache_key] = true - return nil - end local lua_dir = plugin.path .. "/lua" for _, dir_entry in ipairs(M.lsdir(lua_dir)) do diff --git a/lua/zpack/validate.lua b/lua/zpack/validate.lua index 2ec31a9..31a30e7 100644 --- a/lua/zpack/validate.lua +++ b/lua/zpack/validate.lua @@ -114,7 +114,6 @@ local SPEC_FIELD_TYPES = { pin = 'boolean', optional = 'boolean', dev = 'boolean', - virtual = 'boolean', deactivate = 'function', } diff --git a/tests/lazy_parity_test.lua b/tests/lazy_parity_test.lua index 326fcb8..b7be272 100644 --- a/tests/lazy_parity_test.lua +++ b/tests/lazy_parity_test.lua @@ -250,71 +250,6 @@ describe(":ZPack check (zpack_nvim-xrx)", function() end) end) -describe("virtual = true (zpack_nvim-fqt)", function() - before_each(helpers.setup_test_env) - after_each(helpers.cleanup_test_env) - - it("virtual plugins are NOT registered with vim.pack but still run config", function() - local config_ran = false - require('zpack').setup({ - spec = { - { - 'meta/virtual-plugin', - virtual = true, - config = function() config_ran = true end, - }, - }, - defaults = { confirm = false }, - }) - helpers.flush_pending() - - -- The virtual plugin must NOT be in any vim.pack.add call. - local seen_in_pack_add = false - for _, call in ipairs(_G.test_state.vim_pack_calls) do - for _, pack_spec in ipairs(call) do - if pack_spec.src == 'https://github.com/meta/virtual-plugin' then - seen_in_pack_add = true - end - end - end - assert.is_false(seen_in_pack_add, - "virtual = true must skip vim.pack.add registration") - - -- But its config function must still run at startup. - assert.is_true(config_ran, - "virtual plugin config must still run (the meta-plugin's purpose)") - end) - - it("dependencies of virtual plugins are still installed", function() - require('zpack').setup({ - spec = { - { - 'meta/wrapper', - virtual = true, - dependencies = { 'test/real-dep' }, - }, - }, - defaults = { confirm = false }, - }) - helpers.flush_pending() - - local state = require('zpack.state') - assert.is_not_nil(state.spec_registry['https://github.com/test/real-dep'], - "Dependencies of virtual plugins must still install") - - -- Confirm the dep WAS registered with vim.pack - local seen_dep = false - for _, call in ipairs(_G.test_state.vim_pack_calls) do - for _, pack_spec in ipairs(call) do - if pack_spec.src == 'https://github.com/test/real-dep' then - seen_dep = true - end - end - end - assert.is_true(seen_dep, "Dep of virtual plugin must reach vim.pack.add") - end) -end) - describe("deactivate hook (zpack_nvim-aht) + :ZPack reload (zpack_nvim-dpl)", function() before_each(helpers.setup_test_env) after_each(helpers.cleanup_test_env) From 204d6d11b0daa87b2d32340ad4738fc8bc71bd0a Mon Sep 17 00:00:00 2001 From: zuqini Date: Mon, 25 May 2026 10:23:26 -0700 Subject: [PATCH 4/8] fix: cut :ZPack log/check, close review-flagged bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second post-review pass on the lazy-parity branch. Cut as out of scope: * :ZPack log (zpack_nvim-wwl): sidestepped vim.pack (spawned git log directly on plugin.path) AND owned a UI seam (botright new + scratch buffer with filetype=git) — the only :ZPack subcommand that opened a window, in a manager whose README explicitly disclaims "UI dashboard". Same yardstick as the virtual=true and lazy-lock.json import cuts. * :ZPack check (zpack_nvim-xrx): strict subset of :ZPack update — both delegated to run_pack_update(arg, nil, ...) (vim.pack.update without force opens the confirm buffer, which IS the preview). Vocab-only alias; the README/help can note the lazy.nvim mapping for muscle memory. :ZPack reload sweep fix (commands.lua Sub.reload): * The previous sweep used package.searchpath(key, package.path) to scope module clearing to the plugin's lua/ dir, but Neovim's lua loader walks runtimepath rather than augmenting package.path — so searchpath returned nil for every plugin module and the sweep never cleared anything. Switch to a direct vim.uv.fs_stat check on /.lua and /init.lua. Existing test only checked lifecycle order; new regression pins the sweep clears matching keys and leaves siblings + unrelated keys alone. * Guard plugin == nil before resolve_main / lua_dir derivation. :ZPack sync semantics: * Drop bang; sync always force-applies. vim.pack.update returns immediately under no-force (opens confirm buffer), so chaining clean_unused() synchronously would race ahead of the user's confirm/cancel response — cleanup persists even on dismiss. For a preview, use :ZPack update without !. import = function() (import.lua): * Per-setup() visited-function set guards against f = function() return { { import = f } } end self-recursion. Shell-form build (hooks.lua): * Switch from \$SHELL / &shell to a hard-coded sh -c (cmd.exe /c on Windows). The previous form crashed with ENOENT for users whose &shell carried args (e.g. bash --login) since vim.system treats argv[0] as a literal filename. plugin.dependencies (registration.lua): * Skip deps that prune_disabled dropped (e.g. optional = true with no required reference) so callbacks don't see phantom dep names the user can never load. Misc: * Drop dead command.nargs = info.nargs in lazy_trigger/cmd.lua (verified: nvim_cmd ignores the field — the load-bearing read is the info.nargs:find("[1?]") arg re-pack, which stays). * names_for_bulk_update honors a user-spec'd pin = true on zpack.nvim itself (previously seeded unconditionally). * Doc/README: drop log/check entries; update :ZPack sync description; fix doc/zpack.txt non-goals ("no dev mode" was contradicted by the new dev = true field) and migration-dev advice. New tests pinning the fixes: * :ZPack sync force-applies (otherwise clean races confirm). * :ZPack reload clears package.loaded for plugin modules; leaves sibling/unrelated keys alone. * dev = true happy path / fallback / no-source-field notify. Tests: 473/473 pass; luacheck clean; lua-language-server check unchanged (89 vs baseline 90 — dead nargs deletion cleared one warning). --- README.md | 4 +- doc/zpack.txt | 31 ++++---- lua/zpack/commands.lua | 110 ++++++++-------------------- lua/zpack/hooks.lua | 13 ++-- lua/zpack/import.lua | 11 +-- lua/zpack/lazy_trigger/cmd.lua | 8 +-- lua/zpack/registration.lua | 10 ++- tests/lazy_parity_test.lua | 127 +++++++++++++++++++++++++++------ 8 files changed, 173 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index 621d678..8b715bb 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,7 @@ is recommended for ease of use. - `:ZPack[!] load [plugin]` - Load a specific unloaded plugin, or all unloaded plugins with `!` (supports tab completion) - `:ZPack[!] delete [plugin]` - Remove a specific plugin, or all plugins with `!` (supports tab completion) - Deleting active plugins in your spec can result in errors in your current session. Restart Neovim to re-install them. -- `:ZPack[!] sync` - Bulk update + clean in one step. `!` force-applies updates. lazy.nvim parity for `:Lazy sync` -- `:ZPack check [plugin]` - Preview pending updates without applying them. lazy.nvim parity for `:Lazy check` -- `:ZPack log {plugin}` - Show recent git log for a plugin in a scratch buffer. lazy.nvim parity for `:Lazy log` +- `:ZPack sync` - Bulk update + clean in one step (always force-applies; use `:ZPack update` without `!` for a preview). lazy.nvim parity for `:Lazy sync` - `:ZPack reload {plugin}` - Re-source a plugin (runs `deactivate`, clears `package.loaded`, re-runs config). lazy.nvim parity for `:Lazy reload` On Neovim 0.13+, several subcommands map to native `vim.pack` commands you can use interchangeably: diff --git a/doc/zpack.txt b/doc/zpack.txt index f1fdf92..a189356 100644 --- a/doc/zpack.txt +++ b/doc/zpack.txt @@ -148,21 +148,9 @@ command name is configurable via |zpack-setup-cmd_name| — a short name like `:packdel! ++all` — though the native command also removes any installed plugins absent from your spec. -:ZPack[!] sync Bulk update followed by clean. Equivalent to running - `:ZPack update` and then `:ZPack clean`. With `!`, applies - updates immediately (skips the confirmation buffer). lazy.nvim - parity for `:Lazy sync`. - -:ZPack check [plugin] - Preview pending updates without applying them. Opens - |vim.pack.update()|'s confirmation buffer the same way - `:ZPack update` does without the bang — useful as a "what - would update?" check. Supports tab completion of installed - plugin names. lazy.nvim parity for `:Lazy check`. - -:ZPack log {plugin} - Show the last 40 commits for a plugin in a scratch buffer - (git filetype). lazy.nvim parity for `:Lazy log `. +:ZPack sync Bulk update + clean in one step (always force-applies; use + `:ZPack update` without `!` for a preview). lazy.nvim parity + for `:Lazy sync`. :ZPack reload {plugin} Re-source a plugin: runs the plugin's `deactivate` hook (if @@ -340,7 +328,10 @@ zpack might be for you if: As a thin layer, zpack does not provide: - UI dashboard for your plugins (see |zpack-extensions|) -- Advanced profiling, dev mode, change-detection, etc. +- Advanced profiling, file-watch / auto-reload, change-detection, etc. + (`dev = true` rewrites a spec's source to a local checkout under + `setup({ dev = { path } })`; live file-watch is out of scope. `:ZPack + reload {plugin}` is a manual command, not an autocmd-driven reload.) If you're a lazy.nvim user, see |zpack-migrating-lazy|. If something you need isn't achievable natively or through zpack, please submit an issue @@ -1073,8 +1064,12 @@ version pinning lazy.nvim's `version` field maps to zpack's `sem_version`. See |zpack-example-version|. *zpack-migration-dev* -dev mode Use `src = vim.fn.expand('~/projects/my_plugin.nvim')` - for local development. +dev mode Set `dev = true` on a spec and configure + `setup({ dev = { path = '~/projects' } })` — + the source is rewritten to `/`. + See |zpack-Spec.dev| and |zpack-setup-dev|. + Live file-watch / auto-reload is out of scope; + use `:ZPack reload {plugin}` to manually re-source. *zpack-migration-profiling* profiling Use `nvim --startuptime startuptime.log`. diff --git a/lua/zpack/commands.lua b/lua/zpack/commands.lua index dd2f2a2..6e52821 100644 --- a/lua/zpack/commands.lua +++ b/lua/zpack/commands.lua @@ -30,29 +30,35 @@ local is_registered_or_notify = function(plugin_name) return true end ----Collect names of registered plugins that are NOT pinned (`pin = true`). ----zpack.nvim itself is included so a bulk update still keeps the bootstrap ----in sync. Returns nil when no plugin is pinned, so callers can take the ----fast path of letting vim.pack.update default to "update everything". +---Names of registered plugins that are NOT `pin = true`. zpack.nvim is +---seeded unless the user's own spec pins it. Returns nil when nothing is +---pinned so callers can take vim.pack.update's default "everything" path. ---@return string[]? names nil when nothing is pinned local function names_for_bulk_update() local has_pin = false + local zpack_pinned_by_user = false for _, entry in pairs(state.spec_registry) do if entry.merged_spec and entry.merged_spec.pin == true then has_pin = true - break + local name = entry.merged_spec.name + or (entry.plugin and entry.plugin.spec and entry.plugin.spec.name) + if name == 'zpack.nvim' then + zpack_pinned_by_user = true + end end end if not has_pin then return nil end - local names = { 'zpack.nvim' } + local names = {} + if not zpack_pinned_by_user then + table.insert(names, 'zpack.nvim') + end for _, entry in pairs(state.spec_registry) do if entry.merged_spec and entry.merged_spec.pin ~= true then local name = (entry.plugin and entry.plugin.spec and entry.plugin.spec.name) or entry.merged_spec.name - -- 'zpack.nvim' is already seeded above. if name and name ~= 'zpack.nvim' then table.insert(names, name) end @@ -309,79 +315,16 @@ Sub.delete = { end, } --- lazy.nvim parity: `:ZPack sync` chains update + clean (LazyVim users --- type :Lazy sync as the routine reconcile). Install is implicit via the --- next setup() so sync does not need an install step. +-- Always force-applies: vim.pack.update's confirm buffer returns immediately, +-- so a no-force form would race clean_unused ahead of the user's response. +-- For a preview, use `:ZPack update` (no bang) first. Sub.sync = { - bang = true, - run = function(ctx) - local opts - if ctx.bang then opts = { force = true } end - run_pack_update('', opts, 'Sync update failed') + run = function() + run_pack_update('', { force = true }, 'Sync update failed') M.clean_unused() end, } --- lazy.nvim parity: `:ZPack check` previews pending updates without --- applying them. vim.pack.update without `force = true` opens the --- confirmation buffer that shows the same information lazy.nvim's :Lazy --- check renders, so this is effectively `:ZPack update` minus the bang. -Sub.check = { - takes_arg = true, - run = function(ctx) - run_pack_update(ctx.arg, nil, 'Check failed') - end, - complete = function(arg_lead) - return filter_completions(state.registered_plugin_names, arg_lead) - end, -} - --- lazy.nvim parity: `:ZPack log ` shows recent git log for a --- specific plugin in a scratch buffer (matches `:Lazy log `). -Sub.log = { - takes_arg = true, - run = function(ctx) - local plugin_name = ctx.arg - if plugin_name == '' then - util.schedule_notify(('Usage: :%s log '):format(ctx.cmd_name), vim.log.levels.WARN) - return - end - local pack = get_installed_or_notify(plugin_name) - if not pack or not pack.path then return end - - local res = vim.system( - { 'git', '-C', pack.path, 'log', '--oneline', '-n', '40' }, - { text = true } - ):wait() - if res.code ~= 0 then - util.schedule_notify( - ('git log failed for %s: %s'):format(plugin_name, res.stderr or ''), - vim.log.levels.ERROR - ) - return - end - - local lines = vim.split(res.stdout or '', '\n', { plain = true, trimempty = true }) - -- Scratch buffer with git syntax so commit hashes / messages get - -- highlighted the same way the user's other git buffers do. Use - -- nvim_set_option_value so the buffer-option writes go through the - -- API (vim.bo[buf].X = ... linter-trips on `vim` being read-only). - vim.cmd('botright new') - local buf = vim.api.nvim_get_current_buf() - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - local set_opt = vim.api.nvim_set_option_value - set_opt('buftype', 'nofile', { buf = buf }) - set_opt('bufhidden', 'wipe', { buf = buf }) - set_opt('swapfile', false, { buf = buf }) - set_opt('modifiable', false, { buf = buf }) - set_opt('filetype', 'git', { buf = buf }) - pcall(vim.api.nvim_buf_set_name, buf, ('zpack-log://%s'):format(plugin_name)) - end, - complete = function(arg_lead) - return filter_completions(state.registered_plugin_names, arg_lead) - end, -} - -- lazy.nvim parity: `:ZPack reload ` runs the plugin's -- `deactivate` hook (if defined), drops its `package.loaded` modules so -- next require triggers a fresh load, resets the registry's load_status @@ -429,17 +372,20 @@ Sub.reload = { end end - -- Only drop modules whose on-disk file lives under THIS plugin's lua/ — - -- a prefix-match would also clear sibling plugins nested under the same + -- Drop only modules whose file lives under THIS plugin's lua/ — a bare + -- prefix match would clear sibling plugins nested under the same -- namespace (e.g. telescope-fzf-native's `telescope.extensions.fzf`). - local main = require('zpack.utils').resolve_main(plugin, spec) - local lua_dir = plugin.path and (plugin.path .. '/lua') or nil + -- Check fs paths directly; package.searchpath misses plugin modules + -- because Neovim's lua loader walks runtimepath, not package.path. + local lua_dir = plugin and plugin.path and (plugin.path .. '/lua') or nil + local main = plugin and require('zpack.utils').resolve_main(plugin, spec) or nil if main and main ~= '' and lua_dir then local prefix = main .. '.' for key in pairs(package.loaded) do if type(key) == 'string' and (key == main or key:sub(1, #prefix) == prefix) then - local file = package.searchpath(key, package.path) - if file and file:sub(1, #lua_dir) == lua_dir then + local rel = key:gsub('%.', '/') + if vim.uv.fs_stat(lua_dir .. '/' .. rel .. '.lua') + or vim.uv.fs_stat(lua_dir .. '/' .. rel .. '/init.lua') then package.loaded[key] = nil end end @@ -464,7 +410,7 @@ Sub.reload = { } -- Ordered list used for completion and usage messages. -local SUB_ORDER = { 'update', 'restore', 'clean', 'build', 'load', 'delete', 'sync', 'check', 'log', 'reload' } +local SUB_ORDER = { 'update', 'restore', 'clean', 'build', 'load', 'delete', 'sync', 'reload' } -- Guard against SUB_ORDER drifting out of sync with the Sub table. do diff --git a/lua/zpack/hooks.lua b/lua/zpack/hooks.lua index 4e9739f..a480562 100644 --- a/lua/zpack/hooks.lua +++ b/lua/zpack/hooks.lua @@ -61,12 +61,13 @@ local function execute_build_string(build, plugin, notify_failure, on_done) end vim.schedule(function() - -- vim.system spawns asynchronously; on_exit fires off the main loop, so - -- the on_done callback chains to vim.schedule too. Matches lazy.nvim's - -- B.shell which spawns via task:spawn. - local shell = vim.env.SHELL or vim.o.shell - local shell_flag = (type(shell) == 'string' and shell:find('cmd.exe', 1, true)) and '/c' or '-c' - local ok, sys_err = pcall(vim.system, { shell, shell_flag, build }, { cwd = cwd, text = true }, function(res) + -- Hard-code `sh -c` / `cmd.exe /c` rather than `$SHELL` / `&shell`: + -- those commonly carry args (e.g. `bash --login`) which vim.system + -- treats as a literal argv[0] and fails with ENOENT. + local argv = vim.fn.has('win32') == 1 + and { 'cmd.exe', '/c', build } + or { 'sh', '-c', build } + local ok, sys_err = pcall(vim.system, argv, { cwd = cwd, text = true }, function(res) if res.code ~= 0 then local detail = (res.stderr and res.stderr ~= '' and res.stderr) or (res.stdout and res.stdout ~= '' and res.stdout) diff --git a/lua/zpack/import.lua b/lua/zpack/import.lua index eb10300..ffdf461 100644 --- a/lua/zpack/import.lua +++ b/lua/zpack/import.lua @@ -246,10 +246,13 @@ local import_one_spec = function(spec, ctx) if type(spec.import) == 'string' then import_from_module(spec.import --[[@as string]], ctx) else - -- lazy.nvim parity (LazySpecImport.import as function): invoke and - -- treat the return value as a spec (or spec list) to recurse into. - -- A throw surfaces as a structured notify; an empty/non-table return - -- is a no-op rather than an error. + -- Per-setup() visited set guards against + -- `f = function() return { { import = f } } end` self-recursion. + ctx._imported_functions = ctx._imported_functions or {} + if ctx._imported_functions[spec.import] then + return + end + ctx._imported_functions[spec.import] = true local ok, result = pcall(spec.import --[[@as fun(): any]]) if not ok then utils.schedule_notify( diff --git a/lua/zpack/lazy_trigger/cmd.lua b/lua/zpack/lazy_trigger/cmd.lua index e6a877c..2d1b764 100644 --- a/lua/zpack/lazy_trigger/cmd.lua +++ b/lua/zpack/lazy_trigger/cmd.lua @@ -84,11 +84,9 @@ M.setup = function(registered_pack_specs) -- arguments" on first invocation only. local info = vim.api.nvim_get_commands({})[cmd] or vim.api.nvim_buf_get_commands(0, {})[cmd] - if info then - command.nargs = info.nargs - if cmd_args.args and cmd_args.args ~= "" and info.nargs and info.nargs:find("[1?]") then - command.args = { cmd_args.args } - end + if info and cmd_args.args and cmd_args.args ~= "" + and info.nargs and info.nargs:find("[1?]") then + command.args = { cmd_args.args } end local ok, err = pcall(vim.api.nvim_cmd, command, {}) diff --git a/lua/zpack/registration.lua b/lua/zpack/registration.lua index 5b1a0fe..daee734 100644 --- a/lua/zpack/registration.lua +++ b/lua/zpack/registration.lua @@ -35,10 +35,14 @@ M.register_all = function(ctx) if dep_set then local dep_names = {} for dep_src in pairs(dep_set) do + -- Skip deps that prune_disabled dropped (e.g. `optional = true` + -- with no required reference) so the user doesn't see a name + -- they can never load. local dep_entry = state.spec_registry[dep_src] - local dep_name = (dep_entry and dep_entry.merged_spec and dep_entry.merged_spec.name) - or utils.derive_name_from_src(dep_src) - table.insert(dep_names, dep_name) + if dep_entry and dep_entry.merged_spec then + table.insert(dep_names, dep_entry.merged_spec.name + or utils.derive_name_from_src(dep_src)) + end end table.sort(dep_names) plugin.dependencies = dep_names diff --git a/tests/lazy_parity_test.lua b/tests/lazy_parity_test.lua index b7be272..ab631a6 100644 --- a/tests/lazy_parity_test.lua +++ b/tests/lazy_parity_test.lua @@ -225,31 +225,14 @@ describe(":ZPack sync (zpack_nvim-0sp)", function() vim.cmd('ZPack sync') helpers.flush_pending() assert.are.equal(1, #_G.test_state.vim_pack_update_calls, "sync must update") + local opts = _G.test_state.vim_pack_update_calls[1].opts + assert.is_true(opts and opts.force == true, + "sync must force-apply (no-force would race clean ahead of confirm)") assert.is_true(#_G.test_state.vim_pack_del_calls >= 1, "sync must clean unused plugins") end) end) -describe(":ZPack check (zpack_nvim-xrx)", function() - before_each(helpers.setup_test_env) - after_each(helpers.cleanup_test_env) - - it("check delegates to vim.pack.update without force", function() - require('zpack').setup({ - spec = { { 'test/p' } }, - defaults = { confirm = false }, - }) - helpers.flush_pending() - _G.test_state.vim_pack_update_calls = {} - vim.cmd('ZPack check') - assert.are.equal(1, #_G.test_state.vim_pack_update_calls) - local opts = _G.test_state.vim_pack_update_calls[1].opts - -- Either nil (no opts) or { force = false } / unset. Must NOT be true. - local force = opts and opts.force or false - assert.is_false(force, "check must NOT force-apply") - end) -end) - describe("deactivate hook (zpack_nvim-aht) + :ZPack reload (zpack_nvim-dpl)", function() before_each(helpers.setup_test_env) after_each(helpers.cleanup_test_env) @@ -285,4 +268,108 @@ describe("deactivate hook (zpack_nvim-aht) + :ZPack reload (zpack_nvim-dpl)", fu assert.are.same({ 'deactivate', 'config' }, lifecycle, ("Reload must call deactivate then config; got: %s"):format(vim.inspect(lifecycle))) end) + + it("reload clears package.loaded for plugin modules under its lua/", function() + -- Plant a fake plugin on disk so the sweep has a real lua/ tree to fs_stat + -- against. Mock vim.pack.get to point at it so reload resolves a real path. + local tmp = vim.fn.tempname() + local plugin_path = tmp .. '/sweepy' + vim.fn.mkdir(plugin_path .. '/lua/sweepy/sub', 'p') + local f = io.open(plugin_path .. '/lua/sweepy/init.lua', 'w') + f:write('return { x = 1 }'); f:close() + f = io.open(plugin_path .. '/lua/sweepy/sub/inner.lua', 'w') + f:write('return { y = 2 }'); f:close() + + require('zpack').setup({ + spec = { { 'test/sweepy', lazy = false, main = 'sweepy' } }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + + -- Override path from the helpers' default stdpath('data')/... to the + -- tmpdir we just populated, so the sweep's fs_stat actually finds files. + local state = require('zpack.state') + local src = 'https://github.com/test/sweepy' + local pack_spec = _G.test_state.registered_pack_specs.sweepy + state.spec_registry[src].plugin.path = plugin_path + _G.test_state.original_vim_pack_get = vim.pack.get + vim.pack.get = function() return { { spec = pack_spec, path = plugin_path } } end + + package.loaded['sweepy'] = { stale = true } + package.loaded['sweepy.sub.inner'] = { stale = true } + package.loaded['sweepy.absent'] = { stale = true } -- no file on disk + package.loaded['unrelated'] = { stale = true } + + vim.cmd('ZPack reload sweepy') + helpers.flush_pending() + + assert.is_nil(package.loaded['sweepy'], "reload must clear main module") + assert.is_nil(package.loaded['sweepy.sub.inner'], + "reload must clear submodule with on-disk file") + assert.are.same({ stale = true }, package.loaded['sweepy.absent'], + "reload must NOT clear keys with no on-disk file (sibling-plugin safety)") + assert.are.same({ stale = true }, package.loaded['unrelated'], + "reload must NOT touch keys outside the plugin's main namespace") + end) +end) + +describe("dev = true source rewrite (zpack_nvim-lkb)", function() + before_each(helpers.setup_test_env) + after_each(helpers.cleanup_test_env) + + it("dev = true rewrites source to / when dir exists", function() + local dev_root = vim.fn.tempname() + local plugin_dir = dev_root .. '/devplug.nvim' + vim.fn.mkdir(plugin_dir, 'p') + + require('zpack').setup({ + spec = { { 'me/devplug.nvim', dev = true } }, + dev = { path = dev_root }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + + local found + for _, call in ipairs(_G.test_state.vim_pack_calls) do + for _, pack_spec in ipairs(call) do + if pack_spec.src == plugin_dir then found = pack_spec end + end + end + assert.is_not_nil(found, "dev = true must rewrite src to the local dir") + end) + + it("dev.fallback = true falls back to remote when local dir is missing", function() + require('zpack').setup({ + spec = { { 'me/devplug.nvim', dev = true } }, + dev = { path = vim.fn.tempname() .. '/missing', fallback = true }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + + local saw_remote + for _, call in ipairs(_G.test_state.vim_pack_calls) do + for _, pack_spec in ipairs(call) do + if pack_spec.src == 'https://github.com/me/devplug.nvim' then + saw_remote = true + end + end + end + assert.is_true(saw_remote, "fallback = true must use the remote source when local dir is missing") + end) + + it("dev = true with no source field notifies and skips", function() + require('zpack').setup({ + spec = { { dev = true, config = function() end } }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + + local saw + for _, n in ipairs(_G.test_state.notifications) do + if type(n.msg) == 'string' and n.msg:find('dev = true requires a source field', 1, true) then + saw = true + end + end + assert.is_true(saw, "dev=true without a source field must notify") + end) end) From f31f04ecc2421b127944096b467cbf71cd7c10a8 Mon Sep 17 00:00:00 2001 From: zuqini Date: Mon, 25 May 2026 10:58:33 -0700 Subject: [PATCH 5/8] fix: close third-pass review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-pass review on the lazy-parity branch. * Nested `specs` propagated parent's `is_dependency` (import.lua): `dependencies = { { 'A', specs = { 'B' } } }` silently marked B as a dep, contradicting the inline doc-comment and lazy.nvim's peer semantic. Reset `is_dependency = false` on the peer ctx. * `:ZPack reload` keyed unloaded_plugin_names on user-typed name (commands.lua): same class as the Sub.delete canonical-name fix in 0463771 — user-typed name can differ in case on case-insensitive FS, while plugin_loader clears by pack.spec.name. Mirror the fix. * :checkhealth zpack didn't surface dev mode (health.lua): when at least one spec has `dev = true`, emit `dev: N plugin(s) → (fallback: )`. Skipped when no dev plugins so non-users see no extra line. * tests/validate_test.lua merged-config fixture missed the `dev` default that init.lua now populates — added so the test mirrors what :checkhealth actually passes. * tests/lazy_parity_test.lua: new regression "specs nested inside a dependencies chain stay peers, not deps" pinning the import.lua fix above (the existing top-level test doesn't exercise the nested-dep case). Tests: 474/474 pass (was 473; +1 regression). luacheck clean. lua-language-server unchanged at baseline 89. --- lua/zpack/commands.lua | 4 +++- lua/zpack/health.lua | 12 ++++++++++++ lua/zpack/import.lua | 9 ++++----- tests/lazy_parity_test.lua | 27 +++++++++++++++++++++++++++ tests/validate_test.lua | 1 + 5 files changed, 47 insertions(+), 6 deletions(-) diff --git a/lua/zpack/commands.lua b/lua/zpack/commands.lua index 6e52821..fe5c0d6 100644 --- a/lua/zpack/commands.lua +++ b/lua/zpack/commands.lua @@ -397,7 +397,9 @@ Sub.reload = { -- registry rather than reusing `pack.spec` because pack's spec is the -- minimal vim.pack form, and process_spec keys on src_to_pack_spec. registry_entry.load_status = 'pending' - state.unloaded_plugin_names[plugin_name] = true + -- Canonical name: plugin_loader clears by pack.spec.name; user-typed + -- can differ in case on case-insensitive FS and leak a stale entry. + state.unloaded_plugin_names[pack.spec.name] = true local pack_spec = state.src_to_pack_spec[pack.spec.src] or pack.spec require('zpack.plugin_loader').try_process_spec(pack_spec, {}) if registry_entry.load_status == 'loaded' then diff --git a/lua/zpack/health.lua b/lua/zpack/health.lua index 6a1d1f2..f0f4cfe 100644 --- a/lua/zpack/health.lua +++ b/lua/zpack/health.lua @@ -142,6 +142,18 @@ local function check_plugins() if lazy_count > 0 then vim.health.info(('lazy: %d'):format(lazy_count)) end + + local dev_count = 0 + for _, entry in pairs(state.spec_registry) do + if entry.merged_spec and entry.merged_spec.dev then + dev_count = dev_count + 1 + end + end + if dev_count > 0 then + local dev = state.config.dev or {} + vim.health.info(('dev: %d plugin(s) → %s (fallback: %s)'):format( + dev_count, dev.path or '~/projects', tostring(dev.fallback or false))) + end end local function check_bug_report() diff --git a/lua/zpack/import.lua b/lua/zpack/import.lua index ffdf461..737c589 100644 --- a/lua/zpack/import.lua +++ b/lua/zpack/import.lua @@ -292,12 +292,11 @@ local import_one_spec = function(spec, ctx) register_dependencies(spec, src, ctx) end - -- lazy.nvim spec parity: nested `specs` field declares companion plugins - -- grouped with this spec. Unlike `dependencies`, these are peer plugins - -- (not loaded-before-this); they walk through the normal import path with - -- the parent's import-context (NOT marked as `_is_dependency`). + -- Nested `specs` are peer plugins; reset is_dependency so a parent + -- reached via a `dependencies` chain doesn't propagate dep-status down. if spec.specs then - M.import_specs(spec.specs, ctx) + local peer_ctx = vim.tbl_extend('force', ctx, { is_dependency = false }) + M.import_specs(spec.specs, peer_ctx) end end diff --git a/tests/lazy_parity_test.lua b/tests/lazy_parity_test.lua index ab631a6..29d86ec 100644 --- a/tests/lazy_parity_test.lua +++ b/tests/lazy_parity_test.lua @@ -102,6 +102,33 @@ describe("nested specs field (zpack_nvim-74a)", function() local is_dep = companion.specs[1]._is_dependency assert.is_falsy(is_dep, "Nested specs are peers, not dependencies") end) + + it("specs nested inside a dependencies chain stay peers, not deps", function() + require('zpack').setup({ + spec = { + { + 'test/root', + dependencies = { + { + 'test/dep', + specs = { { 'test/sibling' } }, + }, + }, + }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + + local state = require('zpack.state') + local dep = state.spec_registry['https://github.com/test/dep'] + local sibling = state.spec_registry['https://github.com/test/sibling'] + assert.is_not_nil(dep, "dep must register") + assert.is_not_nil(sibling, "sibling declared via nested specs must register") + assert.is_true(dep.specs[1]._is_dependency, "dep itself remains a dep") + assert.is_falsy(sibling.specs[1]._is_dependency, + "specs nested under a dep must NOT inherit is_dependency from ctx") + end) end) describe("pin = true (zpack_nvim-gi5)", function() diff --git a/tests/validate_test.lua b/tests/validate_test.lua index 22ad844..d962fa6 100644 --- a/tests/validate_test.lua +++ b/tests/validate_test.lua @@ -73,6 +73,7 @@ describe("Config Validation", function() defaults = { confirm = true }, performance = { vim_loader = true }, profiling = { loader = false, require = false }, + dev = { path = '~/projects', fallback = false }, }) assert.are.equal(0, #errors) end) From 996c52084b3056e22edd8eef6921e535ff1104da Mon Sep 17 00:00:00 2001 From: zuqini Date: Mon, 25 May 2026 12:26:39 -0700 Subject: [PATCH 6/8] fix: close fourth-pass review findings - :ZPack reload re-runs init (fresh-load contract; matches :Lazy reload) - :ZPack reload skips deactivate when plugin is nil (never-loaded / install-failed window) and falls back to no-prefix package.loaded sweep when utils.resolve_main has cached a not-found result - :ZPack update bulk path seeds names from vim.pack.get so pinning one plugin no longer silently narrows the universe past the registry - resolve_dev_path coerces bad dev.path/dev.fallback to defaults and names the offending spec in the no-source-field notify - normalize_version cast clears the LLS warning the `version = false` early-return left behind - ProcessContext declares _imported_functions for the function-form import dedup set --- lua/zpack/commands.lua | 82 ++++++++++++++++------------- lua/zpack/import.lua | 12 +++-- lua/zpack/init.lua | 1 + lua/zpack/utils.lua | 3 +- tests/lazy_parity_test.lua | 105 ++++++++++++++++++++++++++++++++++++- 5 files changed, 162 insertions(+), 41 deletions(-) diff --git a/lua/zpack/commands.lua b/lua/zpack/commands.lua index fe5c0d6..29cf13d 100644 --- a/lua/zpack/commands.lua +++ b/lua/zpack/commands.lua @@ -30,20 +30,25 @@ local is_registered_or_notify = function(plugin_name) return true end ----Names of registered plugins that are NOT `pin = true`. zpack.nvim is ----seeded unless the user's own spec pins it. Returns nil when nothing is ----pinned so callers can take vim.pack.update's default "everything" path. +---Build the explicit name list for `vim.pack.update` when any plugin is +---pinned. Seeded from vim.pack.get so pinning one plugin doesn't narrow +---the universe past zpack-managed plugins. Returns nil when nothing is +---pinned so callers take vim.pack.update's default "everything" path. ---@return string[]? names nil when nothing is pinned local function names_for_bulk_update() - local has_pin = false + local pinned_names = {} local zpack_pinned_by_user = false + local has_pin = false for _, entry in pairs(state.spec_registry) do if entry.merged_spec and entry.merged_spec.pin == true then has_pin = true local name = entry.merged_spec.name or (entry.plugin and entry.plugin.spec and entry.plugin.spec.name) - if name == 'zpack.nvim' then - zpack_pinned_by_user = true + if name then + pinned_names[name] = true + if name == 'zpack.nvim' then + zpack_pinned_by_user = true + end end end end @@ -52,15 +57,18 @@ local function names_for_bulk_update() end local names = {} + local seen = {} if not zpack_pinned_by_user then table.insert(names, 'zpack.nvim') + seen['zpack.nvim'] = true end - for _, entry in pairs(state.spec_registry) do - if entry.merged_spec and entry.merged_spec.pin ~= true then - local name = (entry.plugin and entry.plugin.spec and entry.plugin.spec.name) - or entry.merged_spec.name - if name and name ~= 'zpack.nvim' then + local installed_ok, installed = pcall(vim.pack.get, nil, { info = false }) + if installed_ok and installed then + for _, pack in ipairs(installed) do + local name = pack.spec and pack.spec.name + if name and not pinned_names[name] and not seen[name] then table.insert(names, name) + seen[name] = true end end end @@ -356,13 +364,13 @@ Sub.reload = { return end - local spec = registry_entry.merged_spec + local spec = registry_entry.merged_spec --[[@as zpack.Spec]] local plugin = registry_entry.plugin - -- Step 1: deactivate hook (lazy.nvim LazyPluginHooks.deactivate). A - -- throw here surfaces as a notify; reload still proceeds so a broken - -- deactivate can't strand the plugin in a half-unloaded state. - if type(spec.deactivate) == 'function' then + -- Skip deactivate when plugin is nil (never-loaded / install-failed): + -- deactivate(nil) would force user code to nil-guard. A throw is caught + -- and surfaced so a broken deactivate doesn't strand the reload. + if plugin and type(spec.deactivate) == 'function' then local ok, err = pcall(spec.deactivate, plugin) if not ok then util.schedule_notify( @@ -372,33 +380,37 @@ Sub.reload = { end end - -- Drop only modules whose file lives under THIS plugin's lua/ — a bare - -- prefix match would clear sibling plugins nested under the same - -- namespace (e.g. telescope-fzf-native's `telescope.extensions.fzf`). - -- Check fs paths directly; package.searchpath misses plugin modules - -- because Neovim's lua loader walks runtimepath, not package.path. + -- Drop modules whose file lives under THIS plugin's lua/. The fs_stat + -- is the sibling-plugin disambiguator (e.g. telescope-fzf-native's + -- `telescope.extensions.fzf`); the `main` prefix is an optimization, + -- skipped when utils.resolve_main caches a not-found result. local lua_dir = plugin and plugin.path and (plugin.path .. '/lua') or nil - local main = plugin and require('zpack.utils').resolve_main(plugin, spec) or nil - if main and main ~= '' and lua_dir then - local prefix = main .. '.' + if lua_dir then + local main = plugin and require('zpack.utils').resolve_main(plugin, spec) or nil + local prefix = main and main ~= '' and (main .. '.') or nil for key in pairs(package.loaded) do - if type(key) == 'string' and (key == main or key:sub(1, #prefix) == prefix) then - local rel = key:gsub('%.', '/') - if vim.uv.fs_stat(lua_dir .. '/' .. rel .. '.lua') - or vim.uv.fs_stat(lua_dir .. '/' .. rel .. '/init.lua') then - package.loaded[key] = nil + if type(key) == 'string' then + local in_namespace = prefix == nil + or key == main + or key:sub(1, #prefix) == prefix + if in_namespace then + local rel = key:gsub('%.', '/') + if vim.uv.fs_stat(lua_dir .. '/' .. rel .. '.lua') + or vim.uv.fs_stat(lua_dir .. '/' .. rel .. '/init.lua') then + package.loaded[key] = nil + end end end end end - -- Step 3: reset load_status so process_spec runs the full lifecycle - -- (packadd, deps, config) again. We re-fetch the pack_spec from the - -- registry rather than reusing `pack.spec` because pack's spec is the - -- minimal vim.pack form, and process_spec keys on src_to_pack_spec. + -- init normally runs once at startup; reload's contract is "fresh load", + -- so re-run it. Matches lazy.nvim's :Lazy reload. + hooks.try_call_hook(pack.spec.src, 'init') + + -- Prefer src_to_pack_spec over pack.spec: process_spec keys on the + -- former (the merged form), and pack.spec is the minimal vim.pack form. registry_entry.load_status = 'pending' - -- Canonical name: plugin_loader clears by pack.spec.name; user-typed - -- can differ in case on case-insensitive FS and leak a stale entry. state.unloaded_plugin_names[pack.spec.name] = true local pack_spec = state.src_to_pack_spec[pack.spec.src] or pack.spec require('zpack.plugin_loader').try_process_spec(pack_spec, {}) diff --git a/lua/zpack/import.lua b/lua/zpack/import.lua index 737c589..0fa32bd 100644 --- a/lua/zpack/import.lua +++ b/lua/zpack/import.lua @@ -16,12 +16,16 @@ local function resolve_dev_path(spec) if spec.dev ~= true then return nil end - local dev_config = state.config.dev - local dev_base = vim.fn.expand(dev_config.path) + -- validate_config is advisory, so a bad value can reach here. Coerce to + -- defaults rather than feed vim.fn.expand(false) → 'v:false' as a path. + local dev_config = state.config.dev or {} + local dev_path_opt = type(dev_config.path) == 'string' and dev_config.path or '~/projects' + local dev_base = vim.fn.expand(dev_path_opt) local source_for_name = spec[1] or spec.src or spec.url or spec.dir if type(source_for_name) ~= 'string' then require('zpack.utils').schedule_notify( - 'dev = true requires a source field ([1]/src/url/dir) to derive the local checkout name', + ('dev = true on spec "%s" requires a source field ([1]/src/url/dir) to derive the local checkout name') + :format(validate.spec_label(spec)), vim.log.levels.ERROR ) return nil @@ -38,7 +42,7 @@ local function resolve_dev_path(spec) -- Missing or non-directory local checkout: `fallback = true` lets the -- caller try the regular source; otherwise we still return the dev path -- so vim.pack's error message points the user at the bad local checkout. - if dev_config.fallback then + if dev_config.fallback == true then return nil end return dev_path diff --git a/lua/zpack/init.lua b/lua/zpack/init.lua index 9242c53..b4961ea 100644 --- a/lua/zpack/init.lua +++ b/lua/zpack/init.lua @@ -13,6 +13,7 @@ local M = {} ---@field confirm boolean? ---@field defaults zpack.Config.Defaults ---@field is_dependency? boolean Internal: Whether currently importing as dependency +---@field _imported_functions? table Internal: dedup set for function-form `import` ---@return zpack.ProcessContext local function create_context(opts) diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua index e434dc8..8145671 100644 --- a/lua/zpack/utils.lua +++ b/lua/zpack/utils.lua @@ -334,7 +334,8 @@ M.normalize_version = function(spec) return nil end if spec.version ~= nil then - return spec.version + -- LLS doesn't narrow past the `== false` check above; cast. + return spec.version --[[@as string|vim.VersionRange]] elseif spec.sem_version then return vim.version.range(spec.sem_version) elseif spec.branch then diff --git a/tests/lazy_parity_test.lua b/tests/lazy_parity_test.lua index 29d86ec..643f5e1 100644 --- a/tests/lazy_parity_test.lua +++ b/tests/lazy_parity_test.lua @@ -393,10 +393,113 @@ describe("dev = true source rewrite (zpack_nvim-lkb)", function() local saw for _, n in ipairs(_G.test_state.notifications) do - if type(n.msg) == 'string' and n.msg:find('dev = true requires a source field', 1, true) then + if type(n.msg) == 'string' and n.msg:find('requires a source field', 1, true) then saw = true end end assert.is_true(saw, "dev=true without a source field must notify") end) end) + +describe(":ZPack reload edge cases", function() + before_each(helpers.setup_test_env) + after_each(helpers.cleanup_test_env) + + it("reload re-runs init hook (matches fresh-load contract)", function() + local lifecycle = {} + require('zpack').setup({ + spec = { + { + 'test/initrelo', + lazy = false, + init = function() table.insert(lifecycle, 'init') end, + config = function() table.insert(lifecycle, 'config') end, + deactivate = function() table.insert(lifecycle, 'deactivate') end, + }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + lifecycle = {} + + vim.cmd('ZPack reload initrelo') + helpers.flush_pending() + + assert.are.same({ 'deactivate', 'init', 'config' }, lifecycle, + ("Reload must run deactivate → init → config; got: %s"):format(vim.inspect(lifecycle))) + end) + + it("reload skips deactivate when the plugin object is nil", function() + -- Narrow but real window: vim.pack.add's load callback hasn't fired + -- (install in progress or callback raised), so registry_entry.plugin + -- stays nil. Reload must not call deactivate(nil). + local called_deactivate = false + require('zpack').setup({ + spec = { + { + 'test/nilp', + lazy = false, + deactivate = function() called_deactivate = true end, + config = function() end, + }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + + -- Force the nil-plugin window the gate guards. + local state = require('zpack.state') + state.spec_registry['https://github.com/test/nilp'].plugin = nil + + vim.cmd('ZPack reload nilp') + helpers.flush_pending() + + assert.is_false(called_deactivate, + "Reload must NOT invoke deactivate when plugin object is nil") + for _, n in ipairs(_G.test_state.notifications) do + assert.is_falsy(type(n.msg) == 'string' + and n.msg:find('Failed to run deactivate hook', 1, true), + "Reload must not produce a deactivate failure notify for nil-plugin reload") + end + end) +end) + +describe(":ZPack update names list preserves vim.pack universe under pin", function() + before_each(helpers.setup_test_env) + after_each(helpers.cleanup_test_env) + + it("includes installed-but-unregistered plugins so a single pin doesn't narrow the universe", function() + require('zpack').setup({ + spec = { + { 'test/free' }, + { 'test/pinned', pin = true }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + + -- Plugin vim.pack knows about but zpack does not (raw vim.pack.add or + -- post-removal orphan). Pre-fix, any pin would skip this entry. + _G.test_state.registered_pack_specs.orphan = { + src = 'https://github.com/raw/orphan', + name = 'orphan', + } + + _G.test_state.vim_pack_update_calls = {} + vim.cmd('ZPack update') + + assert.are.equal(1, #_G.test_state.vim_pack_update_calls) + local names = _G.test_state.vim_pack_update_calls[1].names + assert.is_not_nil(names) + local has_free, has_pinned, has_orphan = false, false, false + for _, n in ipairs(names) do + if n == 'free' then has_free = true end + if n == 'pinned' then has_pinned = true end + if n == 'orphan' then has_orphan = true end + end + assert.is_true(has_free, "non-pinned registry plugin must be in the update list") + assert.is_false(has_pinned, "pinned plugin must be excluded") + assert.is_true(has_orphan, + "installed-but-unregistered plugin must be in the update list") + end) +end) From 5033749a656bd165e899176cd2077910ed9317ba Mon Sep 17 00:00:00 2001 From: zuqini Date: Mon, 25 May 2026 13:04:27 -0700 Subject: [PATCH 7/8] fix: close fifth-pass review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :ZPack reload (commands.lua Sub.reload): guard try_call_hook on type(spec.init) == 'function'. Pre-fix, reload of a no-init spec (the common case) emitted "expected init missing" ERROR — startup pre-filters via ctx.src_with_init; reload now mirrors that. * optional = true on table-form deps (merge.lua resolve_all): the optional prune now treats _is_dependency = true as defeating optional. Pre-fix, `dependencies = { { 'foo/x', optional = true } }` pruned x and cascade-disabled its parent via propagate_enabled_disable. normalize_dependencies only wraps string deps into a fresh {deps} table — table-form deps pass through with `optional` preserved, so the prune saw a single optional contributor. A dep declaration IS a non-optional reference (lazy.nvim parity). * dev = true derivation (import.lua resolve_dev_path): - prefer spec.name over [1]/src/url/dir for the derived local dir name (lazy.nvim parity for `me/myplugin.nvim` → ~/projects/myplugin) - strip trailing slash from dev.path so `~/projects/` does not yield `~/projects//` registry keys that drift between sessions. * import = function() returning non-table (import.lua): notifies WARN on stray return values. Mirrors load_spec_module's notify; pre-fix a `return 'oops'` or implicit nil return was silently dropped. * docs/tips.md: replace stale dev mode advice (`src = vim.fn.expand('~/projects/...')`) with the shipped `dev = true` + `setup({ dev = { path } })` flow. * docs/spec.md: move `-- version = false` next to canonical `version` block, matching doc/zpack.txt grouping. Tests: 479/479 pass (+2 regressions pinning the critical fixes). luacheck clean. lua-language-server unchanged at baseline. --- docs/spec.md | 2 +- docs/tips.md | 2 +- lua/zpack/commands.lua | 9 +++++--- lua/zpack/import.lua | 15 +++++++++--- lua/zpack/merge.lua | 14 ++++++------ tests/lazy_parity_test.lua | 47 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 74 insertions(+), 15 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index 6e0b794..f089586 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -44,13 +44,13 @@ -- 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() + -- version = false, -- Opt out of versioning (lazy.nvim escape hatch) -- 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 - -- version = false, -- Opt out of versioning (lazy.nvim escape hatch) -- Plugin metadata name = "my-plugin", -- Custom plugin name (optional, overrides auto-derived name) diff --git a/docs/tips.md b/docs/tips.md index cfff517..737b7e7 100644 --- a/docs/tips.md +++ b/docs/tips.md @@ -4,7 +4,7 @@ Most of your lazy.nvim plugin specs will work as-is with zpack. However, zpack follows `vim.pack` conventions over lazy.nvim conventions, and is missing a few advanced features: - **version pinning**: lazy.nvim's `version` field maps to zpack's `sem_version`. See [Spec Reference](spec.md) and [version pinning examples](examples.md#version-pinning-for-lazynvim-compatibility) -- **dev mode**: Use `src = vim.fn.expand('~/projects/my_plugin.nvim')` for local development +- **dev mode**: Set `dev = true` on a spec and configure `setup({ dev = { path = '~/projects' } })` — the source is rewritten to `/`. Live file-watch / auto-reload is out of scope; use `:ZPack reload {plugin}` to manually re-source. See [Spec Reference](spec.md) for `dev`/`deactivate` - **profiling**: Use `nvim --startuptime startuptime.log`. Also refer to example [Neovim Profiler script](https://gist.github.com/zuqini/35993710f81983fbfa6baca67bdb32ed) - **default lazy plugins**: lazy.nvim's community specs silently default top-level specs for utility libraries like `plenary.nvim` to `lazy = true`, even without lazy triggers or a lazy parent. zpack respects your specs as-written, so set `lazy = true` explicitly on such specs if you want the same default diff --git a/lua/zpack/commands.lua b/lua/zpack/commands.lua index 29cf13d..081e706 100644 --- a/lua/zpack/commands.lua +++ b/lua/zpack/commands.lua @@ -404,9 +404,12 @@ Sub.reload = { end end - -- init normally runs once at startup; reload's contract is "fresh load", - -- so re-run it. Matches lazy.nvim's :Lazy reload. - hooks.try_call_hook(pack.spec.src, 'init') + -- init runs once at startup; reload re-runs it (fresh-load contract). + -- Type-guard: try_call_hook ERRORs on a missing hook, and startup's + -- caller pre-filters via ctx.src_with_init. + if type(spec.init) == 'function' then + hooks.try_call_hook(pack.spec.src, 'init') + end -- Prefer src_to_pack_spec over pack.spec: process_spec keys on the -- former (the merged form), and pack.spec is the minimal vim.pack form. diff --git a/lua/zpack/import.lua b/lua/zpack/import.lua index 0fa32bd..9f27c51 100644 --- a/lua/zpack/import.lua +++ b/lua/zpack/import.lua @@ -20,11 +20,14 @@ local function resolve_dev_path(spec) -- defaults rather than feed vim.fn.expand(false) → 'v:false' as a path. local dev_config = state.config.dev or {} local dev_path_opt = type(dev_config.path) == 'string' and dev_config.path or '~/projects' - local dev_base = vim.fn.expand(dev_path_opt) - local source_for_name = spec[1] or spec.src or spec.url or spec.dir + -- Strip trailing slash so registry keys do not drift between sessions + -- with vs. without the trailing slash on `dev.path`. + local dev_base = vim.fn.expand(dev_path_opt):gsub('/+$', '') + -- `spec.name` first: lazy.nvim parity for overriding the derived dir. + local source_for_name = spec.name or spec[1] or spec.src or spec.url or spec.dir if type(source_for_name) ~= 'string' then require('zpack.utils').schedule_notify( - ('dev = true on spec "%s" requires a source field ([1]/src/url/dir) to derive the local checkout name') + ('dev = true on spec "%s" requires a source field (name/[1]/src/url/dir) to derive the local checkout name') :format(validate.spec_label(spec)), vim.log.levels.ERROR ) @@ -265,6 +268,12 @@ local import_one_spec = function(spec, ctx) ) elseif type(result) == 'table' then M.import_specs(result, ctx) + else + -- Mirror load_spec_module's non-table-return notify. + utils.schedule_notify( + ('zpack: import function returned non-table (%s)'):format(type(result)), + vim.log.levels.WARN + ) end end return diff --git a/lua/zpack/merge.lua b/lua/zpack/merge.lua index 9649ea9..9825031 100644 --- a/lua/zpack/merge.lua +++ b/lua/zpack/merge.lua @@ -407,17 +407,17 @@ function M.resolve_all() end end - -- lazy.nvim spec parity (`optional = true`): a plugin is included only if - -- it is also referenced non-optionally somewhere in the spec. When every - -- contributing spec carries `optional = true`, mark the entry disabled so - -- the existing prune machinery (which already handles dep cascades) drops - -- it. A dep registration creates a non-optional spec for the target, so - -- this naturally keeps deps of required parents. + -- lazy.nvim spec parity (`optional = true`): include the plugin only if + -- referenced non-optionally somewhere. A dep declaration + -- (`_is_dependency = true`) defeats `optional` — naming a plugin as a + -- dep IS a non-optional reference. Without that, a table-form dep + -- `{ 'foo/x', optional = true }` would prune `x` and cascade-disable + -- its parent. for src, entry in pairs(state.spec_registry) do if entry.specs and #entry.specs > 0 and entry.enabled_result ~= false then local has_required = false for _, s in ipairs(entry.specs) do - if not s.optional then + if not s.optional or s._is_dependency then has_required = true break end diff --git a/tests/lazy_parity_test.lua b/tests/lazy_parity_test.lua index 643f5e1..27a7140 100644 --- a/tests/lazy_parity_test.lua +++ b/tests/lazy_parity_test.lua @@ -190,6 +190,25 @@ describe("optional = true (zpack_nvim-sg0)", function() assert.is_not_nil(state.spec_registry['https://github.com/test/shared'], "Optional + dep-referent must survive") end) + + -- Regression: a table-form dep `{ 'foo/x', optional = true }` was the + -- only contributor for `x` (string-form deps get wrapped fresh without + -- `optional`). Pre-fix the optional prune disabled `x` and cascade- + -- disabled the parent. `_is_dependency` now defeats `optional`. + it("optional written on a table-form dep does not prune the parent", function() + require('zpack').setup({ + spec = { + { 'test/dep-parent', dependencies = { { 'test/dep-child', optional = true } } }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + local state = require('zpack.state') + assert.is_not_nil(state.spec_registry['https://github.com/test/dep-parent'], + "Parent must not be cascade-disabled by an optional table-form dep") + assert.is_not_nil(state.spec_registry['https://github.com/test/dep-child'], + "Table-form dep with `optional = true` must survive as a dep reference") + end) end) describe("import = function() (zpack_nvim-fqs)", function() @@ -429,6 +448,34 @@ describe(":ZPack reload edge cases", function() ("Reload must run deactivate → init → config; got: %s"):format(vim.inspect(lifecycle))) end) + -- Regression: reload called try_call_hook unconditionally, emitting + -- "expected init missing" ERROR for the common no-init case. + it("reload does not emit 'expected init missing' notify when spec has no init", function() + _G.test_state.notifications = {} + require('zpack').setup({ + spec = { + { + 'test/noinitrelo', + lazy = false, + config = function() end, + }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + _G.test_state.notifications = {} + + vim.cmd('ZPack reload noinitrelo') + helpers.flush_pending() + + for _, n in ipairs(_G.test_state.notifications) do + assert.is_falsy( + type(n.msg) == 'string' and n.msg:find('expected init missing', 1, true), + ("Reload of a no-init spec must not emit 'expected init missing'; got: %s"):format(tostring(n.msg)) + ) + end + end) + it("reload skips deactivate when the plugin object is nil", function() -- Narrow but real window: vim.pack.add's load callback hasn't fired -- (install in progress or callback raised), so registry_entry.plugin From ef986ae3781acd1628da63137c8dd40d716f45ac Mon Sep 17 00:00:00 2001 From: zuqini Date: Mon, 25 May 2026 13:38:37 -0700 Subject: [PATCH 8/8] fix(types): cast plugin to zpack.Plugin in vim.pack load callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLS sees the callback param as vim.pack's { spec, path } shape, so injecting name/dir/dependencies fired four inject-field warnings in CI. Cast to zpack.Plugin at the top of the callback — the type adds those fields and doc-comments them as lazy.nvim parity additions. --- lua/zpack/registration.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/zpack/registration.lua b/lua/zpack/registration.lua index daee734..9585d98 100644 --- a/lua/zpack/registration.lua +++ b/lua/zpack/registration.lua @@ -9,6 +9,7 @@ M.register_all = function(ctx) local ok, err = pcall(vim.pack.add, ctx.vim_packs, { confirm = ctx.confirm, load = function(plugin) + ---@cast plugin zpack.Plugin local pack_spec = plugin.spec local registry_entry = state.spec_registry[pack_spec.src]