From d8940e9001287fcc10afe2d189cc621e747623b9 Mon Sep 17 00:00:00 2001
From: zuqini
Date: Fri, 10 Apr 2026 10:13:46 -0700
Subject: [PATCH] feat: add public introspection API for third-party tooling
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Introduces `require('zpack').get_plugins()` and `get_plugin(name)` as
the supported surface for tools (e.g. zshow.nvim) that need to enumerate
or look up plugins zpack is managing, along with a documented
`zpack.PluginInfo` shape.
Fixes a regression in b1eab47 where external consumers reaching into
zpack's internal `src_to_pack_spec` crashed on disabled plugins. The API
draws a clear internal/external boundary so consumers no longer need to
touch internal state.
To keep the contract simple, `enabled = false` plugins (and any dep-only
plugins orphaned by the cascade) are now pruned from the registry during
setup and do not appear in the API; use `cond` for runtime conditions
that should remain visible with `status = "disabled"`. `:ZClean` will
remove `enabled = false` plugins from disk, matching the hard-disable
semantic.
Install-state queries (checked-out rev, available updates, etc.) are
intentionally not part of this API — they are owned by `vim.pack`, which
is itself public and stable. Consumers should call `vim.pack.get`
directly for those.
`name_to_src` and `is_lazy_resolved` are now populated during
`resolve_all` from the merged spec, so `get_plugin(name)` is symmetric
with `get_plugins()` across the installing → pending → loaded
lifecycle, and the reported `lazy` flag stays stable as an entry
transitions (no flip between raw `merged_spec.lazy` and the plugin-aware
resolution). Entries whose `vim.pack.add` load callback has not yet
fired surface as `status = "installing"` with a nil `path` so UIs can
render "downloading" instead of the plugin vanishing. `get_plugins()` is
side-effect-free — no notifications — so it is safe for statuslines and
dashboards to poll.
`derive_status` checks `load_status` before `cond_result` so a
cond=false plugin that gets force-loaded (via `:ZLoad!` or as a
required dependency of a live parent) correctly reports `loaded`
instead of `disabled`.
`src` is now documented as "the git URL or local path passed to
vim.pack.add" — committing to a real semantic rather than the previous
"opaque, do not parse" disclaimer. A formal deprecation policy is
deliberately deferred until the first time a field actually needs
retiring, rather than promised in advance.
---
README.md | 4 +
doc/zpack.txt | 190 ++++++++++-
docs/public_api.md | 116 +++++++
docs/spec.md | 16 +
lua/zpack/api/init.lua | 133 ++++++++
lua/zpack/commands.lua | 4 +-
lua/zpack/init.lua | 19 ++
lua/zpack/merge.lua | 45 ++-
lua/zpack/registration.lua | 7 +-
lua/zpack/state.lua | 5 +
lua/zpack/types.lua | 12 +
lua/zpack/utils.lua | 12 +
tests/conditional_test.lua | 23 +-
tests/dependencies_test.lua | 83 ++---
tests/helpers.lua | 24 +-
tests/public_api_test.lua | 641 ++++++++++++++++++++++++++++++++++++
tests/run_all.lua | 1 +
17 files changed, 1250 insertions(+), 85 deletions(-)
create mode 100644 docs/public_api.md
create mode 100644 lua/zpack/api/init.lua
create mode 100644 tests/public_api_test.lua
diff --git a/README.md b/README.md
index a6d48ff..d85db81 100644
--- a/README.md
+++ b/README.md
@@ -160,6 +160,10 @@ See **[Spec Reference](docs/spec.md)** for the full spec definition, including `
See **[Tips & Migration](docs/tips.md)** for lazy.nvim migration guide and compatibility notes for popular plugins (Snacks.nvim dashboard, noice.nvim, etc.).
+## Public API
+
+See **[Public API](docs/public_api.md)** for the supported introspection surface used by third-party tooling like dashboards and status UIs. Also available as `:help zpack-public-api`.
+
## Extensions
- [zshow.nvim](https://github.com/sairyy/zshow.nvim) — A floating window UI for viewing installed plugins, grouped by load status.
diff --git a/doc/zpack.txt b/doc/zpack.txt
index 69877da..e48929e 100644
--- a/doc/zpack.txt
+++ b/doc/zpack.txt
@@ -912,14 +912,200 @@ To fix this, add a route that explicitly shows `vim.pack` messages:
<
==============================================================================
-12. EXTENSIONS *zpack-extensions*
+12. PUBLIC API *zpack-public-api*
+
+The functions and types in this section are the supported surface for
+third-party tooling (e.g. UIs like zshow.nvim) that needs to inspect the
+plugins managed by zpack. They are covered by the stability promise below.
+
+The canonical module is `require('zpack.api')`. |zpack.api.VERSION|,
+|zpack.get_plugins()|, and |zpack.get_plugin()| are also re-exported on the
+root module for convenience.
+
+Everything exposed here lives under `lua/zpack/api/`. Modules outside that
+directory are INTERNAL — their shapes may change between commits. If you
+were previously reading `require('zpack.state').spec_registry` or similar,
+migrate to |zpack.api.get_plugins()|: `enabled = false` plugins are now
+pruned from the internal registry, so direct reads will miss them.
+
+Install-state queries (installed git revision, available updates, etc.)
+are intentionally NOT part of this API — they are owned by Neovim's
+`vim.pack` module, which is itself public and stable. If you need the
+installed rev for a plugin, call `vim.pack.get({ info.name }, { info =
+false })` directly; zpack does not re-export it.
+
+ *zpack-api-stability*
+STABILITY
+
+- The intent is for functions and fields listed here to remain stable;
+ new fields may be added additively.
+- |zpack.api.VERSION| is bumped whenever the contract changes in a
+ consumer-observable way. Consumers can gate on it: >lua
+
+ if require('zpack.api').VERSION >= 2 then
+ -- use a field that will be introduced in v2
+ end
+<
+- A formal deprecation policy is not yet in place — it will be introduced
+ the first time an existing field needs to be retired, rather than
+ promised in advance.
+- `require('zpack.state')` and every other module under `zpack.*` (other
+ than `zpack.api`) are INTERNAL. Their shapes may change without notice;
+ do not depend on them.
+
+ *zpack.api.VERSION*
+zpack.api.VERSION
+ Integer API contract version. Currently `1`. Bumped when a
+ backward-compatible field is added or when a new value is
+ added to |zpack.PluginStatus|.
+
+ *zpack.api.get_plugins()*
+ *zpack.get_plugins()*
+get_plugins()
+
+ Return a snapshot of every plugin zpack knows about, sorted by `name`.
+ Plugins disabled by `enabled = false` (and any dep-only plugins that
+ become unreferenced as a result) are pruned during setup and will NOT
+ appear here — use `enabled` for hard disables that should vanish from
+ the registry, and `cond` for runtime conditions that should remain
+ visible with `status = "disabled"`. The returned array is freshly
+ allocated on each call; entries must be treated as read-only. zpack
+ itself is NOT listed — it bootstraps via `vim.pack.add` outside this
+ API; consumers that need to show it can query `vim.pack.get` directly.
+ >lua
+
+ for _, info in ipairs(require('zpack.api').get_plugins()) do
+ print(info.name, info.status)
+ end
+<
+ Return: ~
+ |zpack.PluginInfo|[]
+
+ *zpack.api.get_plugin()*
+ *zpack.get_plugin()*
+get_plugin({name})
+
+ Look up a single plugin by its resolved name. Returns a plugin
+ registered under {name}, or nil when none is registered; never
+ throws. `vim.pack.add` rejects name collisions within a single
+ setup(), so at most one entry can ever match. >lua
+
+ local info = require('zpack').get_plugin('telescope.nvim')
+ if info and info.status == 'loaded' then
+ -- ...
+ end
+<
+ Parameters: ~
+ {name} (string) Resolved plugin name to look up.
+ Return: ~
+ |zpack.PluginInfo| or nil
+
+ *zpack.PluginInfo*
+
+Snapshot of a registered plugin returned by |zpack.api.get_plugins()| and
+|zpack.api.get_plugin()|. Treat as read-only. >lua
+
+ {
+ name = string, -- resolved plugin name
+ src = string, -- git URL or local path
+ -- passed to vim.pack.add
+ status = "loaded"|"pending" -- current load/enablement
+ | "loading"|"disabled" -- state
+ | "installing",
+ lazy = boolean, -- configured to lazy-load?
+ path = string?, -- plugin directory on disk
+ -- (nil when installing)
+ }
+<
+ *zpack.PluginInfo.name*
+name (string)
+ Resolved plugin name and the stable lookup key for
+ |zpack.api.get_plugin()|. Matches the name zpack uses for
+ tab completion, commands like |:ZUpdate|, and the
+ directory under `site/pack/zpack/opt`. Unique within a
+ single setup().
+
+ *zpack.PluginInfo.src*
+src (string)
+ The git URL or local path zpack passed to `vim.pack.add`
+ for this plugin — the same value you would put in a
+ spec's `src` / `dir` / `url` field, or the URL zpack
+ derived from the `"user/repo"` shorthand. Unique within
+ a single setup(), and safe to pass back to `vim.pack.get`
+ or display in a UI. Use `name` (not `src`) as the
+ |zpack.api.get_plugin()| lookup key.
+
+ *zpack.PluginInfo.status*
+status ("loaded"|"pending"|"loading"|"disabled"|"installing")
+ Current state of the plugin:
+ - "loaded" : plugin has been loaded into the session
+ - "pending" : registered but not yet loaded (e.g. a lazy
+ plugin whose trigger has not fired)
+ - "loading" : currently mid-load (observable inside the
+ plugin's own `config` callback when it is
+ lazy-loaded)
+ - "disabled" : `cond` resolved to false. The plugin IS
+ registered with `vim.pack.add` (so `path`
+ is populated), but zpack skips its config/
+ load steps. Plugins disabled by `enabled =
+ false` are pruned entirely and do not
+ appear in this API — and `:ZClean` will
+ delete them from disk, which is the
+ intended hard-disable semantic. Use
+ `cond = false` to keep the plugin
+ installed but inactive.
+ - "installing": spec is registered but `vim.pack.add`'s
+ load callback has not fired yet (typically
+ a fresh install awaiting user confirmation
+ or an async download). `path` is nil for
+ these entries. |zpack.api.get_plugin()|
+ can still resolve them: the reported
+ `name` is derived from the spec (explicit
+ `name`, or the basename of `src` with
+ `.git` stripped, matching `vim.pack`'s own
+ derivation) and stays stable as the entry
+ transitions to pending / loaded.
+
+ *zpack.PluginInfo.lazy*
+lazy (boolean)
+ Whether the plugin is configured to lazy-load (either by
+ `lazy = true` or by setting a trigger like `event`, `cmd`,
+ `keys`, or `ft`). Resolved from the merged spec during
+ setup, so the value is stable across the installing →
+ pending → loaded lifecycle. For function-form triggers
+ the pre-install answer is computed without a plugin
+ argument; the post-install callback re-computes with the
+ real plugin, so a function-form trigger may disagree
+ between those two states in rare cases.
+
+ *zpack.PluginInfo.path*
+path (string or nil)
+ Absolute path to the plugin directory on disk. Populated
+ by `vim.pack.add`'s load callback, which resolves the
+ path before `cond` is evaluated — so `path` is present
+ for every "loaded"/"pending"/"loading"/"disabled" entry.
+ It is `nil` only while `status == "installing"` (the
+ load callback has not fired yet, typically a fresh
+ install awaiting user confirmation); check `status` or
+ nil-guard before using it.
+
+ *zpack-api-install-state*
+INSTALL STATE (rev, updates, ...)
+
+Not part of this API. Use `vim.pack.get({ info.name }, { info = false })`
+to fetch the checked-out revision, or `{ info = true }` for upstream /
+update information. `vim.pack` is itself public and stable, so zpack
+deliberately does not re-export or wrap it.
+
+==============================================================================
+13. EXTENSIONS *zpack-extensions*
- zshow.nvim: A floating window UI for viewing installed plugins, grouped
by load status.
https://github.com/sairyy/zshow.nvim
==============================================================================
-13. ACKNOWLEDGEMENTS *zpack-acknowledgements*
+14. ACKNOWLEDGEMENTS *zpack-acknowledgements*
zpack's spec design and several features are inspired by lazy.nvim
(https://github.com/folke/lazy.nvim). Credit to folke for the excellent
diff --git a/docs/public_api.md b/docs/public_api.md
new file mode 100644
index 0000000..c3c9d72
--- /dev/null
+++ b/docs/public_api.md
@@ -0,0 +1,116 @@
+# Public API
+
+The functions and types on this page are the supported surface for third-party tooling (e.g. UIs like [zshow.nvim](https://github.com/sairyy/zshow.nvim)) that needs to inspect the plugins zpack is managing.
+
+See `:help zpack-public-api` for the vimdoc version of this reference.
+
+## Entry point
+
+The canonical module is `require('zpack.api')`. For convenience, `VERSION`, `get_plugins()`, and `get_plugin(name)` are also re-exported on the root module (`require('zpack')`). Everything else lives under `zpack.api.*`.
+
+```lua
+local api = require('zpack.api')
+api.VERSION -- integer, currently 1
+api.get_plugins() -- zpack.PluginInfo[]
+api.get_plugin(name) -- zpack.PluginInfo?
+```
+
+Everything the API exposes is defined in `lua/zpack/api/`. Modules outside that directory are **internal** — their shapes may change between commits. If you were previously reading `require('zpack.state').spec_registry` or similar, migrate to `require('zpack.api').get_plugins()`: the API fields (`name`, `src`, `status`, `lazy`, `path`) cover what dashboards and pickers typically need, and `enabled = false` plugins are now pruned from the internal registry so direct reads will miss them.
+
+Install-state queries (the currently checked-out git revision, available updates, on-disk size, etc.) are intentionally **not** part of this API — they are owned by Neovim's `vim.pack` module, which is itself public and stable. If you need the installed rev for a plugin, call `vim.pack.get({ info.name }, { info = false })` directly; zpack does not re-export it.
+
+## Stability
+
+- The intent is for functions and fields listed here to remain stable, and new fields may be added additively.
+- `zpack.api.VERSION` is bumped whenever the contract changes in a consumer-observable way. Consumers that depend on new fields can gate on it:
+ ```lua
+ if require('zpack.api').VERSION >= 2 then
+ -- use a field that will be introduced in v2
+ end
+ ```
+- A formal deprecation policy is not yet in place — it will be introduced the first time an existing field needs to be retired, rather than promised in advance.
+- `require('zpack.state')` and every other module under `zpack.*` (other than `zpack.api`) are **internal**. Their shapes may change without notice; do not depend on them.
+
+## Functions
+
+### `api.get_plugins()`
+
+Return a snapshot of every plugin zpack knows about, sorted by `name`. Plugins disabled by `enabled = false` (and any dep-only plugins that become unreferenced as a result) are pruned during setup and will **not** appear here — use `enabled` for hard disables that should vanish from the registry, and `cond` for runtime conditions that should remain visible with `status = "disabled"`. The returned array is freshly allocated on each call; entries must be treated as read-only. zpack itself is **not** listed — it bootstraps via `vim.pack.add` outside this API, and consumers that need to show it can query `vim.pack.get` directly.
+
+```lua
+for _, info in ipairs(require('zpack.api').get_plugins()) do
+ print(info.name, info.status)
+end
+```
+
+**Returns:** `zpack.PluginInfo[]`
+
+### `api.get_plugin(name)`
+
+Look up a single plugin by its resolved `zpack.PluginInfo.name`. Returns a plugin registered under `name`, or `nil` when none is registered; never throws. `vim.pack.add` rejects name collisions within a single `setup()`, so at most one entry can ever match.
+
+```lua
+local info = require('zpack.api').get_plugin('telescope.nvim')
+if info and info.status == 'loaded' then
+ -- ...
+end
+```
+
+**Parameters:**
+- `name` (`string`) — Resolved plugin name to look up.
+
+**Returns:** `zpack.PluginInfo?`
+
+### `require('zpack').get_plugins()` / `require('zpack').get_plugin(name)`
+
+Convenience aliases for the functions above, for consumers that don't want a second `require`.
+
+## `zpack.PluginInfo`
+
+Snapshot of a registered plugin returned by the functions above. Treat as read-only.
+
+```lua
+{
+ name = string, -- resolved plugin name
+ src = string, -- git URL or local path passed to
+ -- vim.pack.add
+ status = "loaded"|"pending" -- current load/enablement state
+ | "loading"|"disabled"
+ | "installing",
+ lazy = boolean, -- configured to lazy-load?
+ path = string?, -- absolute plugin directory
+ -- (nil while status == "installing")
+}
+```
+
+### Fields
+
+#### `name` (`string`)
+
+Resolved plugin name and the stable lookup key for `get_plugin(name)`. Matches the name zpack uses for tab completion, commands like `:ZUpdate`, and the directory under `site/pack/zpack/opt`. Unique within a single `setup()`.
+
+#### `src` (`string`)
+
+The git URL or local path zpack passed to `vim.pack.add` for this plugin — the same value you would put in a spec's `src` / `dir` / `url` field, or the URL zpack derived from the `"user/repo"` shorthand. Unique within a single `setup()`, and safe to pass back to `vim.pack.get` or display in a UI. Use `name` (not `src`) as the `get_plugin(...)` lookup key.
+
+#### `status` (`"loaded"|"pending"|"loading"|"disabled"|"installing"`)
+
+Current state of the plugin:
+
+- `"loaded"` — plugin has been loaded into the session
+- `"pending"` — registered but not yet loaded (e.g. a lazy plugin whose trigger has not fired)
+- `"loading"` — currently mid-load (observable inside the plugin's own `config` callback when it is lazy-loaded)
+- `"disabled"` — `cond` resolved to `false`. The plugin **is** registered with `vim.pack.add` (so `path` is populated), but zpack skips its config/load steps. Plugins disabled by `enabled = false` are pruned entirely and do not appear in this API — and `:ZClean` will delete them from disk on its next run, which is the intended hard-disable semantic. Use `cond = false` if you want the plugin to stay installed.
+- `"installing"` — spec is registered but `vim.pack.add`'s load callback has not fired yet (typically a fresh install awaiting user confirmation or an async download). `path` is `nil` for these entries. `get_plugin(name)` can still resolve them: the reported `name` is derived from the spec (explicit `name`, or the basename of `src` with `.git` stripped, matching `vim.pack`'s own derivation) and stays stable as the entry transitions to `pending` / `loaded`.
+
+#### `lazy` (`boolean`)
+
+Whether the plugin is configured to lazy-load — either by `lazy = true` or by setting a trigger like `event`, `cmd`, `keys`, or `ft`. Resolved from the merged spec during setup, so the value is stable across the `installing → pending → loaded` lifecycle. For function-form triggers (e.g. `event = function(plugin) ... end`), the pre-install answer is computed without a plugin argument; the post-install callback re-computes it with the real plugin, so in rare cases a function-form trigger may disagree between those two states.
+
+#### `path` (`string?`)
+
+Absolute path to the plugin directory on disk. Populated by `vim.pack.add`'s load callback, which resolves the path before `cond` is evaluated — so `path` is present for every `loaded`/`pending`/`loading`/`disabled` entry. It is `nil` only while `status == "installing"` (the load callback has not fired yet, typically a fresh install awaiting user confirmation); check `status` or nil-guard before using it.
+
+### Install-state queries (rev, updates, etc.)
+
+Not part of this API. Use `vim.pack.get({ info.name }, { info = false })` to fetch the checked-out revision, or pass `{ info = true }` for upstream/update information. `vim.pack` is itself public and stable, so zpack deliberately does not re-export or wrap it.
diff --git a/docs/spec.md b/docs/spec.md
index 2de2bf0..01624d2 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -87,3 +87,19 @@ The plugin data object passed to hooks and trigger functions:
nowait = true|false, -- Default: false
}
```
+
+### zpack.PluginInfo Reference
+
+Snapshot of a registered plugin returned by the public API functions under `require('zpack.api')`. Treat as read-only. See `:help zpack-public-api` or [docs/public_api.md](public_api.md) for the full reference, including stability guarantees and field-level docs.
+
+```lua
+{
+ name = string, -- resolved plugin name
+ src = string, -- git URL or local path
+ status = "loaded"|"pending" -- current load/enablement state
+ | "loading"|"disabled",
+ lazy = boolean, -- configured to lazy-load?
+ path = string, -- plugin directory on disk
+ rev = string?, -- installed git revision
+}
+```
diff --git a/lua/zpack/api/init.lua b/lua/zpack/api/init.lua
new file mode 100644
index 0000000..0890542
--- /dev/null
+++ b/lua/zpack/api/init.lua
@@ -0,0 +1,133 @@
+---Public introspection API for zpack.
+---
+---This module is the supported surface for third-party tooling that needs to
+---enumerate or look up plugins managed by zpack (e.g. UIs like zshow.nvim).
+---Internal state in `zpack.state` and every other module under `zpack.*`
+---(anything not under `zpack.api.*`) is NOT a public API and may change
+---without notice; always go through this module instead.
+---
+---Stability: the intent is for functions and the |zpack.PluginInfo| shape
+---declared here to remain stable, and |zpack.api.VERSION| will be bumped
+---whenever the contract changes in a consumer-observable way. A formal
+---deprecation policy is not yet in place — it will be introduced the first
+---time an existing field needs to be retired, rather than promised in
+---advance. Consumers can gate behavior on |zpack.api.VERSION|.
+
+---@class zpack.api
+local M = {}
+
+---API contract version. Bumped whenever the contract changes in a
+---consumer-observable way (field added, |zpack.PluginStatus| value added,
+---etc.).
+---@type integer
+M.VERSION = 1
+
+---@param entry zpack.RegistryEntry
+---@return zpack.PluginStatus
+local function derive_status(entry)
+ if not entry.plugin then
+ return 'installing'
+ end
+ -- load_status wins over cond_result: a cond=false plugin can still end up
+ -- loaded if it is pulled in as a required dependency or force-loaded via
+ -- `:ZLoad!`. Reporting "disabled" for an actually-loaded plugin would
+ -- break UIs that key off status.
+ if entry.load_status == 'loaded' or entry.load_status == 'loading' then
+ return entry.load_status
+ end
+ if entry.cond_result == false then
+ return 'disabled'
+ end
+ return entry.load_status or 'pending'
+end
+
+---Project a registry entry onto the public PluginInfo shape, or nil if the
+---entry is not reportable. Post-setup, `merged_spec` and `is_lazy_resolved`
+---are populated on every surviving entry (resolve_all prunes the rest), so
+---a nil here means an internal invariant was broken — drop the entry
+---silently rather than crashing a read-only getter. `entry.plugin` is set
+---inside `vim.pack.add`'s load callback, which has not fired yet for
+---entries mid-install; those surface as `status = "installing"` with a nil
+---`path` so UIs can render "downloading" instead of having the plugin
+---vanish.
+---@param src string
+---@param entry zpack.RegistryEntry
+---@return zpack.PluginInfo?
+local function entry_to_info(src, entry)
+ if not entry.merged_spec then
+ return nil
+ end
+ local plugin = entry.plugin
+ local lazy_flag = entry.is_lazy_resolved == true
+ if not plugin then
+ local utils = require('zpack.utils')
+ return {
+ name = entry.merged_spec.name or utils.derive_name_from_src(src),
+ src = src,
+ status = 'installing',
+ lazy = lazy_flag,
+ path = nil,
+ }
+ end
+ return {
+ name = plugin.spec.name,
+ src = src,
+ status = derive_status(entry),
+ lazy = lazy_flag,
+ path = plugin.path,
+ }
+end
+
+---Return a snapshot of every plugin zpack knows about, sorted by name.
+---Plugins disabled by `enabled = false` (and any dep-only plugins that become
+---unreferenced as a result) are pruned during setup and will NOT appear here;
+---use `enabled` for hard disables that should vanish from the registry, and
+---`cond` for runtime conditions that should remain visible with
+---`status = "disabled"`. zpack itself is not listed — it bootstraps via
+---`vim.pack.add` outside this API, and consumers that need it can query
+---`vim.pack.get` directly. Install-state queries like the checked-out git
+---revision are intentionally not part of this API; use
+---`vim.pack.get({ name }, { info = false })` for those. The returned table
+---is freshly allocated on each call; entries must be treated as read-only.
+---@return zpack.PluginInfo[]
+function M.get_plugins()
+ local state = require('zpack.state')
+
+ local result = {}
+ for src, entry in pairs(state.spec_registry) do
+ local info = entry_to_info(src, entry)
+ if info then
+ table.insert(result, info)
+ end
+ end
+
+ table.sort(result, function(a, b) return a.name < b.name end)
+ return result
+end
+
+---Look up a single plugin by its resolved name. Returns the plugin
+---registered under `name`, or nil when none is registered; never throws.
+---Mid-install entries are findable — their resolved name is computed from
+---the spec during setup so `get_plugin` stays symmetric with `get_plugins`
+---across the installing → pending → loaded lifecycle. `vim.pack.add`
+---rejects name collisions within a single `setup()`, so at most one entry
+---can ever match.
+---@param name string
+---@return zpack.PluginInfo?
+function M.get_plugin(name)
+ if type(name) ~= 'string' or name == '' then
+ return nil
+ end
+ local state = require('zpack.state')
+ local src = state.name_to_src[name]
+ if not src then
+ return nil
+ end
+ local entry = state.spec_registry[src]
+ if not entry then
+ return nil
+ end
+ return entry_to_info(src, entry)
+end
+
+return M
diff --git a/lua/zpack/commands.lua b/lua/zpack/commands.lua
index 5fb4210..6db569d 100644
--- a/lua/zpack/commands.lua
+++ b/lua/zpack/commands.lua
@@ -63,7 +63,7 @@ local run_pack_update = function(plugin_name, update_opts, error_prefix)
end
local get_installed_or_notify = function(plugin_name)
- local ok, result = pcall(vim.pack.get, { plugin_name })
+ local ok, result = pcall(vim.pack.get, { plugin_name }, { info = false })
if not ok or not result or not result[1] then
util.schedule_notify(('Plugin "%s" not installed'):format(plugin_name), vim.log.levels.ERROR)
return nil
@@ -73,7 +73,7 @@ end
M.clean_unused = function()
local to_delete = {}
- local installed = vim.pack.get() or {}
+ local installed = vim.pack.get(nil, { info = false }) or {}
for _, pack in ipairs(installed) do
local src = pack.spec.src
diff --git a/lua/zpack/init.lua b/lua/zpack/init.lua
index 95e46ec..1adbf7e 100644
--- a/lua/zpack/init.lua
+++ b/lua/zpack/init.lua
@@ -1,5 +1,7 @@
---@module 'zpack'
+local api = require('zpack.api')
+
local M = {}
---@class zpack.ProcessContext
@@ -160,4 +162,21 @@ M.add = function()
require('zpack.deprecation').notify_removed('add')
end
+---Public API contract version. Alias for |zpack.api.VERSION|.
+---@type integer
+M.VERSION = api.VERSION
+
+---Return a snapshot of every plugin zpack knows about. Plugins disabled by
+---`enabled = false` are pruned during setup and will not appear. See
+---|zpack.PluginInfo| for the returned shape. Alias for |zpack.api.get_plugins|.
+---@return zpack.PluginInfo[]
+M.get_plugins = api.get_plugins
+
+---Look up a single plugin by its resolved name. Returns nil when no plugin
+---with that name is registered; never throws. Alias for
+---|zpack.api.get_plugin|.
+---@param name string
+---@return zpack.PluginInfo?
+M.get_plugin = api.get_plugin
+
return M
diff --git a/lua/zpack/merge.lua b/lua/zpack/merge.lua
index af02fbc..6ca3806 100644
--- a/lua/zpack/merge.lua
+++ b/lua/zpack/merge.lua
@@ -301,8 +301,8 @@ end
---Propagate enabled=false backward through reverse_dependency_graph.
---A plugin whose required dependency is disabled cannot function, so it is
---disabled too. Emits one warning per propagation step so the user learns
----which dep caused the cascade. Runs before prune_disabled_subtrees so the
----pruner picks up the newly-disabled parents.
+---which dep caused the cascade. Runs before prune_disabled so the pruner
+---picks up the newly-disabled parents.
local function propagate_enabled_disable(state, utils)
local worklist = {}
for src, entry in pairs(state.spec_registry) do
@@ -330,7 +330,11 @@ local function propagate_enabled_disable(state, utils)
end
end
-local function prune_disabled_subtrees(state)
+---Remove every enabled=false entry from the registry, plus any dep-only
+---plugins that become orphaned as a side effect. After this runs, every
+---remaining entry is guaranteed to reach vim.pack.add, which means the
+---public API can rely on entry.plugin being populated by the load callback.
+local function prune_disabled(state)
local worklist = {}
for src, entry in pairs(state.spec_registry) do
if entry.enabled_result == false then
@@ -341,10 +345,10 @@ local function prune_disabled_subtrees(state)
while #worklist > 0 do
local src = table.remove(worklist)
local orphaned = strip_outgoing_edges(state, src)
+ state.spec_registry[src] = nil
for _, dep_src in ipairs(orphaned) do
local dep_entry = state.spec_registry[dep_src]
if dep_entry and entry_is_dep_only(dep_entry) then
- state.spec_registry[dep_src] = nil
table.insert(worklist, dep_src)
end
end
@@ -357,6 +361,7 @@ end
function M.resolve_all()
local state = require('zpack.state')
local utils = require('zpack.utils')
+ local lazy = require('zpack.lazy')
for _, entry in pairs(state.spec_registry) do
if entry.specs and #entry.specs > 0 then
@@ -372,11 +377,39 @@ function M.resolve_all()
end
propagate_enabled_disable(state, utils)
- prune_disabled_subtrees(state)
+ prune_disabled(state)
+
+ -- Pre-compute is_lazy_resolved and name_to_src from merged_spec alone so the
+ -- public API can report a stable `lazy` flag and resolve `get_plugin(name)`
+ -- for entries still mid-install (whose vim.pack.add load callback has not
+ -- fired yet). The registration load callback re-computes is_lazy_resolved
+ -- with the live plugin arg for accuracy with function-form triggers — both
+ -- calls flow through lazy.is_lazy so the answers stay consistent.
+ -- derive_name_from_src must match vim.pack.add's own derivation rule so
+ -- this pre-seed agrees with the registration callback's rewrite. If they
+ -- ever diverge, a stale derived-name key survives in name_to_src — harmless
+ -- (bounded, non-crashing, cleared on re-setup), but worth knowing.
+ for src, entry in pairs(state.spec_registry) do
+ if entry.merged_spec then
+ entry.is_lazy_resolved = lazy.is_lazy(entry.merged_spec, nil, src)
+ local name = entry.merged_spec.name or utils.derive_name_from_src(src)
+ state.name_to_src[name] = src
+ end
+ end
+ -- Drop the pre-load lazy-parent cache so the registration callback
+ -- recomputes has_lazy_parent with populated `parent_entry.plugin`.
+ state.lazy_parent_cache = {}
local vim_packs = {}
for src, entry in pairs(state.spec_registry) do
- if entry.merged_spec and entry.enabled_result then
+ if not entry.merged_spec then
+ utils.schedule_notify(
+ ("zpack: skipping %s — no merged_spec (empty specs list?)"):format(src),
+ vim.log.levels.WARN
+ )
+ state.spec_registry[src] = nil
+ else
+ assert(entry.enabled_result ~= false, ("internal: registry entry for %s survived prune_disabled with enabled_result=false"):format(src))
local pack_spec = {
src = src,
version = utils.normalize_version(entry.merged_spec),
diff --git a/lua/zpack/registration.lua b/lua/zpack/registration.lua
index bcd2af9..2e0f5fc 100644
--- a/lua/zpack/registration.lua
+++ b/lua/zpack/registration.lua
@@ -19,6 +19,11 @@ M.register_all = function(ctx)
local spec = registry_entry.merged_spec --[[@as zpack.Spec]]
registry_entry.plugin = plugin
state.src_to_pack_spec[pack_spec.src] = pack_spec
+ if pack_spec.name then
+ state.name_to_src[pack_spec.name] = pack_spec.src
+ 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)
if not registry_entry.cond_result then
@@ -36,7 +41,7 @@ M.register_all = function(ctx)
table.insert(ctx.src_with_init, pack_spec.src)
end
- if lazy.is_lazy(spec, plugin, pack_spec.src) then
+ if registry_entry.is_lazy_resolved then
table.insert(ctx.registered_lazy_packs, pack_spec)
else
table.insert(ctx.registered_startup_packs, pack_spec)
diff --git a/lua/zpack/state.lua b/lua/zpack/state.lua
index 7cd814c..b934f53 100644
--- a/lua/zpack/state.lua
+++ b/lua/zpack/state.lua
@@ -22,6 +22,9 @@ M.reverse_dependency_graph = {}
---@type { [string]: vim.pack.Spec }
M.src_to_pack_spec = {}
+---@type { [string]: string }
+M.name_to_src = {}
+
---@type { [string]: boolean }
M.lazy_parent_cache = {}
@@ -45,6 +48,7 @@ M.remove_plugin = function(plugin_name, src)
M.spec_registry[src] = nil
M.src_with_pending_build[src] = nil
M.src_to_pack_spec[src] = nil
+ M.name_to_src[plugin_name] = nil
M.lazy_parent_cache[src] = nil
M.resolve_main_not_found[src] = nil
@@ -82,6 +86,7 @@ M.clear_plugin_lists = function()
M.dependency_graph = {}
M.reverse_dependency_graph = {}
M.src_to_pack_spec = {}
+ M.name_to_src = {}
M.lazy_parent_cache = {}
M.resolve_main_not_found = {}
end
diff --git a/lua/zpack/types.lua b/lua/zpack/types.lua
index 1b2fa8f..31c1d27 100644
--- a/lua/zpack/types.lua
+++ b/lua/zpack/types.lua
@@ -59,6 +59,17 @@
---@alias zpack.LoadStatus "pending" | "loading" | "loaded"
+---@alias zpack.PluginStatus "pending" | "loading" | "loaded" | "disabled" | "installing"
+
+---Public snapshot of a registered plugin. Returned by |zpack.get_plugins()|
+---and |zpack.get_plugin()|. Consumers must treat instances as read-only.
+---@class zpack.PluginInfo
+---@field name string Resolved plugin name — the stable lookup key
+---@field src string Git URL or local path passed to `vim.pack.add`; safe to display or pass to `vim.pack.get`
+---@field status zpack.PluginStatus Current load/enablement state
+---@field lazy boolean Whether the plugin is configured to lazy-load
+---@field path? string Absolute plugin directory; nil while `status == "installing"`
+
---@class zpack.RegistryEntry
---@field specs zpack.Spec[]
---@field sorted_specs? zpack.Spec[]
@@ -67,5 +78,6 @@
---@field load_status zpack.LoadStatus
---@field enabled_result? boolean
---@field cond_result? boolean
+---@field is_lazy_resolved? boolean
return {}
diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua
index 5538184..469bafd 100644
--- a/lua/zpack/utils.lua
+++ b/lua/zpack/utils.lua
@@ -172,6 +172,18 @@ M.check_cond = function(spec, plugin, default_cond)
end
---Normalize a plugin name for module matching
+---Derive a plugin name from a src URL/path the same way `vim.pack.add`
+---does when `name` is not explicitly set: basename of the URL/path,
+---stripped of a trailing `.git`. Used to resolve a stable name before
+---`vim.pack.add`'s load callback has populated `plugin.spec.name`.
+---@param src string
+---@return string
+M.derive_name_from_src = function(src)
+ local trimmed = src:gsub('/+$', '')
+ local basename = trimmed:match('([^/]+)$') or trimmed
+ return (basename:gsub('%.git$', ''))
+end
+
---Inspired by lazy.nvim's Util.normname()
---@param name string
---@return string
diff --git a/tests/conditional_test.lua b/tests/conditional_test.lua
index bb145cd..db8e96d 100644
--- a/tests/conditional_test.lua
+++ b/tests/conditional_test.lua
@@ -2,7 +2,7 @@ local helpers = require('helpers')
return function()
helpers.describe("Conditional Loading", function()
- helpers.test("enabled=false prevents plugin registration", function()
+ helpers.test("enabled=false prunes plugin from registry", function()
helpers.setup_test_env()
local state = require('zpack.state')
@@ -18,9 +18,7 @@ return function()
helpers.flush_pending()
local src = 'https://github.com/test/plugin'
- local entry = state.spec_registry[src]
- helpers.assert_not_nil(entry, "Registry entry should still exist when enabled=false")
- helpers.assert_equal(entry.enabled_result, false, "enabled_result should be false")
+ helpers.assert_nil(state.spec_registry[src], "enabled=false plugin should be pruned from spec_registry")
helpers.assert_false(
vim.tbl_contains(state.registered_plugin_names, 'plugin'),
"Plugin should not be in registered_plugin_names when enabled=false"
@@ -54,7 +52,7 @@ return function()
helpers.cleanup_test_env()
end)
- helpers.test("enabled function returning false prevents registration", function()
+ helpers.test("enabled function returning false prunes plugin from registry", function()
helpers.setup_test_env()
local state = require('zpack.state')
@@ -70,9 +68,7 @@ return function()
helpers.flush_pending()
local src = 'https://github.com/test/plugin'
- local entry = state.spec_registry[src]
- helpers.assert_not_nil(entry, "Registry entry should still exist when enabled function returns false")
- helpers.assert_equal(entry.enabled_result, false, "enabled_result should be false")
+ helpers.assert_nil(state.spec_registry[src], "enabled fn returning false should prune from spec_registry")
helpers.assert_false(
vim.tbl_contains(state.registered_plugin_names, 'plugin'),
"Plugin should not be in registered_plugin_names"
@@ -85,7 +81,7 @@ return function()
helpers.cleanup_test_env()
end)
- helpers.test("enabled function returning nil counts as disabled", function()
+ helpers.test("enabled function returning nil counts as disabled and prunes", function()
helpers.setup_test_env()
local state = require('zpack.state')
@@ -100,12 +96,9 @@ return function()
})
helpers.flush_pending()
- local entry = state.spec_registry['https://github.com/test/plugin']
- helpers.assert_not_nil(entry, "entry should still exist")
- helpers.assert_equal(
- entry.enabled_result,
- false,
- "enabled function returning nil should be treated as disabled"
+ helpers.assert_nil(
+ state.spec_registry['https://github.com/test/plugin'],
+ "enabled fn returning nil should be treated as disabled and pruned"
)
helpers.assert_nil(
_G.test_state.registered_pack_specs['plugin'],
diff --git a/tests/dependencies_test.lua b/tests/dependencies_test.lua
index ce86185..129c189 100644
--- a/tests/dependencies_test.lua
+++ b/tests/dependencies_test.lua
@@ -797,16 +797,13 @@ return function()
helpers.flush_pending()
- local dep_entry = state.spec_registry['https://github.com/test/dep']
- helpers.assert_not_nil(dep_entry, "dep registry entry should still exist")
- helpers.assert_equal(dep_entry.enabled_result, false, "dep enabled_result should be false")
-
- local parent_entry = state.spec_registry['https://github.com/test/parent']
- helpers.assert_not_nil(parent_entry, "parent registry entry should still exist")
- helpers.assert_equal(
- parent_entry.enabled_result,
- false,
- "parent should be disabled because its required dep is disabled"
+ helpers.assert_nil(
+ state.spec_registry['https://github.com/test/dep'],
+ "disabled dep should be pruned from spec_registry"
+ )
+ helpers.assert_nil(
+ state.spec_registry['https://github.com/test/parent'],
+ "parent should be pruned after propagation (required dep is disabled)"
)
helpers.assert_nil(_G.test_state.dep_config_ran, "config should NOT have run")
@@ -878,10 +875,10 @@ return function()
helpers.flush_pending()
- local src = 'https://github.com/test/shared'
- local entry = state.spec_registry[src]
- helpers.assert_not_nil(entry, "registry entry should still exist")
- helpers.assert_equal(entry.enabled_result, false, "merged enabled_result should be false")
+ helpers.assert_nil(
+ state.spec_registry['https://github.com/test/shared'],
+ "plugin with any fragment disabling it should be pruned"
+ )
helpers.assert_false(
vim.tbl_contains(state.registered_plugin_names, 'shared'),
"disabled plugin should not be in registered_plugin_names"
@@ -915,12 +912,9 @@ return function()
helpers.flush_pending()
- local entry = state.spec_registry['https://github.com/test/shared']
- helpers.assert_not_nil(entry, "registry entry should still exist")
- helpers.assert_equal(
- entry.enabled_result,
- false,
- "merged function AND_LOGIC should yield false when either fn returns false"
+ helpers.assert_nil(
+ state.spec_registry['https://github.com/test/shared'],
+ "merged function AND_LOGIC yielding false should prune the plugin"
)
helpers.assert_nil(
_G.test_state.registered_pack_specs['shared'],
@@ -1415,20 +1409,17 @@ return function()
helpers.flush_pending()
- helpers.assert_equal(
- state.spec_registry['https://github.com/test/prop-leaf'].enabled_result,
- false,
- "leaf should be directly disabled"
+ helpers.assert_nil(
+ state.spec_registry['https://github.com/test/prop-leaf'],
+ "leaf should be pruned"
)
- helpers.assert_equal(
- state.spec_registry['https://github.com/test/prop-mid'].enabled_result,
- false,
- "mid should be propagation-disabled (depends on leaf)"
+ helpers.assert_nil(
+ state.spec_registry['https://github.com/test/prop-mid'],
+ "mid should be pruned (propagation-disabled, depends on leaf)"
)
- helpers.assert_equal(
- state.spec_registry['https://github.com/test/prop-grand'].enabled_result,
- false,
- "grand should be propagation-disabled (depends transitively on leaf)"
+ helpers.assert_nil(
+ state.spec_registry['https://github.com/test/prop-grand'],
+ "grand should be pruned (propagation-disabled, depends transitively on leaf)"
)
for _, name in ipairs({ 'prop-grand', 'prop-mid', 'prop-leaf' }) do
@@ -1474,15 +1465,13 @@ return function()
helpers.flush_pending()
- helpers.assert_equal(
- state.spec_registry['https://github.com/test/shared-dep-a'].enabled_result,
- false,
- "dep-a should be propagation-disabled"
+ helpers.assert_nil(
+ state.spec_registry['https://github.com/test/shared-dep-a'],
+ "dep-a should be pruned (propagation-disabled)"
)
- helpers.assert_equal(
- state.spec_registry['https://github.com/test/shared-dep-b'].enabled_result,
- false,
- "dep-b should be propagation-disabled"
+ helpers.assert_nil(
+ state.spec_registry['https://github.com/test/shared-dep-b'],
+ "dep-b should be pruned (propagation-disabled)"
)
helpers.assert_nil(
_G.test_state.registered_pack_specs['shared-dep-a'],
@@ -1516,10 +1505,9 @@ return function()
helpers.flush_pending()
- helpers.assert_equal(
- state.spec_registry['https://github.com/test/multi-dep-parent'].enabled_result,
- false,
- "parent with one disabled required dep should be disabled"
+ helpers.assert_nil(
+ state.spec_registry['https://github.com/test/multi-dep-parent'],
+ "parent with one disabled required dep should be pruned"
)
helpers.assert_nil(
_G.test_state.registered_pack_specs['multi-dep-parent'],
@@ -1553,10 +1541,9 @@ return function()
helpers.flush_pending()
- helpers.assert_equal(
- state.spec_registry['https://github.com/test/fn-prop-parent'].enabled_result,
- false,
- "parent should be propagation-disabled when dep's enabled function returns false"
+ helpers.assert_nil(
+ state.spec_registry['https://github.com/test/fn-prop-parent'],
+ "parent should be pruned when dep's enabled function returns false"
)
helpers.assert_nil(
_G.test_state.registered_pack_specs['fn-prop-parent'],
diff --git a/tests/helpers.lua b/tests/helpers.lua
index 629d93f..9f6b434 100644
--- a/tests/helpers.lua
+++ b/tests/helpers.lua
@@ -149,25 +149,26 @@ function M.setup_test_env()
_G.test_state.original_vim_pack_get = vim.pack.get
_G.test_state.registered_pack_specs = {}
- vim.pack.get = function(names)
+ _G.test_state.pack_revs = {}
+ vim.pack.get = function(names, _opts)
+ local function make_entry(name, pack_spec)
+ return {
+ spec = pack_spec,
+ path = vim.fn.stdpath('data') .. '/site/pack/zpack/opt/' .. name,
+ name = name,
+ rev = _G.test_state.pack_revs[name] or ('mock-rev-' .. name),
+ }
+ end
local results = {}
if names == nil or #names == 0 then
for name, pack_spec in pairs(_G.test_state.registered_pack_specs) do
- table.insert(results, {
- spec = pack_spec,
- path = vim.fn.stdpath('data') .. '/site/pack/zpack/opt/' .. name,
- name = name,
- })
+ table.insert(results, make_entry(name, pack_spec))
end
else
for _, name in ipairs(names) do
local pack_spec = _G.test_state.registered_pack_specs[name]
if pack_spec then
- table.insert(results, {
- spec = pack_spec,
- path = vim.fn.stdpath('data') .. '/site/pack/zpack/opt/' .. name,
- name = name,
- })
+ table.insert(results, make_entry(name, pack_spec))
end
end
end
@@ -251,6 +252,7 @@ function M.cleanup_test_env()
package.loaded['zpack.deprecation'] = nil
package.loaded['zpack.merge'] = nil
package.loaded['zpack.module_loader'] = nil
+ package.loaded['zpack.api'] = nil
-- Remove our module loader from package.loaders if present
for i = #package.loaders, 1, -1 do
diff --git a/tests/public_api_test.lua b/tests/public_api_test.lua
new file mode 100644
index 0000000..015f22d
--- /dev/null
+++ b/tests/public_api_test.lua
@@ -0,0 +1,641 @@
+local helpers = require('helpers')
+
+local PLUGIN_INFO_KEYS = {
+ name = true,
+ src = true,
+ status = true,
+ lazy = true,
+ path = true,
+}
+
+local function find_by_name(list, name)
+ for _, info in ipairs(list) do
+ if info.name == name then
+ return info
+ end
+ end
+ return nil
+end
+
+return function()
+ helpers.describe("Public API (zpack.get_plugins / get_plugin)", function()
+ helpers.test("get_plugins returns an entry for each registered plugin", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/alpha' },
+ { 'test/beta' },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ local list = require('zpack').get_plugins()
+ helpers.assert_not_nil(find_by_name(list, 'alpha'), "alpha should be in get_plugins()")
+ helpers.assert_not_nil(find_by_name(list, 'beta'), "beta should be in get_plugins()")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("get_plugins entry shape has name, src, status, lazy", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/alpha' },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ local info = find_by_name(require('zpack').get_plugins(), 'alpha')
+ helpers.assert_not_nil(info, "alpha should be listed")
+ helpers.assert_equal(info.name, 'alpha', "name should be resolved plugin name")
+ helpers.assert_equal(info.src, 'https://github.com/test/alpha', "src should be git URL")
+ helpers.assert_equal(info.status, 'loaded', "eagerly loaded plugin should report loaded")
+ helpers.assert_equal(info.lazy, false, "non-lazy plugin should report lazy=false")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("PluginInfo exposes exactly the documented key set", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/alpha' },
+ { 'test/gamma', cond = false },
+ { 'test/delta', lazy = true },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ local list = require('zpack').get_plugins()
+ helpers.assert_true(#list >= 3, "all three plugins should be listed")
+ for _, info in ipairs(list) do
+ for k in pairs(info) do
+ helpers.assert_true(
+ PLUGIN_INFO_KEYS[k] == true,
+ ("PluginInfo leaked undocumented key %q on %q — if this is intentional, bump zpack.api.VERSION and update PLUGIN_INFO_KEYS"):format(k, tostring(info.name))
+ )
+ end
+ -- Every key must be present (rawget catches silent-nil regressions
+ -- that `pairs` iteration would miss).
+ for k in pairs(PLUGIN_INFO_KEYS) do
+ helpers.assert_true(
+ rawget(info, k) ~= nil,
+ ("PluginInfo is missing required key %q on %q"):format(k, tostring(info.name))
+ )
+ end
+ end
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("enabled=false plugins are pruned and not returned", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/alpha', enabled = false },
+ { 'test/beta' },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ local list = require('zpack').get_plugins()
+ helpers.assert_nil(find_by_name(list, 'alpha'), "enabled=false plugin must not appear in get_plugins()")
+ helpers.assert_not_nil(find_by_name(list, 'beta'), "beta should still be listed")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("enabled=false plugins do not reach vim.pack.add", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/alpha', enabled = false },
+ { 'test/beta' },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ helpers.assert_nil(_G.test_state.registered_pack_specs['alpha'], "alpha must not be installed")
+ helpers.assert_not_nil(_G.test_state.registered_pack_specs['beta'], "beta should be installed")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("get_plugin returns nil for enabled=false plugins", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/alpha', enabled = false },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ helpers.assert_nil(require('zpack').get_plugin('alpha'), "pruned plugin must not be findable")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("lazy plugins report lazy=true and status=pending", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/alpha', lazy = true },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ local info = find_by_name(require('zpack').get_plugins(), 'alpha')
+ helpers.assert_not_nil(info, "lazy alpha should be listed")
+ helpers.assert_equal(info.lazy, true, "lazy should be true")
+ helpers.assert_equal(info.status, 'pending', "unloaded lazy plugin should be pending")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("get_plugin returns a single entry by name", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/alpha' },
+ { 'test/beta' },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ local info = require('zpack').get_plugin('beta')
+ helpers.assert_not_nil(info, "get_plugin should find beta")
+ helpers.assert_equal(info.name, 'beta', "name should match")
+ helpers.assert_equal(info.src, 'https://github.com/test/beta', "src should match")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("get_plugin returns nil for unknown name", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = { { 'test/alpha' } },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ helpers.assert_nil(require('zpack').get_plugin('does-not-exist'), "unknown name should return nil")
+ helpers.assert_nil(require('zpack').get_plugin(''), "empty name should return nil")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("cond=false reports status=disabled", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/alpha', cond = false },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ local info = require('zpack').get_plugin('alpha')
+ helpers.assert_not_nil(info, "cond-disabled alpha should still be listed")
+ helpers.assert_equal(info.status, 'disabled', "cond=false should report disabled")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("cond function returning false reports status=disabled", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/alpha', cond = function() return false end },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ local info = require('zpack').get_plugin('alpha')
+ helpers.assert_not_nil(info, "cond-fn-disabled alpha should still be listed")
+ helpers.assert_equal(info.status, 'disabled', "cond() == false should report disabled")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("cond_disabled lazy plugin still reports lazy=true", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/alpha', cond = false, lazy = true },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ local info = require('zpack').get_plugin('alpha')
+ helpers.assert_not_nil(info, "alpha should be listed")
+ helpers.assert_equal(info.status, 'disabled', "cond=false should report disabled")
+ helpers.assert_equal(info.lazy, true, "lazy field must be honest even when cond-disabled")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("cond_disabled event-triggered plugin reports lazy=true", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/alpha', cond = false, event = 'BufReadPost' },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ local info = require('zpack').get_plugin('alpha')
+ helpers.assert_not_nil(info, "alpha should be listed")
+ helpers.assert_equal(info.lazy, true, "event-triggered plugin should report lazy=true even when cond-disabled")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("disable propagates and prunes parent + child", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/parent', dependencies = { 'test/child' } },
+ { 'test/child', enabled = false },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ helpers.assert_nil(require('zpack').get_plugin('parent'), "parent should be pruned after dep-propagated disable")
+ helpers.assert_nil(require('zpack').get_plugin('child'), "child should be pruned after explicit disable")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("dep-only plugin is pruned when its only parent is disabled", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/parent', enabled = false, dependencies = { 'test/dep' } },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ helpers.assert_nil(require('zpack').get_plugin('parent'), "disabled parent should be pruned")
+ helpers.assert_nil(require('zpack').get_plugin('dep'), "dep-only child of disabled parent should be pruned")
+ helpers.assert_nil(_G.test_state.registered_pack_specs['dep'], "orphaned dep must not reach vim.pack.add")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("dep with another live parent survives when one parent is disabled", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/keeper', dependencies = { 'test/shared' } },
+ { 'test/dropper', enabled = false, dependencies = { 'test/shared' } },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ helpers.assert_not_nil(require('zpack').get_plugin('keeper'), "keeper should survive")
+ helpers.assert_not_nil(require('zpack').get_plugin('shared'), "shared dep should survive (still referenced by keeper)")
+ helpers.assert_nil(require('zpack').get_plugin('dropper'), "dropper should be pruned")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("status reports loading while a lazy-loaded plugin's config is running", function()
+ helpers.setup_test_env()
+ local observed_status
+ require('zpack').setup({
+ spec = {
+ {
+ 'test/alpha',
+ lazy = true,
+ config = function()
+ local info = require('zpack').get_plugin('alpha')
+ observed_status = info and info.status
+ end,
+ },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ -- Before lazy-load, alpha is pending.
+ helpers.assert_equal(require('zpack').get_plugin('alpha').status, 'pending', "lazy plugin starts pending")
+
+ pcall(vim.cmd, 'ZLoad! alpha')
+
+ helpers.assert_equal(observed_status, 'loading', "get_plugin inside config must see status=loading")
+ helpers.assert_equal(require('zpack').get_plugin('alpha').status, 'loaded', "status should be loaded after load completes")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("PluginInfo does not expose a rev field (install state is vim.pack's)", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = { { 'test/alpha' } },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ local info = require('zpack').get_plugin('alpha')
+ helpers.assert_not_nil(info, "alpha should exist")
+ helpers.assert_nil(
+ rawget(info, 'rev'),
+ "rev was intentionally removed from PluginInfo — consumers must use vim.pack.get"
+ )
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("get_plugins surfaces entries whose load callback has not fired as installing", function()
+ helpers.setup_test_env()
+
+ -- Simulate vim.pack deferring the load callback (e.g. fresh install
+ -- awaiting confirmation): record the spec but don't invoke opts.load.
+ vim.pack.add = function(specs, _opts)
+ table.insert(_G.test_state.vim_pack_calls, specs)
+ for _, pack_spec in ipairs(specs) do
+ local name = pack_spec.name or pack_spec.src:match('[^/]+$')
+ pack_spec.name = name
+ _G.test_state.registered_pack_specs[name] = pack_spec
+ end
+ end
+
+ local ok, err = pcall(function()
+ require('zpack').setup({
+ spec = { { 'test/alpha' } },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+ end)
+ helpers.assert_true(ok, "setup must not throw when load callback is deferred: " .. tostring(err))
+
+ local info = find_by_name(require('zpack').get_plugins(), 'alpha')
+ helpers.assert_not_nil(info, "pending-install entries must surface in get_plugins()")
+ helpers.assert_equal(info.status, 'installing', "deferred-load entry should report status=installing")
+ helpers.assert_equal(info.path, nil, "installing entries have no resolved path yet")
+ helpers.assert_equal(info.name, 'alpha', "installing entry should still carry a derivable name")
+
+ -- get_plugin() is symmetric with get_plugins(): name_to_src is populated
+ -- at resolve_all time from merged_spec.name (or a derived basename), so
+ -- installing entries are findable by the same name they will carry once
+ -- the load callback fires.
+ local looked_up = require('zpack').get_plugin('alpha')
+ helpers.assert_not_nil(looked_up, "get_plugin must resolve installing entries")
+ helpers.assert_equal(looked_up.status, 'installing', "looked-up installing entry keeps status=installing")
+ helpers.assert_equal(looked_up.name, 'alpha', "looked-up installing entry has the same name as in get_plugins()")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("get_plugins does not include zpack itself", function()
+ helpers.setup_test_env()
+ _G.test_state.registered_pack_specs['zpack.nvim'] = {
+ name = 'zpack.nvim',
+ src = 'https://github.com/zuqini/zpack.nvim',
+ }
+
+ require('zpack').setup({
+ spec = { { 'test/alpha' } },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ local list = require('zpack').get_plugins()
+ helpers.assert_nil(
+ find_by_name(list, 'zpack.nvim'),
+ "zpack.nvim should not be in the API — consumers that need it can query vim.pack.get directly"
+ )
+ helpers.assert_not_nil(find_by_name(list, 'alpha'), "user plugins should still be returned")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("get_plugins() is sorted by name", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/charlie' },
+ { 'test/alpha' },
+ { 'test/bravo' },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ local list = require('zpack').get_plugins()
+ local names = {}
+ for _, info in ipairs(list) do
+ table.insert(names, info.name)
+ end
+ local prev = nil
+ for _, name in ipairs(names) do
+ if prev ~= nil then
+ helpers.assert_true(prev <= name, "get_plugins() must be sorted by name")
+ end
+ prev = name
+ end
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("get_plugin tolerates non-string arguments", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = { { 'test/alpha' } },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ helpers.assert_nil(require('zpack').get_plugin(nil), "nil should return nil, not throw")
+ helpers.assert_nil(require('zpack').get_plugin(42), "number should return nil, not throw")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("zpack.api.VERSION is exposed as an integer >= 1", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({ spec = {}, defaults = { confirm = false } })
+ helpers.flush_pending()
+
+ local version = require('zpack.api').VERSION
+ helpers.assert_equal(type(version), 'number', "VERSION must be numeric")
+ helpers.assert_true(version >= 1, "VERSION must be >= 1")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("lazy via event trigger reports lazy=true", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/alpha', event = 'BufReadPost' },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ local info = require('zpack').get_plugin('alpha')
+ helpers.assert_not_nil(info, "alpha should be listed")
+ helpers.assert_equal(info.lazy, true, "event-triggered plugin should report lazy=true")
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("lazy flag does not flip across installing → loaded lifecycle", function()
+ helpers.setup_test_env()
+
+ -- First setup: defer the load callback so alpha surfaces as installing
+ -- with an event-triggered spec. The lazy flag must already be true.
+ vim.pack.add = function(specs, _opts)
+ table.insert(_G.test_state.vim_pack_calls, specs)
+ for _, pack_spec in ipairs(specs) do
+ local name = pack_spec.name or pack_spec.src:match('[^/]+$')
+ pack_spec.name = name
+ _G.test_state.registered_pack_specs[name] = pack_spec
+ end
+ end
+
+ require('zpack').setup({
+ spec = { { 'test/alpha', event = 'BufReadPost' } },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ local installing = require('zpack').get_plugin('alpha')
+ helpers.assert_not_nil(installing, "installing entry must be findable")
+ helpers.assert_equal(installing.status, 'installing', "alpha should be installing")
+ helpers.assert_equal(
+ installing.lazy, true,
+ "event-triggered plugin must report lazy=true even before the load callback fires"
+ )
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("force-loaded cond=false plugin reports loaded, not disabled", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/alpha', cond = false },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ -- Pre-condition: alpha is registered and reports disabled.
+ helpers.assert_equal(
+ require('zpack').get_plugin('alpha').status, 'disabled',
+ "cond=false plugin starts disabled"
+ )
+
+ -- `:ZLoad! alpha` force-loads past the cond gate (used e.g. when a
+ -- consumer deliberately wants the plugin loaded despite its cond).
+ pcall(vim.cmd, 'ZLoad! alpha')
+
+ helpers.assert_equal(
+ require('zpack').get_plugin('alpha').status, 'loaded',
+ "force-loaded cond=false plugin must report loaded, not disabled"
+ )
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("cond=false dep pulled in by a live parent reports loaded", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ {
+ 'test/parent',
+ dependencies = { { 'test/helper', cond = false } },
+ },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ -- Parent is eagerly loaded, which pulls helper in as a required dep.
+ -- plugin_loader warns that helper has cond=false but loads it anyway,
+ -- so derive_status must honor load_status over cond_result.
+ local helper = require('zpack').get_plugin('helper')
+ helpers.assert_not_nil(helper, "cond=false dep should still be registered")
+ helpers.assert_equal(
+ helper.status, 'loaded',
+ "cond=false dep force-loaded by a live parent must report loaded"
+ )
+
+ helpers.cleanup_test_env()
+ end)
+
+ helpers.test("get_plugin(info.name) round-trips every get_plugins() entry", function()
+ helpers.setup_test_env()
+
+ require('zpack').setup({
+ spec = {
+ { 'test/alpha' },
+ { 'test/beta', lazy = true },
+ { 'test/gamma', cond = false },
+ { 'test/delta', event = 'BufReadPost' },
+ },
+ defaults = { confirm = false },
+ })
+ helpers.flush_pending()
+
+ local list = require('zpack').get_plugins()
+ helpers.assert_true(#list >= 4, "all four plugins should be listed")
+ for _, info in ipairs(list) do
+ local looked_up = require('zpack').get_plugin(info.name)
+ helpers.assert_not_nil(
+ looked_up,
+ ("get_plugin(%q) must round-trip get_plugins() entry"):format(info.name)
+ )
+ helpers.assert_equal(
+ looked_up.name, info.name,
+ "round-tripped name must match the original entry"
+ )
+ helpers.assert_equal(
+ looked_up.src, info.src,
+ "round-tripped src must match the original entry"
+ )
+ end
+
+ helpers.cleanup_test_env()
+ end)
+ end)
+end
diff --git a/tests/run_all.lua b/tests/run_all.lua
index ccb43aa..77c3bc1 100644
--- a/tests/run_all.lua
+++ b/tests/run_all.lua
@@ -30,6 +30,7 @@ local test_modules = {
'zrestore_test',
'is_single_spec_test',
'source_plugin_files_test',
+ 'public_api_test',
}
print("\n" .. string.rep("=", 60))