8000 feat(merge): support opts_extend by zuqini · Pull Request #46 · zuqini/zpack.nvim · GitHub
[go: up one dir, main page]

Skip to content

feat(merge): support opts_extend - #46

Merged
zuqini merged 7 commits into
mainfrom
opts-extend-45
Oct 30, 2026
Merged

zuqini merged 7 commits into
mainfrom
opts-extend-45

Conversation

@zuqini
@zuqini zuqini commented Oct 29, 2026 •
Copy link
Copy Markdown
Owner

Adds lazy.nvim's opts_extend: keys in opts whose lists get appended to instead of replaced when several specs for the same plugin set opts. Per-language files can each add to nvim-treesitter's ensure_installed without an opts function:

-- plugins/treesitter.lua
{ 'nvim-treesitter/nvim-treesitter', opts_extend = { 'ensure_installed' }, opts = { ensure_installed = { 'bash', 'c' } } }
-- plugins/rust.lua
{ 'nvim-treesitter/nvim-treesitter', opts = { ensure_installed = { 'rust' } } }
-- ensure_installed ends up with bash, c and rust

As in lazy.nvim, opts still merge through vim.tbl_deep_extend and only the lists at each extend path are concatenated afterwards, so plugins without opts_extend merge exactly as before. Dotted keys reach nested lists ('sources.default') and duplicates are kept. It differs from lazy.nvim on purpose:

  • opts_extend from 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.
  • Where lazy.nvim drops data, zpack keeps it: two dicts at an extend key stay deep-merged instead of becoming {}, and a table with named keys (which-key's spec = { mode = { 'n', 'x' }, { ... } }) is appended as one entry, so its mode only applies to its own mappings.
  • Specs pulled in through dependencies append before top-level ones (zpack's existing merge order); lazy.nvim appends in import order.

Two related fixes, as separate commits:

  • Opts are copied before they are handed out. A spec's nested tables reached opts functions and 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 before config/setup(). Tables with a metatable (other than vim.empty_dict()'s), functions and userdata are still passed by reference.
  • Specs declared under a removed plugin's dependencies are dropped. When a plugin is disabled or skipped as optional, 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' dependencies stays while either plugin does.

Also: validate_spec checks that opts_extend is a string or a list of strings without empty dot segments; LuaLS annotation, vimdoc entry, spec blocks and a lazy.nvim migration note in docs/tips.md.

Closes #45

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
zuqini merged commit b23ed1f into main Oct 30, 2026
4 checks passed
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
@zuqini
zuqini deleted the opts-extend-45 branch November 6, 2026 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Support opts_extend

1 participant

0