8000 fix(merge): fold specs deriving the same plugin name (#39) by zuqini · Pull Request #40 · zuqini/zpack.nvim · GitHub
[go: up one dir, main page]

Skip to content
Merged
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
17 changes: 11 additions & 6 deletions doc/zpack.txt
Original file line number Diff line number Diff line change
Expand Up @@ -672,11 +672,8 @@ parent's lazy trigger fires.
dir is given (lazy.nvim parity). Provide at least one of:
[1], src, dir, or url. Priority: src > url > dir > [1].
The plugin NAME still derives from [1] even when an
explicit source wins, and specs sharing a [1] merge into
one plugin even when only some of them carry the explicit
source (lazy.nvim fragment semantics). When several
fragments carry an explicit source, the last one imported
wins.
explicit source wins; specs deriving the same name merge
into one plugin — see |zpack-Spec.name| for precedence.

*zpack-Spec.src*
src (string, optional)
Expand Down Expand Up @@ -840,7 +837,15 @@ name (string, optional)
Custom plugin name. Overrides the name derived from [1] (or
from the source URL when no [1] is given). Useful when you
need a specific directory name or want to avoid naming
conflicts.
conflicts. Specs deriving the same name are folded into one
plugin (lazy.nvim parity); when their sources differ (e.g.
`old-owner/repo` vs `new-owner/repo`), a `dev = true`
fragment wins, then an explicit `src`/`url`/`dir`, then a
top-level spec over a dependency, latest-imported within a
tier; zpack warns unless the specs name a repo in common (a
shared [1], or a `src`/`url` equal to the other's [1]).
Fields merge as usual, so `enabled = false` on either
fragment disables the plugin. Set `name` to keep them apart.

*zpack-Spec.main*
main (string, optional)
Expand Down
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
commit = "abc123", -- Git commit

-- Plugin metadata
name = "my-plugin", -- Custom plugin name (optional, overrides the name derived from [1] / the source)
name = "my-plugin", -- Custom plugin name (optional, overrides the name derived from [1] / the source; specs deriving the same name fold into one plugin)
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 @@ -22,6 +22,7 @@ Most of your lazy.nvim plugin specs will work as-is with zpack. However, zpack f

Known gotchas when using zpack:
- **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
- **same plugin, two owners**: specs deriving the same name (e.g. `williamboman/mason.nvim` in a `dependencies` list and `mason-org/mason.nvim` at top level) are folded into one plugin; a `dev = true` fragment wins, then an explicit `src`/`url`/`dir`, then a top-level spec over a dependency, and zpack warns unless the specs name a repo in common (a shared `[1]`, or a `src`/`url` equal to the other's `[1]`). Fields merge as usual, so `enabled = false` on either fragment disables the plugin. Point both at the current owner, or set `name` on one to keep them apart

## Compatibility Notes

Expand Down
188 changes: 111 additions & 77 deletions lua/zpack/merge.lua
Original file line number Diff line number Diff line change
Expand Up @@ -426,93 +426,123 @@ local function rekey_graph_edges(state, old_src, new_src)
end
end

---Rank an entry for the coalesce fold. A non-shorthand-keyed entry carrying a
---`dev = true` fragment wins outright — dev outranks the whole explicit chain
---in normalize_source, independent of import order. Otherwise the latest
---explicit-source fragment wins (lazy.nvim later-fragment-wins); an entry
---with no explicit fragment (the bare-shorthand entry) ranks below any
---explicit one regardless of import order.
local function fold_rank(entry, src, shorthand)
if src ~= shorthand then
for _, spec in ipairs(entry.specs) do
if spec.dev == true then
return math.huge
end
end
end
local latest = -1
local function merge_entry(entry)
entry.sorted_specs = M.sort_specs(entry.specs)
entry.merged_spec = M.merge_spec_array(entry.sorted_specs)
end

---Rank an entry for the coalesce fold as a (tier, import order) pair; compare
---tier first, then order. A `dev = true` fragment that did not fall back to
---its own `[1]` shorthand wins outright — dev outranks the whole explicit
---chain in normalize_source, independent of import order. Below that, an
---explicit-source fragment beats a bare one, and among bare entries a
---top-level fragment beats a dependency-only one — the same
---standalone-over-dep rule sort_specs applies to field merging. Within a
---tier the latest-imported wins. lazy.nvim itself is pure
---later-fragment-wins; the tiers are a deliberate divergence so the winner
---does not depend on the order lua/plugins/*.lua files are imported.
local function fold_rank(entry, src)
local explicit, top, dep = -1, -1, -1
for _, spec in ipairs(entry.specs) do
local order = spec._import_order or 0
if spec.dev == true and (type(spec[1]) ~= 'string' or src ~= util.github_url(spec[1])) then
return 3, order
end
if type(spec.src) == 'string' or type(spec.url) == 'string'
or type(spec.dir) == 'string' then
latest = math.max(latest, spec._import_order or 0)
explicit = math.max(explicit, order)
elseif spec._is_dependency then
dep = math.max(dep, order)
else
top = math.max(top, order)
end
end
return latest
if explicit >= 0 then
return 2, explicit
end
if top >= 0 then
return 1, top
end
return 0, dep
end

---lazy.nvim fragment parity: `{ 'user/repo' }` and `{ 'user/repo', url = fork }`
---are fragments of the SAME plugin, but normalize to different sources because
---an explicit src/url/dir wins over `[1]`. Without this fold the fragments
---would survive as separate registry entries deriving the same pack name and
---`vim.pack.add` would abort setup() with a conflicting-src error. Group
---entries by every `[1]` shorthand their specs carry (plus the entry keyed at
---the shorthand itself) and fold each group into its highest-ranked entry
---(see fold_rank): losers concat their specs into the winner (re-sorted in
---place so specs[1] stays the earliest-imported fragment — get_import_order
---reads it) and the graph edges built on their srcs at import time are
---rekeyed. A fold can hand the winner `[1]`s that connect it to
---another group, so sweep to a fixpoint. Runs post-import so every fragment
---is visible no matter which was imported first.
local function coalesce_shorthand_overrides(state, utils)
local changed = true
while changed do
changed = false
local groups = {}
for src, entry in pairs(state.spec_registry) do
local seen = {}
for _, spec in ipairs(entry.specs) do
if type(spec[1]) == 'string' then
local shorthand = utils.github_url(spec[1])
if not seen[shorthand] then
seen[shorthand] = true
groups[shorthand] = groups[shorthand] or {}
table.insert(groups[shorthand], src)
end
end
end
---The repos an entry names: its registry src plus the URL of every `[1]` its
---fragments carry. Two entries naming a repo in common are the user spelling
---one repo two ways (lazy.nvim's fork-override idiom, or `{ url = X }` next
---to `{ 'user/repo', url = fork }`); a fold joining entries with no repo in
---common is an inference zpack made for them, so the caller warns.
local function named_repos(entry, src)
local repos = { [src] = true }
for _, spec in ipairs(entry.specs) do
if type(spec[1]) == 'string' then
repos[util.github_url(spec[1])] = true
end
for shorthand, srcs in pairs(groups) do
-- A fold earlier in this sweep may have consumed a member already.
local live = {}
for _, src in ipairs(srcs) do
if state.spec_registry[src] then
table.insert(live, src)
end
return repos
end

local function share_a_repo(a, b)
for repo in pairs(a) do
if b[repo] then
return true
end
end
return false
end

---lazy.nvim parity: plugins are keyed by derived name, so `{ 'user/repo' }`
---and `{ 'user/repo', url = fork }` — or `{ 'old-owner/repo' }` and
---`{ 'new-owner/repo' }` after a GitHub rename — are fragments of the SAME
---plugin even though they normalize to different sources. Without this fold
---they would reach vim.pack.add as separate entries deriving the same pack
---name and abort setup() with a conflicting-src error. Group entries by the
---name their merged_spec resolves to — the exact name handed to vim.pack, so
---the fold set is the conflict set and nothing more (grouping by every `[1]`
---a fragment carries over-folds: `{ 'user/repo', name = 'alias' }` would
---swallow an unrelated `other/repo`) — and fold each group into its
---highest-ranked entry (see fold_rank): losers concat their specs into the
---winner, which is re-sorted so specs[1] stays the earliest-imported fragment
---(get_import_order reads it) and re-merged. Graph edges built on the losers'
---srcs at import time are rekeyed.
local function coalesce_same_name(state, utils)
local groups = {}
for src, entry in pairs(state.spec_registry) do
if entry.merged_spec then
local name = utils.resolve_plugin_name(entry.merged_spec, src)
groups[name] = groups[name] or {}
table.insert(groups[name], src)
end
end
for name, srcs in pairs(groups) do
if #srcs > 1 then
local winner = srcs[1]
local winner_tier, winner_order = fold_rank(state.spec_registry[winner], winner)
for i = 2, #srcs do
local tier, order = fold_rank(state.spec_registry[srcs[i]], srcs[i])
if tier > winner_tier or (tier == winner_tier and order > winner_order) then
winner, winner_tier, winner_order = srcs[i], tier, order
end
end
if state.spec_registry[shorthand] and not vim.tbl_contains(live, shorthand) then
table.insert(live, shorthand)
end
if #live > 1 then
local winner = live[1]
local winner_rank = fold_rank(state.spec_registry[winner], winner, shorthand)
for i = 2, #live do
local rank = fold_rank(state.spec_registry[live[i]], live[i], shorthand)
if rank > winner_rank then
winner, winner_rank = live[i], rank
end
end
for _, src in ipairs(live) do
if src ~= winner then
vim.list_extend(state.spec_registry[winner].specs, state.spec_registry[src].specs)
rekey_graph_edges(state, src, winner)
state.spec_registry[src] = nil
changed = true
local winner_entry = state.spec_registry[winner]
local winner_repos = named_repos(winner_entry, winner)
for _, src in ipairs(srcs) do
if src ~= winner then
local loser = state.spec_registry[src]
if not share_a_repo(winner_repos, named_repos(loser, src)) then
utils.schedule_notify(
("zpack: %s: %s and %s resolve to the same plugin; merged into %s"):format(name, src, winner, winner),
vim.log.levels.WARN
)
end
vim.list_extend(winner_entry.specs, loser.specs)
rekey_graph_edges(state, src, winner)
state.spec_registry[src] = nil
end
table.sort(state.spec_registry[winner].specs, function(a, b)
return (a._import_order or 0) < (b._import_order or 0)
end)
end
table.sort(winner_entry.specs, function(a, b)
return (a._import_order or 0) < (b._import_order or 0)
end)
merge_entry(winner_entry)
end
end
end
Expand All @@ -525,12 +555,16 @@ function M.resolve_all()
local utils = require('zpack.utils')
local lazy = require('zpack.lazy')

coalesce_shorthand_overrides(state, utils)
for _, entry in pairs(state.spec_registry) do
if entry.specs and #entry.specs > 0 then
merge_entry(entry)
end
end

coalesce_same_name(state, utils)

for src, entry in pairs(state.spec_registry) do
if entry.specs and #entry.specs > 0 then
entry.sorted_specs = M.sort_specs(entry.specs)
entry.merged_spec = M.merge_spec_array(entry.sorted_specs)
entry.enabled_result = utils.check_enabled(entry.merged_spec, src)
-- `opts` is deliberately not stored on merged_spec; compute a boolean
-- summary once here so existence checks in plugin_loader / startup can
Expand Down
4 changes: 2 additions & 2 deletions lua/zpack/utils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,8 @@ M.derive_name_from_src = function(src)
end

---Expand a `[1]` short name ("user/repo") to its GitHub URL. Shared by
---`normalize_source` and `coalesce_shorthand_overrides` so the two sites
---cannot drift on what a shorthand resolves to.
---`normalize_source` and merge.lua's coalesce fold so the two sites cannot
---drift on what a shorthand resolves to.
---@param short_name string
---@return string
M.github_url = function(short_name)
Expand Down
Loading
Loading
0