Repository navigation
feat(merge): support opts_extend - #46
Merged
Merged
Conversation
When several specs for one plugin set table-form `opts`, a list-valued
option from a later spec replaces the earlier one. `opts_extend` names the
keys whose lists are concatenated instead, so e.g. per-language files can
each add to nvim-treesitter's `ensure_installed` without an opts function:
{ 'nvim-treesitter/nvim-treesitter', opts_extend = { 'ensure_installed' },
opts = { ensure_installed = { 'bash', 'c', 'diff' } } }
{ 'nvim-treesitter/nvim-treesitter', opts = { ensure_installed = { 'rust' } } }
-- ensure_installed = { 'bash', 'c', 'diff', 'rust' }
As in lazy.nvim, keys may be dotted to reach nested lists
(`'sources.default'`), lists are concatenated without deduplication, and
function-form opts receive the accumulated table and return its
replacement.
Deliberate divergences from lazy.nvim:
- zpack unions `opts_extend` across every spec of the plugin. lazy.nvim
reads only the nearest declaration at or before each fragment, so lists
merged before the declaring fragment are replaced and a later fragment
that redeclares fewer keys stops the rest from concatenating; with the
union the result does not hinge on the order files are imported.
- Where lazy.nvim loses data, zpack deep-merges instead: only plain lists
concatenate, so a dict at an extend key (`servers = { lua_ls = ... }`)
merges as usual rather than collapsing to `{}`, and `{ 'a', 'a.b' }`
keeps `a`'s other keys.
- Lists on an extend path are copied rather than aliased, so an opts
function that appends in place (`vim.list_extend(opts.ensure_installed,
...)`) cannot edit a spec's own opts and duplicate entries on
`:ZPack reload`.
Also:
- validate_spec checks opts_extend is a table of strings; resolve_opts
skips non-string keys instead of throwing
- LuaLS annotation, vimdoc field entry, spec blocks and a lazy.nvim
migration note
Refs #45
resolve_opts handed tables from a spec's table-form opts to the merged
result by reference (as vim.tbl_deep_extend and lazy.nvim's merge do), so
an opts function that appends in place edited the spec itself:
{ opts = { sections = { lualine_x = { 'encoding' } } } }
{ opts = function(_, o) table.insert(o.sections.lualine_x, 'copilot') end }
Each later resolve (`:ZPack reload`, or a retry after a failed load)
started from the edited spec and added 'copilot' again. A plugin's setup()
editing its opts had the same effect.
merge_opts now copies plain tables as it merges them. Tables with a
metatable, functions and userdata are kept by reference, so objects and
vim.empty_dict() survive and vim.deepcopy's userdata error is avoided.
The copy costs a few microseconds per plugin and runs when the plugin's
config does.
- Keep vim.empty_dict() a dict when specs merge it. merge_opts built every
result as a plain `{}`, so two specs setting `settings =
vim.empty_dict()` encoded as `[]` where vim.tbl_deep_extend gave `{}`.
- Append the list entries of a table that also has named keys. which-key's
`spec = { mode = { 'n', 'x' }, { ... } }` failed the list check and
replaced every earlier entry; now the entries are appended and the
named keys merge as usual. lazy.nvim keeps the list entries and drops
the named keys.
- Treat tables with a metatable (other than vim.empty_dict()'s) as objects
when merging too: replaced, not flattened into a plain table.
- Copy tables returned by function-form opts, so a setup() editing them
cannot reach a module-level defaults table and duplicate entries on
`:ZPack reload`.
- Share one copy map per spec, so a table referenced from two opts keys
stays one table, and guard merges in progress, so a cyclic table set by
two specs no longer overflows the stack.
- Leave opts_extend out of merge_specs like opts; resolve_opts reads it
from every spec, while the OVERRIDE fallback kept only the last list.
- validate_spec flags an opts_extend that is not a list and keys with an
empty segment (`'ensure_installed.'`), both of which silently did
nothing.
- Correct the vimdoc and migration notes, which claimed parity with
lazy.nvim beyond plain lists.
When several specs for one plugin set table-form `opts`, a list-valued
option from a later spec replaces the earlier one. `opts_extend` names the
keys whose lists are concatenated instead, so e.g. per-language files can
each add to nvim-treesitter's `ensure_installed` without an opts function:
{ 'nvim-treesitter/nvim-treesitter', opts_extend = { 'ensure_installed' },
opts = { ensure_installed = { 'bash', 'c', 'diff' } } }
{ 'nvim-treesitter/nvim-treesitter', opts = { ensure_installed = { 'rust' } } }
-- ensure_installed = { 'bash', 'c', 'diff', 'rust' }
As in lazy.nvim, opts still merge through vim.tbl_deep_extend and the
lists at each extend path are concatenated afterwards; keys may be dotted
to reach nested lists (`'sources.default'`), lists are concatenated
without deduplication, and function-form opts receive the accumulated
table and return its replacement. Merging outside extend paths is
unchanged.
Deliberate divergences from lazy.nvim:
- `opts_extend` merges like the other list fields (LIST_EXTEND), so the
union over every spec of the plugin applies to every merge. lazy.nvim
reads only the nearest declaration at or before each fragment, so lists
merged before the first declaring fragment replace each other and a
later fragment that redeclares fewer keys stops the rest from
concatenating; with the union the result does not hinge on the order
files are imported.
- Where lazy.nvim loses data, zpack keeps it. Two dicts at an extend key
(`servers = { lua_ls = ... }`) stay deep-merged rather than collapsing
to `{}`, and `{ 'a', 'a.b' }` keeps `a`'s other keys. A table with named
keys, such as which-key's `spec = { mode = 'x', { ... } }`, is appended
as one entry, so its `mode` stays scoped to its own mappings instead of
being dropped.
- nil holes (`cond and 'x' or nil`) are dropped from extend lists, so
ipairs-based installers see every entry, and an empty table counts as
unset, so an empty default elsewhere does not change the result's shape.
Also:
- opts_extend also takes a single key (`opts_extend = 'ensure_installed'`),
which merge_spec_array turns into a list; validate_spec checks it is a
string or a list of strings without empty segments
(`'ensure_installed.'`), and resolve_opts skips other non-string keys
and paths through an object instead of throwing
- LuaLS annotation, vimdoc field entry, spec blocks and a lazy.nvim
migration note
Refs #45
resolve_opts handed tables from a spec's table-form opts to the merged
result by reference (as vim.tbl_deep_extend and lazy.nvim's merge do), so
an opts function that appends in place edited the spec itself:
{ opts = { sections = { lualine_x = { 'encoding' } } } }
{ opts = function(_, o) table.insert(o.sections.lualine_x, 'copilot') end }
Each later resolve (`:ZPack reload`, or a retry after a failed load)
started from the edited spec and added 'copilot' again. A table an opts
function attaches (`o.ensure_installed = servers`) or returns, and a
plugin's setup() editing its opts, had the same effect.
resolve_opts now copies the plain tables of the accumulated opts once at
each hand-off: before every opts function and before returning them for
config/setup(). Copying where opts leave rather than where they are
merged covers every way a reference gets in. One copy map per hand-off
keeps a table shared between two keys, or a cycle, one table. Tables
with a metatable (other than vim.empty_dict()'s), functions and userdata
are kept by reference, which is also how a user passes a table whose
identity matters.
zuqini
added a commit
that referenced
this pull request
Oct 30, 2026
The is_missing call per list entry cost ~2% of resolve_opts with the JIT off (same-process benchmark against #46); inlined, it is level. Refs zpack_nvim-0eg
When a plugin was disabled or skipped as `optional`, prune_disabled
removed its registry entry and any dep-only plugin it orphaned, but specs
it declared under `dependencies` for a plugin that stays (because other
specs enable it) kept configuring that plugin. LazyVim's language extras
do this, e.g. an optional nvim-dap carrying
dependencies = { { 'mason-org/mason.nvim', opts = { ensure_installed = { 'delve' } } } }
With `opts_extend = { 'ensure_installed' }` on mason, delve was installed
even though dap was never enabled; the same spec's keys, event and other
fields merged in too. lazy.nvim deletes a removed plugin's fragments
together with their child fragments.
Each dependency spec now records the specs that declared it, and pruning
removes it (recursively) from the plugins that stay once all of them are
removed, re-merging those plugins. One table listed under two plugins'
`dependencies` stays while either plugin does. A plugin left with no specs, or with only optional ones, is removed
as well, and edges into a removed plugin are cleared so no remaining
parent points at it. Nested `specs` are peers in zpack and are unchanged.
zuqini
added a commit
that referenced
this pull request
Oct 30, 2026
…49) #46 dropped the specs a disabled or optional-skipped plugin declares under `dependencies` only after the same-name fold, the `enabled` decisions and the dependency graph had been derived from every spec, so a dropped spec could still rename a plugin (aborting setup() with vim.pack's "Conflicting `src`"), disable or cascade-disable a plugin that stays, add dependency edges that force-load lazy plugins, or pick the install source. Port lazy.nvim's resolve: import records where each spec table was declared; pruning removes disabled and optional-only plugins with every spec declared under their `dependencies` and `specs`, re-merges only the plugins that lost specs, and builds the dependency graph from what is left. Removals go in a fixed order (settled by a top-level spec first, then by the import order of the specs they rest on, optional-only last), each spec's `enabled` is evaluated once, a plugin keeps its fold-time name and source, and a spec table listed in several places registers once. Refs zpack_nvim-07l
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds lazy.nvim's
opts_extend: keys inoptswhose lists get appended to instead of replaced when several specs for the same plugin setopts. Per-language files can each add to nvim-treesitter'sensure_installedwithout an opts function:As in lazy.nvim, opts still merge through
vim.tbl_deep_extendand only the lists at each extend path are concatenated afterwards, so plugins withoutopts_extendmerge exactly as before. Dotted keys reach nested lists ('sources.default') and duplicates are kept. It differs from lazy.nvim on purpose:opts_extendfrom all specs of a plugin is combined, so the result does not depend on the order files are imported. lazy.nvim reads only the nearest declaration at or before each spec.{}, and a table with named keys (which-key'sspec = { mode = { 'n', 'x' }, { ... } }) is appended as one entry, so itsmodeonly applies to its own mappings.dependenciesappend before top-level ones (zpack's existing merge order); lazy.nvim appends in import order.Two related fixes, as separate commits:
setup()by reference, so appending in place edited the spec itself and every:ZPack reload(or retry after a failed load) added the entries again; lazy.nvim has the same bug. Plain tables are now copied before each opts function and beforeconfig/setup(). Tables with a metatable (other thanvim.empty_dict()'s), functions and userdata are still passed by reference.dependenciesare dropped. When a plugin is disabled or skipped asoptional, the specs it declares for plugins that stay no longer configure them, matching lazy.nvim. LazyVim's language extras rely on this: an optional nvim-dap carrying{ 'mason-org/mason.nvim', opts = { ensure_installed = { 'delve' } } }no longer installs delve when dap is off. A table listed under two plugins'dependenciesstays while either plugin does.Also:
validate_specchecks thatopts_extendis a string or a list of strings without empty dot segments; LuaLS annotation, vimdoc entry, spec blocks and a lazy.nvim migration note indocs/tips.md.Closes #45