feat: close lazy.nvim parity gaps across lazy triggers - #27
Merged
Conversation
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.
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.
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.ftregisters a FileType autocmd + sweeps existing matching buffers (was global up-front).ia/ca/!a) append<C-]>so first press actually expands.<Nop>rhs installs a real<Nop>and skips the proxy; withft, scoped buffer-local.completeso tab-completion loads the plugin.latch_first_call) on allonce = trueautocmds.ftdetect/*.{vim,lua}sourced insideaugroup filetypedetectso ft-lazy plugins detect properly.User VeryLazyemit on UIEnter + post-UIEnter fast path for re-setup().merge.get_unique_keyandcreate_key_idincludeft+bufferso disjoint-scope same-lhs specs both survive.apply_keyshonorskey.ft(real keymap installs buffer-locally).utils.source_ftdetect_filesaugroup leak fixed (split|-chain into statements).apply_keys(both branches) so one malformed spec can't strand siblings.latch_first_callretries on throw (setsdoneafter callback returns).Test plan
nvim -u NONE -l tests/busted.lua— 448/448 passluacheck lua/ tests/— 0 warnings / 0 errorslua-language-server --check— warning count unchanged from baseline.claude/review-decisions.md