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
45 changes: 25 additions & 20 deletions lua/zpack/merge.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ local M = {}

M.OVERRIDE = "override"
M.LIST_EXTEND = "list_extend"
M.DEEP_MERGE = "deep_merge"
M.AND_LOGIC_COND = "and_logic_cond"
M.AND_LOGIC_ENABLED = "and_logic_enabled"

-- `opts` is intentionally absent from this table. It is resolved lazily at
-- load time by `resolve_opts` so that function-form opts (which receive the
-- accumulated opts and may return a replacement) are handled with a single
-- authoritative code path. `merge_specs` skips `opts` entirely; callers that
-- need to know "does any spec contribute opts?" read `entry.has_opts`.
M.field_strategies = {
name = M.OVERRIDE,
main = M.OVERRIDE,
Expand All @@ -26,8 +30,6 @@ M.field_strategies = {
ft = M.LIST_EXTEND,
keys = M.LIST_EXTEND,

opts = M.DEEP_MERGE,

cond = M.AND_LOGIC_COND,
enabled = M.AND_LOGIC_ENABLED,
}
Expand Down Expand Up @@ -176,7 +178,10 @@ function M.merge_specs(base, incoming)
for k in pairs(incoming) do all_keys[k] = true end

for key in pairs(all_keys) do
if internal_fields[key] then
if key == "opts" then
-- Skip: opts is resolved lazily via resolve_opts at load time.
-- See the comment above M.field_strategies.
elseif internal_fields[key] then
result[key] = incoming[key] ~= nil and incoming[key] or base[key]
else
local strategy = M.field_strategies[key] or M.OVERRIDE
Expand All @@ -191,12 +196,6 @@ function M.merge_specs(base, incoming)
result[key] = incoming_val
elseif strategy == M.LIST_EXTEND then
result[key] = extend_unique(to_array(base_val), to_array(incoming_val))
elseif strategy == M.DEEP_MERGE then
if type(base_val) == "table" and type(incoming_val) == "table" then
result[key] = vim.tbl_deep_extend("force", base_val, incoming_val)
else
result[key] = incoming_val
end
elseif strategy == M.AND_LOGIC_COND then
result[key] = merge_and_cond(base_val, incoming_val)
elseif strategy == M.AND_LOGIC_ENABLED then
Expand Down Expand Up @@ -227,19 +226,15 @@ function M.sort_specs(specs)
return sorted
end

---Merge an array of specs in order (lowest priority first)
---Merge an array of specs in order (lowest priority first).
---Always returns a fresh table (never a reference to any input spec), and
---`opts` is always absent from the result — it is resolved at load time via
---`resolve_opts` so function-form opts are handled consistently.
---@param specs zpack.Spec[]
---@return zpack.Spec
function M.merge_spec_array(specs)
if #specs == 0 then
return {}
end
if #specs == 1 then
return specs[1]
end

local result = specs[1]
for i = 2, #specs do
local result = {}
for i = 1, #specs do
result = M.merge_specs(result, specs[i])
end
return result
Expand Down Expand Up @@ -368,6 +363,16 @@ function M.resolve_all()
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)
-- `opts` is deliberately not stored on merged_spec; compute a boolean
-- summary once here so existence checks in plugin_loader / startup can
-- answer "does any spec contribute opts?" without re-scanning.
entry.has_opts = false
for _, s in ipairs(entry.sorted_specs) do
if s.opts ~= nil then
entry.has_opts = true
break
end
end
-- cond_result is intentionally left nil here. It is written later by
-- registration.register_all's vim.pack.add load callback (which needs
-- the live plugin arg for function-form conds). Readers must treat
Expand Down
18 changes: 5 additions & 13 deletions lua/zpack/plugin_loader.lua
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,9 @@ end
---@param spec zpack.Spec
function M.run_config(src, plugin, spec)
local registry_entry = state.spec_registry[src]
local resolved_opts
if registry_entry.sorted_specs and #registry_entry.sorted_specs > 1 then
resolved_opts = merge.resolve_opts(registry_entry.sorted_specs, plugin)
else
local opts = spec.opts
if type(opts) == "function" then
resolved_opts = opts(plugin, {}) or {}
else
resolved_opts = opts or {}
end
end
-- resolve_opts is the single authoritative path for opts; it walks
-- sorted_specs fresh so function-form opts compose correctly.
local resolved_opts = merge.resolve_opts(registry_entry.sorted_specs or {}, plugin)
local main = utils.resolve_main(plugin, spec)

