8000 fix(import): explicit src/url/dir overrides [1] shorthand, matching lazy.nvim by zuqini · Pull Request #36 · 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
32 changes: 20 additions & 12 deletions doc/zpack.txt
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ dev (table, optional)
- `path`: Base directory for local plugin checkouts.
Default: `'~/projects'`.
- `fallback`: When the local directory is missing, fall
through to the regular `[1]` / `src` / `url` / `dir`
through to the regular `src` / `url` / `dir` / `[1]`
chain instead of using the dev path anyway.
Default: `false`.

Expand Down Expand Up @@ -594,7 +594,7 @@ parent's lazy trigger fires.

>lua
{
-- Plugin source (provide exactly one)
-- Plugin source (provide at least one; src > url > dir > [1])
[1] = "user/repo", -- Short name (github.com/...)
src = "https://...", -- Custom git URL or local path
dir = "/path/to/plugin", -- Local path (lazy.nvim compat, ~ expanded)
Expand Down Expand Up @@ -668,26 +668,33 @@ parent's lazy trigger fires.
*zpack-Spec.[1]*
[1] (string, optional)
Plugin short name (e.g., "user/repo"). Expands to
`https://github.com/{[1]}`. Provide exactly one of:
[1], src, dir, or url. Priority: [1] > src > url > dir.
`https://github.com/{[1]}` when no explicit src, url, or
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.

*zpack-Spec.src*
src (string, optional)
Custom git URL or local path for the plugin source.
Provide exactly one of: [1], src, dir, or url.
Overrides [1] when both are set.

*zpack-Spec.dir*
dir (string, optional)
Local plugin directory path. For lazy.nvim compatibility.
Paths are automatically expanded (e.g., `~` expands to home
directory). Mapped to `src` internally. Provide exactly one
of: [1], src, dir, or url.
directory). Mapped to `src` internally. Overrides [1] when
both are set.

*zpack-Spec.url*
url (string, optional)
Custom git URL. For lazy.nvim compatibility.
Mapped to `src` internally. Provide exactly one of:
[1], src, dir, or url.
Mapped to `src` internally. Overrides [1] when both are
set, e.g. to swap in a fork while keeping the short name.

*zpack-Spec.dependencies*
dependencies (string|string[]|zpack.Spec|zpack.Spec[], optional)
Expand Down Expand Up @@ -830,9 +837,10 @@ commit (string, optional)

*zpack-Spec.name*
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.
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.

*zpack-Spec.main*
main (string, optional)
Expand Down
8 changes: 4 additions & 4 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

