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))