Skip to content

feat: close lazy.nvim parity gaps across lazy triggers - #27

Merged
zuqini merged 8 commits into
mainfrom
lazy-parity-gap-close
May 24, 2026
Merged

feat: close lazy.nvim parity gaps across lazy triggers#27
zuqini merged 8 commits into
mainfrom
lazy-parity-gap-close

Conversation

@zuqini

@zuqini zuqini commented May 24, 2026

Copy link
Copy Markdown
Owner

Summary

Closes lazy.nvim parity gaps across the lazy-trigger surface (cmd / keys / event / ft) surfaced via 8 successive cross-handler review passes. Net ~2000 LOC across lua/zpack/{keymap,merge,utils,types}.lua, lua/zpack/lazy_trigger/*.lua, tests, and docs. New shared helpers: util.install_on_ft, util.latch_first_call, util.normalize_ft_scope, keymap.try_map, event.on_ui_enter_or_now.

Notable behavioral fixes:

  • keys.ft registers a FileType autocmd + sweeps existing matching buffers (was global up-front).
  • Abbreviation modes (ia/ca/!a) append <C-]> so first press actually expands.
  • <Nop> rhs installs a real <Nop> and skips the proxy; with ft, scoped buffer-local.
  • Cmd proxy exposes complete so tab-completion loads the plugin.
  • nvim#25526 nested-fire defense (latch_first_call) on all once = true autocmds.
  • ftdetect/*.{vim,lua} sourced inside augroup filetypedetect so ft-lazy plugins detect properly.
  • User VeryLazy emit on UIEnter + post-UIEnter fast path for re-setup().
  • merge.get_unique_key and create_key_id include ft + buffer so disjoint-scope same-lhs specs both survive.
  • apply_keys honors key.ft (real keymap installs buffer-locally).
  • utils.source_ftdetect_files augroup leak fixed (split |-chain into statements).
  • Per-key pcall in apply_keys (both branches) so one malformed spec can't strand siblings.
  • Self-deleting ft-scoped proxy autocmd once all claiming plugins load.
  • latch_first_call retries on throw (sets done after callback returns).

Test plan

  • nvim -u NONE -l tests/busted.lua — 448/448 pass
  • luacheck lua/ tests/ — 0 warnings / 0 errors
  • lua-language-server --check — warning count unchanged from baseline
  • Multi-reviewer parity audit (bug-finder + structural + design) — 0 critical findings
  • Accepted tradeoffs recorded in .claude/review-decisions.md

zuqini added 8 commits May 23, 2026 23:33
Five lazy.nvim parity gaps surfaced via cross-handler review (beads
sdu/6n2/n3g/8k9/eyo):

* keys.ft (zpack_nvim-n3g): filetype-scoped lazy keys now register a
  FileType autocmd that installs the proxy buffer-locally, instead of
  installing the proxy globally up-front. Matches lazy.nvim
  handler/keys.lua:151-163. The proxy callback deletes the buffer-local
  mapping (not the global one) before re-feeding.
* abbreviation modes (zpack_nvim-sdu): when the proxy fires for a key
  whose mode ends in 'a' (ia/ca/!a), append <C-]> to the re-fed lhs so
  the abbreviation actually expands on the first press (matches
  handler/keys.lua:135-138). The post-load maparg check is taught to
  query abbreviations via maparg's {abbr}=true on the base mode, since
  the {mode} arg does not accept the 'a' suffix.
* <Nop> rhs (zpack_nvim-eyo): a KeySpec whose [2] is '<Nop>' (any case)
  or '' now installs a real <Nop> keymap and skips the proxy entirely,
  matching Util.is_nop in handler/keys.lua:117-119. Pressing the key
  never loads the plugin -- useful for suppressing default mappings.
* cmd tab-completion (zpack_nvim-6n2): the lazy cmd proxy now exposes a
  `complete` callback that loads the claiming plugins so the real
  command's completions take over on the first <Tab>, matching
  handler/cmd.lua:53-57.
* nvim#25526 (zpack_nvim-8k9): each event/UIEnter autocmd registration
  captures a local `done` boolean and bails on the second fire in the
  same tick. Defense in depth alongside the existing load_status gate;
  matches handler/event.lua:67,73,76.

Also:

* lua/zpack/keymap.lua: SUPPORTED_OPTS gains `buffer` so the lazy
  trigger can install buffer-local proxies via the shared map helper.
* lua/zpack/types.lua: zpack.KeySpec gains `ft` and `buffer`.
* doc/zpack.txt, docs/spec.md: KeySpec reference documents `ft` and the
  <Nop> shortcut.
* tests/lazy_keys_test.lua, lazy_cmd_test.lua, lazy_event_test.lua:
  twelve new tests cover the parity behaviors above.
Follow-up to 77fd9a4. Cross-handler review against lazy.nvim flagged
four additional gaps; the cmd-proxy count/nargs and <Nop>+ft gaps are
deferred to their own beads (zpack_nvim-kss/7p7/wiq).

* ft.lua done guard: mirror event.lua's per-callback `done` flag so the
  ft trigger is also protected against nvim#25526 (the plugin's own
  `ftplugin/*` sourced during packadd can nest-fire FileType on the same
  buffer before load_status flips).
* <Nop> rhs (keys.lua): strip `expr` / `replace_keycodes` from the opts
  passed to keymap.map. The rhs is the literal string `<Nop>`, so
  evaluating it as a vimscript expression contradicts the install intent.
* ftdetect sourcing: utils.source_ftdetect_files runs each lazy plugin's
  `ftdetect/*.{vim,lua}` inside `augroup filetypedetect` at ft-trigger
  registration. Without this, a plugin like rust-lang/rust.vim specced
  as `ft = 'rust'` silently never loads on `:edit foo.rs` because the
  filetype-detection rules are deferred behind `:packadd`.
* User VeryLazy emission: lazy.fire_very_lazy schedules a `User VeryLazy`
  dispatch on UIEnter (or immediately when `vim_did_enter == 1`), so
  configs hooking `autocmd User VeryLazy` for post-setup delayed init
  work the same way they do under lazy.nvim. Registered after per-plugin
  UIEnter handlers so VeryLazy plugins are loaded before user code runs.

Tests:
* Four new regressions pinning the behaviors above.
* tests/lazy_event_test.lua "re-fire falls back to pattern='*'" was
  patched: the new `User VeryLazy` emit consumes once-true `User *`
  proxies during flush, so the mock now installs before setup() and the
  explicit dispatch runs before the scheduled emit.
Three behavioral fixes plus a small refactor on top of 66945e7. Each
fix closes a gap a second-pass review flagged in the prior two commits.

* VeryLazy post-UIEnter fast-path (lazy_trigger/event.lua): when
  setup() runs after vim has already entered (`:luafile`, config
  reload, `vim_did_enter == 1`), schedule `try_process_spec` for
  VeryLazy plugins directly instead of registering a UIEnter autocmd
  that will never fire. Without this, `event = 'VeryLazy'` plugins
  silently failed to load before fire_very_lazy's User VeryLazy emit,
  breaking the inline contract "Registered AFTER per-plugin UIEnter
  handlers so VeryLazy plugins are loaded by the time User VeryLazy
  fires" for any path that re-enters setup().
* merge.get_unique_key includes ft (merge.lua): two specs declaring
  the same lhs/mode but disjoint ft scopes (`{ '<leader>x', ...,
  ft = 'lua' }` + `{ '<leader>x', ..., ft = 'rust' }`) used to dedup
  to one in extend_unique, so the second was silently dropped before
  lazy_trigger/keys.lua's ft-aware dedup ever saw it. Aligns the merge
  key with keys.lua's create_key_id (lhs/mode + sorted ft).
* <Nop> + ft scope (lazy_trigger/keys.lua): a `<Nop>` rhs with `ft`
  set used to install globally, masking the key in every buffer
  instead of scoping the suppression. Now registers a FileType
  autocmd that installs the real <Nop> buffer-locally on matching ft,
  matching lazy.nvim. Drops the redundant
  `nop_opts.replace_keycodes = nil` — keymap.map already nulls it
  when expr is unset. Factored install_nop helper so the global and
  ft-scoped install paths share one body.

Refactor:

* util.once_per_tick (utils.lua): wraps a callback so a synchronous
  second invocation no-ops. The nvim#25526 `done = false` block from
  77fd9a4 was duplicated across event.lua (other_events branch),
  ft.lua, and 66945e7's VeryLazy registration; with the new post-
  UIEnter fast-path added in this commit, the pattern would have
  reached four call sites. Lifted into one named helper with the
  guard's reason in the doc-comment. ft.lua's stray-leading-space
  comment indent is fixed as a side effect.

Tests:

* tests/lazy_event_test.lua "VeryLazy event creates UIEnter autocmd"
  rewritten as "VeryLazy plugin loads when setup runs post-UIEnter"
  — the old test pinned the buggy "no fast-path" shape. The new test
  asserts `vim_did_enter == 1` as a prerequisite and verifies
  load_status == "loaded" after flush.
* tests/lazy_keys_test.lua: three new tests pin the <Nop> + ft
  contract (no global keymap installed; buffer-local install on
  matching FileType; non-matching filetypes skipped).
* tests/merge_test.lua: two new tests pin the ft-aware dedup (same
  lhs/mode + disjoint ft → both survive; ft list in different orders
  → dedup).
…le cleanups)

Findings from a third-pass review on top of ca16ec4. Mix of one real
bug (augroup leak), one parity bug that survived the prior passes
(apply_keys + ft), and a handful of small consistency / hygiene
items.

Bug fixes:

* utils.source_ftdetect_files augroup leak (utils.lua): the prior
  shape `vim.cmd('augroup filetypedetect | source %s | augroup END')`
  was a single `|`-chained ex-command — a throw inside `source` aborts
  the chain, so `augroup END` never runs and the current augroup
  silently stays as `filetypedetect`. Every subsequent vimscript
  `autocmd` statement in the same tick (user init.lua snippets,
  later sourced files) then lands in `filetypedetect` instead of the
  default group. Split into three statements so the `augroup END` is
  unconditional. Empirically reproducible before/after; regression
  test added.
* keymap.apply_keys honors `key.ft` (keymap.lua): post-load, the real
  keymap used to install globally even for ft-scoped specs (the prior
  decision deliberately kept apply_keys simple). Concrete failure
  mode: two plugins claiming the same lhs under disjoint ft scopes
  (`{ 'X', cbA, ft = 'lua' }` + `{ 'X', cbB, ft = 'rust' }`) would
  have plugin B's global apply_keys silently overwrite plugin A's
  keymap in every buffer — including plugin A's own ft. Now installs
  via a FileType autocmd (catches future buffers) and iterates
  already-loaded matching buffers (their FileType has already fired).

Parity completion:

* keys proxy / nop honor `key_spec.buffer` (lazy_trigger/keys.lua):
  install_proxy used to hardcode `buffer = buf` (the ft scope buf
  or nil), silently dropping the user's `buffer = true|0|N` intent
  when no ft was set. install_nop overwrote it to nil for the same
  reason. Now `buffer = buf or key_spec.buffer` so unscoped buffer-
  local proxies behave as advertised.
* `ft = {}` falls back to a global proxy (lazy_trigger/keys.lua):
  an empty-table ft used to register a FileType autocmd with
  `pattern = {}`, which matches nothing — the key effectively
  disappeared with no diagnostic. Now normalizes to "no ft scope".

Lifecycle / hygiene:

* ft-scoped proxy autocmd self-deletes once all claiming plugins
  load (lazy_trigger/keys.lua). Before: the autocmd persisted for
  the session and fired no-op on every matching FileType. Now
  captures its own id and `nvim_del_autocmd`s itself when
  `any_pack_pending` first returns false.
* fire_very_lazy moves from lazy.lua to lazy_trigger/event.lua. The
  producer (`User VeryLazy` emit on UIEnter) now lives next to the
  per-plugin `event = 'VeryLazy'` consumer; the synthetic-event
  protocol sits in one file.
* `once_per_tick` → `latch_first_call` (utils.lua + call sites).
  The old name implied a per-tick reset that the implementation
  never had — it's a permanent one-shot latch (callers combine it
  with `once = true` so the autocmd self-deletes after the first
  dispatch). New name describes the behavior; docstring spells out
  the `once = true` dependency for future callers.
* merge.get_unique_key doc-comment reworded: the prior text claimed
  it "matches" lazy_trigger/keys.lua's create_key_id format, but
  the two use different separators on purpose (each is local to its
  module). Now says it mirrors the dedup intent without implying
  format parity.

Docs:

* doc/zpack.txt, docs/spec.md: KeySpec reference picks up the new
  `buffer` field. spec.md picks up the `User VeryLazy` auto-emit
  note that doc/zpack.txt already had. KeySpec.ft doc updated to
  reflect the new "real keymap also buffer-local" contract.
* types.lua: KeySpec.ft field doc updated to match.

Tests:

* tests/source_plugin_files_test.lua: new `describe` block for
  source_ftdetect_files mirroring the source_after_plugin_files
  suite — sources lua file, idempotency, missing-directory, per-file
  pcall on throw, and a dedicated regression for the augroup leak
  (registers a bare `autocmd` after a throwing ftdetect file and
  asserts it does not land in `filetypedetect`).
* tests/lazy_keys_test.lua: empty-string rhs Nop install; cross-ft
  sibling-override regression (verifies post-load keymap stays
  buffer-local); KeySpec.buffer proxy scoping; `ft = {}` fallback.
Two narrow fixes surfaced when reviewing on top of b3e216e. Both are
straightforward symmetry-completions of patterns already established
in the same files.

* keymap.apply_keys per-key pcall now covers the ft branch
  (keymap.lua): the else branch was already pcall-wrapped so a single
  malformed spec couldn't strand its siblings, but the ft branch's
  apply_ft_scoped call sat outside any pcall. A bad key.ft
  (e.g. { 1 }) makes nvim_create_autocmd throw past apply_keys's
  loop; the outer pcall in plugin_loader / startup catches it but
  reports a single "Failed to apply keys" notify, dropping every
  later key in the same spec. Now both branches go through one pcall
  shape with the per-key notify.
* keys.lua <Nop>+ft path sweeps currently-matching buffers
  (lazy_trigger/keys.lua): a buffer already at the ft when setup()
  runs (e.g. `:luafile` reload with a .lua buffer open) wouldn't
  receive the <Nop> until something re-triggered FileType, because
  the new autocmd only catches future fires. Mirrors apply_ft_scoped
  in keymap.lua, which already iterates nvim_list_bufs for the same
  reason.

No tests changed: both fixes harden existing behavior under
conditions the suite doesn't exercise (malformed ft in a key spec;
setup() re-run with buffers already open). The full suite still
passes (442/442); luacheck and lua-language-server warning counts
are unchanged from baseline.
…ch, ft sweep)

- Add `util.install_on_ft` helper unifying the FileType-autocmd +
  matching-buffer-sweep pattern used in three places. Adds the missing
  sweep to the non-Nop ft-scoped lazy proxy so a buffer already at the
  filetype when `setup()` runs (`:luafile %`, config reload) installs
  the proxy instead of waiting for a re-dispatch that never comes.
- Thread `buffer` through `create_key_id` and `merge.get_unique_key`.
  Two plugin sources claiming the same lhs+mode with disjoint `buffer`
  scopes no longer collapse to a single entry whose first-wins `buffer`
  silently misses the other plugin's intended buffer. `buffer = true`
  and `buffer = 0` coerce to the same key (lazy.nvim parity).
- Wrap `fire_very_lazy`'s UIEnter callback in `latch_first_call` so a
  nested UIEnter (nvim#25526) can't emit `User VeryLazy` twice. Drop
  the vestigial `nested = true` on the same autocmd (`vim.schedule`
  doesn't carry nesting).
- `install_nop` now strips `replace_keycodes` explicitly instead of
  relying on `keymap.map`'s internal zeroing — call site owns its opts.
- Tighten three `key.ft` gates to reject the empty string (`ft = ''`)
  so it can't register an autocmd with an unmatchable pattern list.
…l_on_ft contract)

- keymap.try_map: per-key pcall+notify wrapper now used by install_proxy,
  install_nop, apply_ft_scoped, and apply_keys's direct branch.
  install_proxy is throw-safe at the leaf, so install_on_ft's sweep can
  no longer strand an autocmd that spams notify on every future FileType.
- util.normalize_ft_scope: extracted shared predicate; collapses the
  open-coded "non-empty string or non-empty list" check that lived in
  three places across keys.lua and keymap.lua.
- install_on_ft doc-comment: contract (installer catches own throws),
  invariant (synchronous sweep before id return), and glob-pattern
  limitation now documented.
- event.lua on_ui_enter_or_now: shared helper for fire_very_lazy and the
  per-plugin VeryLazy handler so the latch_first_call (nvim#25526) lives
  in one place and the two paths can't drift.
… retry)

Two follow-up cleanups surfaced in the eighth-pass review on top of
0c9de8d. Neither changes observable behavior under current callers; both
close hygiene gaps so future drift is impossible.

* normalize_ft_scope predicate consolidation (merge.lua, keys.lua):
  the "non-empty string or non-empty list" predicate was inlined at
  merge.lua:get_unique_key and keys.lua:ft_key_part, parallel to the
  shared util.normalize_ft_scope helper introduced in 0c9de8d. Both
  sites now route through the helper — site-private format strings
  stay site-private; the predicate becomes single-source. Identical
  observable behavior (keys.lua's caller already passes the normalized
  scope, so the empty-table branch was unreachable).
* latch_first_call retries on throw (utils.lua): the latch used to
  set done=true BEFORE invoking the callback, so a throw on first call
  permanently consumed the latch and silently bailed on every future
  dispatch. Now sets done=true AFTER callback() returns, matching the
  "exactly one successful call" semantics. All current callers use
  try_process_spec (non-throwing), so behavior is identical today; the
  fix makes the helper future-proof for any throwing callback.

Tests: full suite passes (448/448); luacheck and lua-language-server
warning counts unchanged from baseline.
@zuqini
zuqini merged commit 9ed473a into main May 24, 2026
4 checks passed
@zuqini
zuqini deleted the lazy-parity-gap-close branch May 24, 2026 22:39
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.

1 participant