```lua
{
-- Plugin source (provide exactly one)
[1] = "user/repo", -- Plugin short name. Expands to https://github.com/{user/repo}
-- Plugin source (provide at least one; src > url > dir > [1])
[1] = "user/repo", -- Plugin short name. Expands to https://github.com/{user/repo} when no explicit src/url/dir is given; the plugin name always derives from [1]
src = "https://...", -- Custom git URL or local path
dir = "/path/to/plugin", -- Local plugin directory (lazy.nvim compat, ~ expanded, mapped to src)
url = "https://...", -- Custom git URL (lazy.nvim compat, mapped to src)
url = "https://...", -- Custom git URL (lazy.nvim compat, mapped to src, overrides [1] — e.g. to swap in a fork)

-- Dependencies
dependencies = string|string[]|zpack.Spec|zpack.Spec[], -- Plugin dependencies
Expand Down Expand Up @@ -53,7 +53,7 @@
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 the name derived from [1] / the source)
main = "module.name", -- Explicit main module (auto-detected if not set)
module = false, -- Disable module-based lazy loading for this plugin

Expand Down
2 changes: 1 addition & 1 deletion lua/zpack/api/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ local function entry_to_info(src, entry)
if not plugin then
local utils = require('zpack.utils')
return {
name = entry.merged_spec.name or utils.derive_name_from_src(src),
name = utils.resolve_plugin_name(entry.merged_spec, src),
src = src,
status = 'installing',
lazy = lazy_flag,
Expand Down
13 changes: 5 additions & 8 deletions lua/zpack/commands.lua
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,13 @@ local function names_for_bulk_update()
local pinned_names = {}
local zpack_pinned_by_user = false
local has_pin = false
for _, entry in pairs(state.spec_registry) do
for src, entry in pairs(state.spec_registry) do
if entry.merged_spec and entry.merged_spec.pin == true then
has_pin = true
local name = entry.merged_spec.name
or (entry.plugin and entry.plugin.spec and entry.plugin.spec.name)
if name then
pinned_names[name] = true
if name == 'zpack.nvim' then
zpack_pinned_by_user = true
end
local name = util.resolve_plugin_name(entry.merged_spec, src)
pinned_names[name] = true
if name == 'zpack.nvim' then
zpack_pinned_by_user = true
end
end
end
Expand Down
17 changes: 12 additions & 5 deletions lua/zpack/import.lua
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ local function resolve_dev_path(spec)
-- with vs. without the trailing slash on `dev.path`.
local dev_base = vim.fn.expand(dev_path_opt):gsub('/+$', '')
-- `spec.name` first: lazy.nvim parity for overriding the derived dir.
-- This chain derives the checkout NAME ([1] before src, unlike
-- normalize_source's src-first source precedence): a fork url still
-- checks out under the upstream short name.
local source_for_name = spec.name or spec[1] or spec.src or spec.url or spec.dir
if type(source_for_name) ~= 'string' then
require('zpack.utils').schedule_notify(
Expand Down Expand Up @@ -51,30 +54,34 @@ local function resolve_dev_path(spec)
return dev_path
end

---Normalize plugin source using priority: dev > [1] > src > url > dir
---Normalize plugin source using priority: dev > src > url > dir > [1]
---lazy.nvim parity: `[1]` is only a fallback for deriving the URL, so an
---explicit src/url/dir wins — `{ 'user/repo', url = fork }` installs the fork.
---fold_rank in merge.lua mirrors this precedence and its explicit-field set;
---update both together.
---@param spec zpack.Spec
---@return string|nil source URL/path, or nil if invalid
---@return string|nil error message if validation fails
local normalize_source = function(spec)
-- lazy.nvim spec parity: `dev = true` rewrites the source to a local
-- checkout under `config.dev.path` (default '~/projects'). When the local
-- directory is missing and `fallback = true` is set, resolution falls
-- through to the regular [1]/src/url/dir chain below.
-- through to the regular src/url/dir/[1] chain below.
local dev_path = resolve_dev_path(spec)
if dev_path then
return dev_path
end
-- Each source field must be a string; a non-string (over-nested spec or
-- typo) would crash the `[1]` concat or `dir` expand. Skip rather than
-- abort setup().
if type(spec[1]) == 'string' then
return 'https://github.com/' .. spec[1]
elseif type(spec.src) == 'string' then
if type(spec.src) == 'string' then
return spec.src
elseif type(spec.url) == 'string' then
return spec.url
elseif type(spec.dir) == 'string' then
return vim.fn.expand(spec.dir)
elseif type(spec[1]) == 'string' then
return utils.github_url(spec[1])
else
return nil, "spec must provide one of: [1], src, dir, or url"
end
Expand Down
155 changes: 148 additions & 7 deletions lua/zpack/merge.lua
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,147 @@ local function prune_disabled(state)
end
end

---Move every dependency-graph edge touching old_src onto new_src, in both
---directions. Edges that would become self-loops (old ↔ new) are dropped.
local function rekey_graph_edges(state, old_src, new_src)
local outgoing = state.dependency_graph[old_src]
if outgoing then
local target = state.dependency_graph[new_src] or {}
for dep_src in pairs(outgoing) do
local rdeps = state.reverse_dependency_graph[dep_src]
if rdeps then
rdeps[old_src] = nil
end
if dep_src ~= new_src then
target[dep_src] = true
if rdeps then
rdeps[new_src] = true
end
elseif rdeps and next(rdeps) == nil then
-- Dropping the old→new self-loop can empty new_src's reverse set;
-- clear it to preserve the no-empty-sets invariant below.
state.reverse_dependency_graph[dep_src] = nil
end
end
state.dependency_graph[new_src] = target
state.dependency_graph[old_src] = nil
end
local incoming = state.reverse_dependency_graph[old_src]
if incoming then
local target = state.reverse_dependency_graph[new_src] or {}
for parent_src in pairs(incoming) do
local deps = state.dependency_graph[parent_src]
if deps then
deps[old_src] = nil
end
if parent_src ~= new_src then
target[parent_src] = true
if deps then
deps[new_src] = true
end
end
end
-- The reverse graph must hold no empty sets: strip_outgoing_edges reads a
-- set emptying as its "newly orphaned" signal. The forward graph tolerates
-- empty sets (import pre-creates them), hence no guard on the write above.
if next(target) ~= nil then
state.reverse_dependency_graph[new_src] = target
end
state.reverse_dependency_graph[old_src] = nil
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
for _, spec in ipairs(entry.specs) do
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)
end
end
return latest
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
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
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
end
end
table.sort(state.spec_registry[winner].specs, function(a, b)
return (a._import_order or 0) < (b._import_order or 0)
end)
end
end
end
end

---Pre-compute merged_spec for all entries in the registry
---Creates pack_specs with merged data and returns sorted vim_packs array
---@return vim.pack.Spec[]
Expand All @@ -384,6 +525,8 @@ function M.resolve_all()
local utils = require('zpack.utils')
local lazy = require('zpack.lazy')

coalesce_shorthand_overrides(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)
Expand Down Expand Up @@ -441,15 +584,13 @@ function M.resolve_all()
-- 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.
-- resolve_plugin_name here and in the pack_spec below are the same call,
-- so this pre-seed agrees with the registration callback's rewrite (which
-- reads pack_spec.name) by construction.
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
state.name_to_src[utils.resolve_plugin_name(entry.merged_spec, src)] = src
end
end
-- Drop the pre-load lazy-parent cache so the registration callback
Expand All @@ -469,7 +610,7 @@ function M.resolve_all()
local pack_spec = {
src = src,
version = utils.normalize_version(entry.merged_spec),
name = entry.merged_spec.name or utils.derive_name_from_src(src),
name = utils.resolve_plugin_name(entry.merged_spec, src),
}
table.insert(vim_packs, pack_spec)
state.src_to_pack_spec[src] = pack_spec
Expand Down
3 changes: 1 addition & 2 deletions lua/zpack/registration.lua
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ M.register_all = function(ctx)
-- they can never load.
local dep_entry = state.spec_registry[dep_src]
if dep_entry and dep_entry.merged_spec then
table.insert(dep_names, dep_entry.merged_spec.name
or utils.derive_name_from_src(dep_src))
table.insert(dep_names, utils.resolve_plugin_name(dep_entry.merged_spec, dep_src))
end
end
table.sort(dep_names)
Expand Down
10 changes: 5 additions & 5 deletions lua/zpack/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@
---@alias zpack.FtValue string|string[]

---@class zpack.Spec
---@field [1]? string Plugin short name (e.g., "user/repo"). Required if src/dir/url not provided
---@field src? string Custom git URL or local path. Required if [1]/dir/url not provided
---@field dir? string Local plugin directory path (lazy.nvim compat). Mapped to src
---@field url? string Custom git URL (lazy.nvim compat). Mapped to src
---@field name? string Custom plugin name. Overrides auto-derived name from URL
---@field [1]? string Plugin short name (e.g., "user/repo"). Fallback source when src/dir/url not provided
---@field src? string Custom git URL or local path. Highest-priority source, overrides [1]
---@field dir? string Local plugin directory path (lazy.nvim compat). Mapped to src, overrides [1]
---@field url? string Custom git URL (lazy.nvim compat). Mapped to src, overrides [1]
---@field name? string Custom plugin name. Overrides the name derived from [1] (or the source URL)
---@field init? fun(plugin: zpack.Plugin?)
---@field build? false|string|(string|fun(plugin: zpack.Plugin?))[]|fun(plugin: zpack.Plugin?)
---@field enabled? boolean|(fun():boolean)
Expand Down
Loading
Loading
0