Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion doc/zpack.txt
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,8 @@ commit (string, optional)
name (string, optional)
Custom plugin name. Overrides the name automatically derived
from the source URL. Useful when you need a specific directory
name or want to avoid naming conflicts.
name, and required to install two plugins whose sources derive
the same one — see |zpack-gotcha-name-collision|.

*zpack-Spec.main*
main (string, optional)
Expand Down Expand Up @@ -1114,6 +1115,30 @@ enabled vs cond lazy.nvim collapses both to a single "disabled"

Known gotchas when using zpack:

*zpack-gotcha-name-collision*
one directory per name A plugin is installed into a directory named
after it, so two specs resolving to the same
name compete for it: `alice/shared.nvim` and
`bob/shared.nvim` both want `shared.nvim`.
zpack keeps the one imported first and warns,
naming both sources. Give one of them
`name = "..."` to install both.

Sources differing only in letter case
(`user/Plugin.nvim` and `user/plugin.nvim`)
are one repository as far as git hosts are
concerned, so those are folded into a single
plugin instead: their specs merge, and the
casing imported first is the one used.

Two different plugins whose names differ only
in case (`alice/shared.nvim` and
`bob/Shared.nvim`) are two directories on a
case-sensitive filesystem, so both are kept.
zpack warns, because on macOS or Windows one
of the two will fail to install; set
`name = "..."` on one to be portable.