if type(spec.config) == "function" then
Expand All @@ -56,7 +48,7 @@ function M.run_config(src, plugin, spec)
if not ok then
utils.schedule_notify(("Failed to run config for %s: %s"):format(src, err), vim.log.levels.ERROR)
end
elseif spec.config == true or spec.opts ~= nil then
elseif spec.config == true or registry_entry.has_opts then
if not main then
utils.schedule_notify(
("Could not determine main module for %s. Please set `main` explicitly or use `config = function() ... end`.")
Expand Down Expand Up @@ -135,7 +127,7 @@ M.process_spec = function(pack_spec, opts)
end
end

if spec.config or spec.opts ~= nil then
if spec.config or registry_entry.has_opts then
M.run_config(pack_spec.src, plugin, spec)
end

Expand Down
2 changes: 1 addition & 1 deletion lua/zpack/startup.lua
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ M.process_all = function(ctx)

local entry = state.spec_registry[pack_spec.src]
local spec = entry.merged_spec
if spec.config or spec.opts ~= nil then
if spec.config or entry.has_opts then
loader.run_config(pack_spec.src, entry.plugin, spec)
end
end
Expand Down
1 change: 1 addition & 0 deletions lua/zpack/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
---@field specs zpack.Spec[]
---@field sorted_specs? zpack.Spec[]
---@field merged_spec? zpack.Spec
---@field has_opts? boolean Whether any spec in this entry contributes opts; authoritative existence check
---@field plugin zpack.Plugin?
---@field load_status zpack.LoadStatus
---@field enabled_result? boolean
Expand Down
8 changes: 5 additions & 3 deletions tests/is_single_spec_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ return function()
local entry = state.spec_registry['https://github.com/test/plugin']

helpers.assert_not_nil(entry, "plugin should be registered")
helpers.assert_equal(entry.merged_spec.opts.foo, true, "opts should be preserved")
helpers.assert_true(entry.has_opts, "entry should record that opts were contributed")
helpers.assert_equal(entry.sorted_specs[1].opts.foo, true, "opts should be preserved")

helpers.cleanup_test_env()
end)
Expand Down Expand Up @@ -64,8 +65,9 @@ return function()
local dep_entry = state.spec_registry['https://github.com/test/dep']

helpers.assert_not_nil(dep_entry, "dependency should be registered")
helpers.assert_not_nil(dep_entry.merged_spec.opts, "opts should be preserved on dependency")
helpers.assert_equal(dep_entry.merged_spec.opts.select.lookahead, true,
helpers.assert_true(dep_entry.has_opts, "dep entry should record that opts were contributed")
helpers.assert_not_nil(dep_entry.sorted_specs[1].opts, "opts should be preserved on dependency")
helpers.assert_equal(dep_entry.sorted_specs[1].opts.select.lookahead, true,
"opts fields should be preserved")
helpers.assert_not_nil(dep_entry.merged_spec.init, "init should be preserved on dependency")

Expand Down
19 changes: 14 additions & 5 deletions tests/merge_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -251,18 +251,27 @@ return function()
end)

helpers.describe("Merge Module Unit Tests", function()
helpers.test("merge_specs deep merges opts", function()
helpers.test("merge_specs skips opts (resolved lazily by resolve_opts)", function()
helpers.setup_test_env()
local merge = require('zpack.merge')

-- opts is intentionally not collapsed by merge_specs. It is resolved
-- at load time by resolve_opts so function-form opts compose correctly
-- with a single authoritative code path. See merge.lua field_strategies
-- comment. Callers that need the deep-merged value should invoke
-- merge.resolve_opts(sorted_specs, plugin).
local base = { opts = { a = 1, nested = { x = 1 } } }
local incoming = { opts = { b = 2, nested = { y = 2 } } }
local result = merge.merge_specs(base, incoming)

helpers.assert_equal(result.opts.a, 1)
helpers.assert_equal(result.opts.b, 2)
helpers.assert_equal(result.opts.nested.x, 1)
helpers.assert_equal(result.opts.nested.y, 2)
helpers.assert_nil(result.opts, "merge_specs must never populate opts")

-- resolve_opts is the authoritative path and deep-merges table-form opts.
local resolved = merge.resolve_opts({ base, incoming }, {})
helpers.assert_equal(resolved.a, 1)
helpers.assert_equal(resolved.b, 2)
helpers.assert_equal(resolved.nested.x, 1)
helpers.assert_equal(resolved.nested.y, 2)

helpers.cleanup_test_env()
end)
Expand Down
21 changes: 13 additions & 8 deletions tests/opts_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ local helpers = require('helpers')

return function()
helpers.describe("Plugin opts and auto-setup", function()
helpers.test("opts table is stored in spec", function()
helpers.test("opts table is recorded on the registry entry", function()
helpers.setup_test_env()

require('zpack').setup({
Expand All @@ -18,15 +18,18 @@ return function()
helpers.flush_pending()
local state = require('zpack.state')
local src = 'https://github.com/test/plugin'
local spec = state.spec_registry[src].merged_spec
helpers.assert_not_nil(spec.opts, "opts should be stored")
helpers.assert_equal(spec.opts.enabled, true)
helpers.assert_equal(spec.opts.theme, 'dark')
local entry = state.spec_registry[src]
helpers.assert_true(entry.has_opts, "has_opts should be true")
-- opts is intentionally not stored on merged_spec; the raw value lives
-- on sorted_specs and is resolved at load time via resolve_opts.
helpers.assert_nil(entry.merged_spec.opts, "merged_spec.opts must always be nil")
helpers.assert_equal(entry.sorted_specs[1].opts.enabled, true)
helpers.assert_equal(entry.sorted_specs[1].opts.theme, 'dark')

helpers.cleanup_test_env()
end)

helpers.test("opts function is stored in spec", function()
helpers.test("opts function is recorded on the sorted spec", function()
helpers.setup_test_env()
local opts_fn = function() return { test = true } end

Expand All @@ -43,8 +46,10 @@ return function()
helpers.flush_pending()
local state = require('zpack.state')
local src = 'https://github.com/test/plugin'
local spec = state.spec_registry[src].merged_spec
helpers.assert_equal(type(spec.opts), 'function')
local entry = state.spec_registry[src]
helpers.assert_true(entry.has_opts, "has_opts should be true for function-form opts")
helpers.assert_nil(entry.merged_spec.opts, "merged_spec.opts must always be nil")
helpers.assert_equal(type(entry.sorted_specs[1].opts), 'function')

helpers.cleanup_test_env()
end)
Expand Down
Loading