*zpack-gotcha-install-update*
install/update feedback `vim.pack` surfaces install/update progress
via `:messages` (e.g. "vim.pack: Downloading
Expand Down
4 changes: 3 additions & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@
commit = "abc123", -- Git commit

-- Plugin metadata
name = "my-plugin", -- Custom plugin name (optional, overrides auto-derived name)
name = "my-plugin", -- Custom plugin name (optional, overrides auto-derived name).
-- Also how two plugins whose sources derive the same name are
-- kept apart (see docs/tips.md, "one directory per plugin name")
main = "module.name", -- Explicit main module (auto-detected if not set)
module = false, -- Disable module-based lazy loading for this plugin

Expand Down
1 change: 1 addition & 0 deletions docs/tips.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Most of your lazy.nvim plugin specs will work as-is with zpack. However, zpack f
## Gotchas

Known gotchas when using zpack:
- **one directory per plugin name**: a plugin is installed into a directory named after it, so two specs resolving to the same name compete for it — `alice/shared.nvim` and `bob/shared.nvim` both want `shared.nvim`. zpack keeps the one imported first and warns, naming both sources; give one of them `name = "..."` to install both. Two cases are treated differently: sources differing only in letter case (`user/Plugin.nvim` and `user/plugin.nvim`) are the same repository as far as git hosts are concerned, so those are folded into a single plugin, keeping the casing imported first; two *different* plugins whose names differ only in case (`alice/shared.nvim` and `bob/Shared.nvim`) are two directories on a case-sensitive filesystem, so both are kept with a warning that they will not both install on macOS or Windows
- **install/update feedback**: `vim.pack` surfaces install/update progress via `:messages` (e.g. `vim.pack: Downloading updates (0/83)`). These messages are hidden if you have `vim.opt.cmdheight = 0` — raise it, check `:messages`, or route them through a notifier like [snacks.notifier](https://github.com/folke/snacks.nvim), [nvim-notify](https://github.com/rcarriga/nvim-notify), or [noice.nvim](https://github.com/folke/noice.nvim). Also see [noice.nvim with vim.pack](#noicenvim-with-vimpack) for compatibility notes

## Compatibility Notes
Expand Down
58 changes: 56 additions & 2 deletions lua/zpack/import.lua
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,47 @@ local function resolve_dev_path(spec)
return dev_path
end

---Normalize plugin source using priority: dev > [1] > src > url > dir
---Whether a source is fetched from a git host rather than read off disk.
---Covers `scheme://...` and scp-style `git@host:owner/repo`.
---@param src string
---@return boolean
local is_remote_source = function(src)
return src:find('^%a[%w+.-]*://') ~= nil or src:find('^[%w.-]+@[%w.-]+:') ~= nil
end

---Fold a remote source onto the casing that claimed it first.
---
---`zuqini/ZSnip.nvim` and `zuqini/zsnip.nvim` are one repository -- git hosts
---match owner and repo case-insensitively -- but as two registry keys they
---become two plugins competing for one directory, which `vim.pack.add` only
---discovers when git refuses the second clone with "destination path already
---exists". Folding them here means the specs merge, the way they would have
---if both had been written the same way.
---
---Local sources are left alone: on a case-sensitive filesystem two paths
---differing in case are two directories, and `resolve_name_collisions` in
---`zpack.merge` reports them if their names then collide.
---@param src string
---@return string
local canonical_src = function(src)
if not is_remote_source(src) then
return src
end

local folded = src:lower()
local claimed = state.src_by_folded[folded]
if claimed then
return claimed
end
state.src_by_folded[folded] = src
return src
end

---Resolve a spec's raw source using priority: dev > [1] > src > url > dir
---@param spec zpack.Spec
---@return string|nil source URL/path, or nil if invalid
---@return string|nil error message if validation fails
local normalize_source = function(spec)
local resolve_source = function(spec)
-- lazy.nvim spec parity: `dev = true` rewrites the source to a local
-- checkout under `config.dev.path` (default '~/projects'). When the local
-- directory is missing and `fallback = true` is set, resolution falls
Expand All @@ -80,6 +116,24 @@ local normalize_source = function(spec)
end
end

---Resolve a spec's source and fold it onto the casing that claimed it first.
---
---The fold belongs here rather than at the call sites: `register_dependencies`
---resolves a dependency's source to key `dependency_graph` before handing the
---spec to `import_one_spec`, so a fold applied only at registration would key
---the graph with a casing that never reaches `spec_registry` -- a dangling
---edge, which reads downstream as "this plugin has no dependency".
---@param spec zpack.Spec
---@return string|nil source URL/path, or nil if invalid
---@return string|nil error message if validation fails
local normalize_source = function(spec)
local src, err = resolve_source(spec)
if not src then
return nil, err
end
return canonical_src(src)
end

---Check if a table has any non-integer keys
---@param tbl table
---@return boolean
Expand Down
105 changes: 97 additions & 8 deletions lua/zpack/merge.lua
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,41 @@ local function strip_outgoing_edges(state, src)
return newly_orphaned
end

---Drop the edges that point *at* `src`, so no surviving parent is left naming
---a source the registry no longer has. `prune_disabled` reaches here with the
---parents already disabled, but a collision drop leaves them standing.
local function strip_incoming_edges(state, src)
local incoming = state.reverse_dependency_graph[src]
if incoming then
for parent_src in pairs(incoming) do
local deps = state.dependency_graph[parent_src]
if deps then
deps[src] = nil
end
end
end
state.reverse_dependency_graph[src] = nil
end

---Drain `worklist`, removing each source from the registry and cascading into
---dep-only plugins that lose their last parent as a result. Shared by
---`prune_disabled` and `resolve_name_collisions` so both leave the dependency
---graphs keyed only by sources the registry still has.
local function drop_entries(state, worklist)
while #worklist > 0 do
local src = table.remove(worklist)
local orphaned = strip_outgoing_edges(state, src)
strip_incoming_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
table.insert(worklist, dep_src)
end
end
end
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
Expand Down Expand Up @@ -362,18 +397,69 @@ local function prune_disabled(state)
table.insert(worklist, src)
end
end
drop_entries(state, worklist)
end

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
table.insert(worklist, dep_src)
---Report two plugins that want the same directory, and keep the first.
---
---A plugin's directory is its name, so `a/foo.nvim` and `b/foo.nvim` both ask
---for `foo.nvim`. `vim.pack.add` finds out only when git refuses the second
---clone with "destination path already exists and is not an empty directory",
---which names neither spec and aborts the whole of `setup()`.
---
---Names differing only in case (`a/Foo.nvim` vs `b/foo.nvim`) are warned about
---but both kept: they are two directories on a case-sensitive filesystem, and
---dropping one there would remove a plugin that installs perfectly well. On a
---case-insensitive filesystem the warning is what explains the `vim.pack`
---error that follows.
---
---Sources differing only in case were already folded into one entry at import,
---so anything reaching here is two genuinely different plugins and the user
---has to break the tie with `name`.
---@param state table
---@param utils table
local function resolve_name_collisions(state, utils)
local srcs = vim.tbl_keys(state.spec_registry)
-- pairs() has no stable order, so "imported first" has to be asked for
-- rather than assumed -- otherwise which plugin survives varies per start.
table.sort(srcs, function(a, b)
return utils.get_import_order(a) < utils.get_import_order(b)
end)

local claimed = {}
local claimed_folded = {}
local dropped = {}
for _, src in ipairs(srcs) do
local entry = state.spec_registry[src]
if entry.merged_spec then
local name = entry.merged_spec.name or utils.derive_name_from_src(src)
local owner = claimed[name]
if owner then
utils.schedule_notify(
('zpack: skipping %s — it resolves to the same plugin directory (%s) as %s.\n'
.. 'Set `name = "..."` on one of them to install both.')
:format(src, name, owner),
vim.log.levels.WARN
)
table.insert(dropped, src)
else
claimed[name] = src
local variant = claimed_folded[name:lower()]
if variant then
utils.schedule_notify(
('zpack: %s and %s resolve to plugin directories differing only in case (%s).\n'
.. 'Both are installed on a case-sensitive filesystem; on macOS or Windows one\n'
.. 'of them will fail to install. Set `name = "..."` on one to be portable.')
:format(variant, src, name),
vim.log.levels.WARN
)
else
claimed_folded[name:lower()] = src
end
end
end
end
drop_entries(state, dropped)
end

---Pre-compute merged_spec for all entries in the registry
Expand Down Expand Up @@ -434,6 +520,9 @@ function M.resolve_all()

propagate_enabled_disable(state, utils)
prune_disabled(state)
-- After pruning: a disabled plugin is not competing for a directory, so it
-- must not be the one that "wins" a collision against an enabled spec.
resolve_name_collisions(state, utils)

-- 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)`
Expand Down
7 changes: 7 additions & 0 deletions lua/zpack/state.lua
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ M.src_to_pack_spec = {}
---@type { [string]: string }
M.name_to_src = {}

---Case-folded remote source to the source string actually keying
---`spec_registry`, so a repository written with different casing in two specs
---resolves to one registry entry.
---@type { [string]: string }
M.src_by_folded = {}

---@type { [string]: boolean }
M.lazy_parent_cache = {}

Expand All @@ -55,6 +61,7 @@ M.remove_plugin = function(plugin_name, src)
M.src_with_pending_build[src] = nil
M.src_to_pack_spec[src] = nil
M.name_to_src[plugin_name] = nil
M.src_by_folded[src:lower()] = nil
M.lazy_parent_cache[src] = nil
M.resolve_main_not_found[src] = nil

Expand Down
Loading
Loading