From 77fd9a4c9316199d024e250309f9b9ba07e56768 Mon Sep 17 00:00:00 2001 From: zuqini Date: Sat, 23 May 2026 23:33:49 -0700 Subject: [PATCH 1/8] feat: close lazy.nvim parity gap in lazy triggers 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 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. * rhs (zpack_nvim-eyo): a KeySpec whose [2] is '' (any case) or '' now installs a real 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 , 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 shortcut. * tests/lazy_keys_test.lua, lazy_cmd_test.lua, lazy_event_test.lua: twelve new tests cover the parity behaviors above. --- doc/zpack.txt | 16 ++ docs/spec.md | 6 + lua/zpack/keymap.lua | 2 +- lua/zpack/lazy_trigger/cmd.lua | 28 +++- lua/zpack/lazy_trigger/event.lua | 21 ++- lua/zpack/lazy_trigger/keys.lua | 203 ++++++++++++++++------ lua/zpack/types.lua | 2 + tests/lazy_cmd_test.lua | 90 ++++++++++ tests/lazy_event_test.lua | 47 ++++++ tests/lazy_keys_test.lua | 277 +++++++++++++++++++++++++++++++ 10 files changed, 630 insertions(+), 62 deletions(-) diff --git a/doc/zpack.txt b/doc/zpack.txt index 4e20fac..8d91d36 100644 --- a/doc/zpack.txt +++ b/doc/zpack.txt @@ -854,6 +854,7 @@ pattern (string|string[], optional) [2] = function() end, -- RHS function desc = "description", -- Keymap description mode = "n"|{"n","v"}, -- Mode(s), default: "n" + ft = "lua"|{"lua","vim"}, -- FileType scope; install proxy buffer-locally only remap = true|false, -- Allow remapping, default: false nowait = true|false, -- Default: false expr = true|false, -- RHS is an expression, default: false @@ -912,6 +913,21 @@ replace_keycodes (boolean, optional) When `expr` is true, replace keycodes in the resulting string. Default: true when `expr` is true; otherwise unused. + *zpack.KeySpec.ft* +ft (string|string[], optional) + FileType scope (lazy.nvim parity). When set, the lazy + proxy is installed buffer-locally on a matching FileType + event instead of being installed globally up-front. After + the plugin loads, the real keymap is installed globally + via the spec's `[2]` rhs. + + *zpack.KeySpec.nop* +A KeySpec whose `[2]` is `''` (any case) or the empty string is +installed as a real no-op keymap rather than a lazy proxy. The plugin +will not be loaded when the key is pressed, matching lazy.nvim's +`Util.is_nop` behavior — useful for suppressing default mappings the +plugin would otherwise install. + ============================================================================== 11. TIPS & MIGRATION *zpack-tips-and-migration* diff --git a/docs/spec.md b/docs/spec.md index 1eaae11..e32151b 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -83,6 +83,7 @@ The plugin data object passed to hooks and trigger functions: [2] = function() end, -- RHS function desc = "description", -- Keymap description mode = "n"|{"n","v"}, -- Mode(s), default: "n" + ft = "lua"|{"lua","vim"}, -- FileType scope; proxy installs buffer-locally only remap = true|false, -- Allow remapping, default: false nowait = true|false, -- Default: false expr = true|false, -- RHS is an expression, default: false @@ -92,6 +93,11 @@ The plugin data object passed to hooks and trigger functions: } ``` +A KeySpec whose `[2]` rhs is `` (any case) or the empty string is +installed as a real no-op keymap rather than a lazy proxy — pressing the key +never loads the plugin. Useful for suppressing default mappings the plugin +would otherwise install. Matches lazy.nvim's `Util.is_nop` behavior. + ### zpack.PluginInfo Reference Snapshot of a registered plugin returned by the public API functions under `require('zpack.api')`. Treat as read-only. See `:help zpack-public-api` or [docs/public_api.md](public_api.md) for the full reference, including stability guarantees and field-level docs. diff --git a/lua/zpack/keymap.lua b/lua/zpack/keymap.lua index b0f151b..7dcfed9 100644 --- a/lua/zpack/keymap.lua +++ b/lua/zpack/keymap.lua @@ -2,7 +2,7 @@ local util = require('zpack.utils') local M = {} -local SUPPORTED_OPTS = { 'desc', 'remap', 'nowait', 'expr', 'silent', 'replace_keycodes' } +local SUPPORTED_OPTS = { 'desc', 'remap', 'nowait', 'expr', 'silent', 'replace_keycodes', 'buffer' } ---@param lhs string ---@param rhs string|fun() diff --git a/lua/zpack/lazy_trigger/cmd.lua b/lua/zpack/lazy_trigger/cmd.lua index baa1aaa..e15129d 100644 --- a/lua/zpack/lazy_trigger/cmd.lua +++ b/lua/zpack/lazy_trigger/cmd.lua @@ -32,18 +32,25 @@ M.setup = function(registered_pack_specs) -- is silently dropped on the first lazy invocation of a register-accepting -- command — subsequent calls go through the real command directly. for cmd, pack_specs in pairs(cmd_to_pack_specs) do - vim.api.nvim_create_user_command(cmd, function(cmd_args) + -- Loading the claiming plugins tears down the proxy and lets the real + -- command's callback/complete take over. Shared by the invocation + -- callback and the tab-completion callback so first-tab completions + -- come from the real command, not an empty proxy. + local function load_plugins() pcall(vim.api.nvim_del_user_command, cmd) - local any_ok = false for _, pack_spec in ipairs(pack_specs) do if loader.try_process_spec(pack_spec) then any_ok = true end end + return any_ok + end + + vim.api.nvim_create_user_command(cmd, function(cmd_args) -- Proxy already self-deleted; nvim_cmd would error with "Not an -- editor command" on top of the per-plugin load-failure notify. - if not any_ok then + if not load_plugins() then return end @@ -62,7 +69,20 @@ M.setup = function(registered_pack_specs) if not ok then util.schedule_notify(("Failed to re-fire :%s: %s"):format(cmd, tostring(err)), vim.log.levels.ERROR) end - end, { nargs = '*', bang = true, count = -1 }) + end, { + nargs = '*', + bang = true, + count = -1, + -- Tabbing at the cmdline loads the plugin so the real command's + -- complete handler can return actual completions on the first press, + -- matching lazy.nvim's UX (handler/cmd.lua complete callback). + complete = function(_, line) + if not load_plugins() then + return {} + end + return vim.fn.getcompletion(line, 'cmdline') + end, + }) end end diff --git a/lua/zpack/lazy_trigger/event.lua b/lua/zpack/lazy_trigger/event.lua index b4f27e1..b8874b6 100644 --- a/lua/zpack/lazy_trigger/event.lua +++ b/lua/zpack/lazy_trigger/event.lua @@ -76,7 +76,14 @@ M.setup = function(pack_spec, spec, event) if has_very_lazy then -- VeryLazy is synthetic (UIEnter-only); no real event to re-fire. + -- `done` guards against the same `once = true` autocmd firing twice + -- in the same tick (https://github.com/neovim/neovim/issues/25526). + local done = false util.autocmd("UIEnter", function() + if done then + return + end + done = true vim.schedule(function() loader.try_process_spec(pack_spec) end) @@ -84,11 +91,17 @@ M.setup = function(pack_spec, spec, event) end if #other_events > 0 then + local done = false util.autocmd(other_events, function(ev) - -- Skip when a sibling event/ft has already loaded (or is mid-load, - -- when plugin/ files synchronously fire a matching autocmd during - -- packadd). "loaded" prevents double-firing via refire's FileType - -- branch; "loading" prevents spurious "Circular dependency" notify. + -- `done` guards against nvim#25526 (same `once = true` autocmd + -- firing twice in the same tick). Set before any further work so + -- the second fire bails before refire.exec can double-fire user + -- autocmds. The load_status gate below handles other races (sibling + -- event/ft already loaded, plugin/ files re-entering synchronously). + if done then + return + end + done = true local entry = state.spec_registry[pack_spec.src] if entry and entry.load_status ~= "pending" then return diff --git a/lua/zpack/lazy_trigger/keys.lua b/lua/zpack/lazy_trigger/keys.lua index 7121f57..a4bedd2 100644 --- a/lua/zpack/lazy_trigger/keys.lua +++ b/lua/zpack/lazy_trigger/keys.lua @@ -5,12 +5,118 @@ local loader = require('zpack.plugin_loader') local M = {} ----Create a unique key identifier from lhs and mode +---@param ft any +---@return string +local ft_key_part = function(ft) + if type(ft) ~= 'string' and type(ft) ~= 'table' then + return '' + end + local ft_list = util.normalize_string_list(ft) --[[@as string[] ]] + local sorted = { unpack(ft_list) } + table.sort(sorted) + return '-ft:' .. table.concat(sorted, ',') +end + +---Create a unique key identifier from lhs, mode, and (optional) ft scope. ---@param lhs string The key mapping (e.g., "ff") ---@param mode string The mode (e.g., "n", "v") +---@param ft string|string[]|nil Optional filetype scope ---@return string Unique identifier -local create_key_id = function(lhs, mode) - return lhs .. '-' .. mode +local create_key_id = function(lhs, mode, ft) + return lhs .. '-' .. mode .. ft_key_part(ft) +end + +---@param rhs any +---@return boolean +local is_nop_rhs = function(rhs) + return type(rhs) == 'string' and (rhs == '' or rhs:lower() == '') +end + +---Check whether a mapping for `lhs` is currently installed in `mode`. +---Abbreviation modes (ia/ca/!a) are queried via maparg's {abbr}=true on the +---base mode, since maparg's {mode} arg does not accept the 'a' suffix. +---@param lhs string +---@param mode string +---@return boolean +local mapping_present = function(lhs, mode) + if mode:sub(-1) == 'a' then + local base = mode:sub(1, -2) + local m = vim.fn.maparg(lhs, base, true, true) + return type(m) == 'table' and next(m) ~= nil + end + return vim.fn.maparg(lhs, mode) ~= '' +end + +---Install a (buffer-local when `buf` is non-nil) proxy that lazy-loads the +---plugins claiming this lhs on first press. +---@param key_info table +---@param buf? integer +local install_proxy = function(key_info, buf) + local lhs = key_info.key_spec[1] + local key_spec = key_info.key_spec + keymap.map(lhs, function() + -- Mirror the install scope: a global proxy must delete the global + -- mapping; a buffer-local proxy must delete the buffer-local one + -- (otherwise vim.keymap.del finds nothing and the stale buffer-local + -- proxy fires forever on the re-fed lhs). + if buf then + pcall(vim.keymap.del, key_info.split_mode, lhs, { buffer = 0 }) + else + pcall(vim.keymap.del, key_info.split_mode, lhs) + end + local any_ok = false + for _, pack_spec in ipairs(key_info.pack_specs) do + if loader.try_process_spec(pack_spec) then + any_ok = true + end + end + -- Proxy already self-deleted; if no plugin loaded, feeding lhs would + -- type it literally into the buffer. + if not any_ok then + return + end + -- A malformed key spec is pcall-swallowed by apply_keys, so the lhs + -- may end up unmapped. Skip the re-feed unless the spec expected a + -- real keymap — a nil-rhs spec (e.g. `{ 'i', mode = 'o' }`) is the + -- "load + fall through to native binding" pattern and must still feed. + if key_info.key_spec[2] ~= nil + and not mapping_present(lhs, key_info.split_mode) then + return + end + -- Abbreviation modes (ia/ca/!a) need appended on the re-fed lhs + -- to actually expand the abbreviation on the first triggering press. + local feed_lhs = key_info.split_mode:sub(-1) == 'a' and (lhs .. '') or lhs + -- 'i' prepends to typeahead so queued keys (e.g. trailing 'b' in 'vib') + -- still run after the re-fed lhs. bridges the expr/typeahead + -- boundary without disturbing operator-pending state. + vim.api.nvim_feedkeys(vim.keycode('' .. feed_lhs), 'i', false) + end, { + desc = key_spec.desc, + mode = key_info.split_mode, + -- expr is forced on regardless of key_spec.expr so the proxy preserves + -- operator-pending state across the lazy-load trigger (issue #26). The + -- real keymap installed on load honors the user's key_spec.expr. + -- Tradeoff: expr callbacks run under textlock, so configs that mutate + -- text/windows synchronously during the first triggering press hit + -- E565 and must defer with vim.schedule(). + expr = true, + nowait = key_spec.nowait, + silent = key_spec.silent, + remap = key_spec.remap, + noremap = key_spec.noremap, + buffer = buf, + }) +end + +---@param key_info table +local function any_pack_pending(key_info) + for _, pack_spec in ipairs(key_info.pack_specs) do + local entry = state.spec_registry[pack_spec.src] + if entry and entry.load_status == 'pending' then + return true + end + end + return false end ---@param registered_pack_specs vim.pack.Spec[] @@ -29,16 +135,32 @@ M.setup = function(registered_pack_specs) local mode = key.mode or 'n' local modes = util.normalize_string_list(mode) --[[@as string[] ]] - for _, m in ipairs(modes) do - local key_id = create_key_id(lhs, m) - if not key_to_info[key_id] then - key_to_info[key_id] = { - split_mode = m, - pack_specs = {}, - key_spec = key, - } + -- rhs never needs the proxy: install as a real no-op so the + -- key acts as a true no-op without loading the plugin. + if is_nop_rhs(key[2]) then + local ok, err = pcall(keymap.map, lhs, '', key) + if not ok then + util.schedule_notify( + ("Failed to map %s for %s: %s"):format(lhs, pack_spec.name or pack_spec.src, tostring(err)), + vim.log.levels.ERROR + ) + end + else + -- Only string/table ft is honored as a scope; anything else is a + -- type error best treated as "no ft" so the proxy stays global. + local ft = (type(key.ft) == 'string' or type(key.ft) == 'table') and key.ft or nil + for _, m in ipairs(modes) do + local key_id = create_key_id(lhs, m, ft) + if not key_to_info[key_id] then + key_to_info[key_id] = { + split_mode = m, + pack_specs = {}, + key_spec = key, + ft = ft, + } + end + table.insert(key_to_info[key_id].pack_specs, pack_spec) end - table.insert(key_to_info[key_id].pack_specs, pack_spec) end end end @@ -46,48 +168,23 @@ M.setup = function(registered_pack_specs) -- Create keymaps for _, key_info in pairs(key_to_info) do - local lhs = key_info.key_spec[1] - local key_spec = key_info.key_spec - keymap.map(lhs, function() - pcall(vim.keymap.del, key_info.split_mode, lhs) - local any_ok = false - for _, pack_spec in ipairs(key_info.pack_specs) do - if loader.try_process_spec(pack_spec) then - any_ok = true + if key_info.ft then + -- ft-scoped: install the proxy buffer-locally each time a matching + -- buffer enters the filetype. Once every claiming plugin has loaded, + -- the autocmd no-ops; the global keymap from apply_keys handles + -- subsequent presses. + util.autocmd("FileType", function(ev) + if not any_pack_pending(key_info) then + return end - end - -- Proxy already self-deleted; if no plugin loaded, feeding lhs would - -- type it literally into the buffer. - if not any_ok then - return - end - -- A malformed key spec is pcall-swallowed by apply_keys, so the lhs - -- may end up unmapped. Skip the re-feed unless the spec expected a - -- real keymap — a nil-rhs spec (e.g. `{ 'i', mode = 'o' }`) is the - -- "load + fall through to native binding" pattern and must still feed. - if key_info.key_spec[2] ~= nil - and vim.fn.maparg(lhs, key_info.split_mode) == '' then - return - end - -- 'i' prepends to typeahead so queued keys (e.g. trailing 'b' in 'vib') - -- still run after the re-fed lhs. bridges the expr/typeahead - -- boundary without disturbing operator-pending state. - vim.api.nvim_feedkeys(vim.keycode('' .. lhs), 'i', false) - end, { - desc = key_spec.desc, - mode = key_info.split_mode, - -- expr is forced on regardless of key_spec.expr so the proxy preserves - -- operator-pending state across the lazy-load trigger (issue #26). The - -- real keymap installed on load honors the user's key_spec.expr. - -- Tradeoff: expr callbacks run under textlock, so configs that mutate - -- text/windows synchronously during the first triggering press hit - -- E565 and must defer with vim.schedule(). - expr = true, - nowait = key_spec.nowait, - silent = key_spec.silent, - remap = key_spec.remap, - noremap = key_spec.noremap, - }) + install_proxy(key_info, ev.buf) + end, { + group = state.lazy_group, + pattern = util.normalize_string_list(key_info.ft), + }) + else + install_proxy(key_info, nil) + end end end diff --git a/lua/zpack/types.lua b/lua/zpack/types.lua index e855db7..7cafc1a 100644 --- a/lua/zpack/types.lua +++ b/lua/zpack/types.lua @@ -6,11 +6,13 @@ ---@field expr? boolean ---@field silent? boolean ---@field replace_keycodes? boolean +---@field buffer? integer|boolean ---@class zpack.KeySpec : zpack.KeymapOpts ---@field [1] string ---@field [2]? string|fun() ---@field noremap? boolean +---@field ft? string|string[] FileType scope (lazy.nvim parity); install proxy buffer-locally on matching FileType only ---@class zpack.EventSpec ---@field event string|string[] Event name(s) to trigger on diff --git a/tests/lazy_cmd_test.lua b/tests/lazy_cmd_test.lua index a31e61d..ba1b86a 100644 --- a/tests/lazy_cmd_test.lua +++ b/tests/lazy_cmd_test.lua @@ -264,4 +264,94 @@ describe("Lazy Loading - Commands", function() assert.is_false(saw_refire_notify, "Proxy must not attempt nvim_cmd re-fire when every claiming plugin failed to load") end) + + -- Bead zpack_nvim-6n2: tab-completion on the lazy proxy must load the + -- plugin so the user gets real completions on the first , matching + -- lazy.nvim's UX (handler/cmd.lua complete callback). + it("Lazy proxy command loads plugin on tab-completion", function() + local loaded = false + require('zpack').setup({ + spec = { + { + 'test/plugin', + cmd = 'TestCompleteLoad', + config = function() loaded = true end, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + -- vim.fn.getcompletion invokes the user command's complete callback. + vim.fn.getcompletion('TestCompleteLoad ', 'cmdline') + helpers.flush_pending() + + assert.is_true(loaded, + "Plugin should load when tab-completion is requested on the proxy") + end) + + -- Bead zpack_nvim-6n2: after the proxy fires its complete callback, the + -- real command's completions take over so the user sees actual suggestions. + it("Lazy proxy command returns real completions after load", function() + require('zpack').setup({ + spec = { + { + 'test/plugin', + cmd = 'TestRealComplete', + config = function() + vim.api.nvim_create_user_command('TestRealComplete', function() end, { + nargs = '*', + complete = function() return { 'apple', 'banana', 'cherry' } end, + }) + end, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local results = vim.fn.getcompletion('TestRealComplete ', 'cmdline') + helpers.flush_pending() + + assert.is_true(vim.tbl_contains(results, 'apple'), + "real command's completions should be returned after proxy loads it") + assert.is_true(vim.tbl_contains(results, 'banana'), + "real command's completions should be returned after proxy loads it") + + pcall(vim.api.nvim_del_user_command, 'TestRealComplete') + end) + + -- Bead zpack_nvim-6n2: the proxy is torn down so the real command's + -- subsequent invocations bypass the proxy entirely. + it("Lazy proxy command tears itself down after tab-completion fires", function() + require('zpack').setup({ + spec = { + { + 'test/plugin', + cmd = 'TestTearDownOnComplete', + config = function() + vim.api.nvim_create_user_command('TestTearDownOnComplete', function() end, { + nargs = '*', + }) + end, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + vim.fn.getcompletion('TestTearDownOnComplete ', 'cmdline') + helpers.flush_pending() + + -- After tab-complete loads the plugin, the real command (registered by + -- the plugin's config) replaces the proxy. Verifying it exists and is + -- callable is the load-path's post-condition. + local commands = vim.api.nvim_get_commands({}) + assert.is_not_nil(commands.TestTearDownOnComplete, + "Real command should be present after proxy fires its complete callback") + + pcall(vim.api.nvim_del_user_command, 'TestTearDownOnComplete') + end) end) diff --git a/tests/lazy_event_test.lua b/tests/lazy_event_test.lua index 277e1ce..c6c4395 100644 --- a/tests/lazy_event_test.lua +++ b/tests/lazy_event_test.lua @@ -808,4 +808,51 @@ describe("Lazy Loading - Events", function() loader.process_spec = original_process_spec vim.api.nvim_del_augroup_by_id(test_group) end) + + -- Bead zpack_nvim-8k9: per-autocmd `done` flag guards against nvim#25526 + -- (https://github.com/neovim/neovim/issues/25526), where a `once = true` + -- autocmd can fire twice in the same tick. The visible contract: invoking + -- the lazy-load callback twice in a row dispatches refire exactly once, + -- even if the load_status gate hasn't yet committed `loaded`. + it("event proxy callback only refires once on synchronous double-invocation", function() + local refire = require('zpack.lazy_trigger.refire') + local state = require('zpack.state') + local exec_call_count = 0 + local original_exec = refire.exec + refire.exec = function(...) + exec_call_count = exec_call_count + 1 + return original_exec(...) + end + + require('zpack').setup({ + spec = { + { + 'test/plugin', + event = 'BufReadPost', + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local target + for _, au in ipairs(vim.api.nvim_get_autocmds({ group = state.lazy_group })) do + if au.event == 'BufReadPost' then + target = au + break + end + end + assert.is_not_nil(target, "BufReadPost autocmd should be registered") + assert.is_not_nil(target.callback, "Callback must be exposed for the test") + + local ev = { event = 'BufReadPost', buf = vim.api.nvim_get_current_buf(), match = '' } + target.callback(ev) + target.callback(ev) + + refire.exec = original_exec + + assert.are.equal(1, exec_call_count, + "refire.exec must fire exactly once across two callback invocations") + end) end) diff --git a/tests/lazy_keys_test.lua b/tests/lazy_keys_test.lua index 7c75891..8084137 100644 --- a/tests/lazy_keys_test.lua +++ b/tests/lazy_keys_test.lua @@ -835,4 +835,281 @@ describe("Lazy Loading - Keymaps", function() vim.api.nvim_buf_delete(buf, { force = true }) pcall(vim.keymap.del, 'n', 'tn') end) + + -- Bead zpack_nvim-eyo: a rhs should install a real keymap + -- (no proxy, no plugin load) so the key acts as a true no-op. + it("KeySpec with rhs installs real keymap and skips proxy", function() + local state = require('zpack.state') + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tnp', '' }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local found_map + for _, map in ipairs(vim.api.nvim_get_keymap('n')) do + if map.lhs == ' tnp' then + found_map = map + break + end + end + assert.is_not_nil(found_map, "Real keymap should be installed") + -- Real keymap has rhs ''; the lazy proxy would have a callback instead. + assert.is_nil(found_map.callback, + " spec must install a non-proxy keymap (no callback)") + + -- Pressing the key must not load the plugin. + vim.api.nvim_feedkeys(' tnp', 'mx', false) + helpers.flush_pending() + + local src = 'https://github.com/test/plugin' + assert.are.equal("pending", state.spec_registry[src].load_status, + "Plugin must remain unloaded after pressing a -mapped key") + end) + + -- Bead zpack_nvim-eyo: case-insensitive match for ``. + it("KeySpec with (lowercase) rhs also installs real keymap", function() + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tnl', '' }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local found_map + for _, map in ipairs(vim.api.nvim_get_keymap('n')) do + if map.lhs == ' tnl' then + found_map = map + break + end + end + assert.is_not_nil(found_map, "Real keymap should be installed") + assert.is_nil(found_map.callback, + " spec must install a non-proxy keymap (no callback)") + end) + + -- Bead zpack_nvim-sdu: abbreviation modes (ia/ca/!a) need appended + -- on the re-fed lhs for the abbreviation to actually expand on first press. + it("Lazy proxy appends on re-feed for abbreviation modes", function() + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'teh', function() end, mode = 'ia' }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local feed_captured + local original_feedkeys = vim.api.nvim_feedkeys + vim.api.nvim_feedkeys = function(keys, _, _) + feed_captured = keys + end + + -- maparg's {mode} only accepts single-char modes; abbreviations are + -- queried via {abbr}=true on the underlying base mode ('i' for 'ia'). + local maparg = vim.fn.maparg('teh', 'i', true, true) + assert.is_not_nil(maparg.callback, "Proxy should be installed as insert-mode abbreviation") + maparg.callback() + + vim.api.nvim_feedkeys = original_feedkeys + + assert.is_not_nil(feed_captured, "Proxy must call feedkeys on first press") + local ctrl_close = vim.keycode('') + assert.is_truthy(feed_captured:find(ctrl_close, 1, true), + "Re-fed string must contain for abbreviation modes") + end) + + -- Bead zpack_nvim-sdu: non-abbrev modes (n/i/v/etc.) must not add . + it("Lazy proxy does not append for non-abbreviation modes", function() + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tab', function() end, mode = 'n' }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local feed_captured + local original_feedkeys = vim.api.nvim_feedkeys + vim.api.nvim_feedkeys = function(keys, _, _) + feed_captured = keys + end + + local maparg = vim.fn.maparg(' tab', 'n', false, true) + assert.is_not_nil(maparg.callback, "Proxy should be installed for n mode") + maparg.callback() + + vim.api.nvim_feedkeys = original_feedkeys + + assert.is_not_nil(feed_captured) + local ctrl_close = vim.keycode('') + assert.is_falsy(feed_captured:find(ctrl_close, 1, true), + "Non-abbreviation modes must not have appended") + end) + + -- Bead zpack_nvim-n3g: ft-scoped keys must not install a global proxy and + -- must register a FileType autocmd for the requested filetypes. + it("KeySpec with ft does not install global proxy", function() + local state = require('zpack.state') + + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tfg', function() end, ft = 'lua' }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local found_global + for _, map in ipairs(vim.api.nvim_get_keymap('n')) do + if map.lhs == ' tfg' then + found_global = map + end + end + assert.is_nil(found_global, "ft-scoped key must not install a global proxy") + + local autocmds = vim.api.nvim_get_autocmds({ group = state.lazy_group }) + local ft_autocmd = helpers.find_autocmd(autocmds, 'FileType', 'lua') + assert.is_not_nil(ft_autocmd, + "FileType autocmd must be registered for the ft-scoped key") + end) + + -- Bead zpack_nvim-n3g: when a buffer enters the requested ft, the proxy + -- installs buffer-locally. + it("KeySpec with ft installs buffer-local proxy on matching FileType", function() + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tfb', function() end, ft = 'lua' }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local buf = vim.api.nvim_create_buf(true, false) + vim.api.nvim_set_current_buf(buf) + vim.bo[buf].filetype = 'lua' + helpers.flush_pending() + + local found_local + for _, map in ipairs(vim.api.nvim_buf_get_keymap(buf, 'n')) do + if map.lhs == ' tfb' then + found_local = map + end + end + assert.is_not_nil(found_local, + "Buffer-local proxy should be installed on matching FileType") + assert.are.equal(buf, found_local.buffer, "Mapping must be buffer-local to buf") + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + -- Bead zpack_nvim-n3g: non-matching filetypes must not get the proxy. + it("KeySpec with ft skips non-matching filetypes", function() + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tfm', function() end, ft = 'lua' }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local buf = vim.api.nvim_create_buf(true, false) + vim.api.nvim_set_current_buf(buf) + vim.bo[buf].filetype = 'rust' + helpers.flush_pending() + + for _, map in ipairs(vim.api.nvim_buf_get_keymap(buf, 'n')) do + assert.are_not.equal(' tfm', map.lhs, + "Non-matching filetype must not receive the proxy") + end + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + -- Bead zpack_nvim-n3g: pressing the ft-scoped key triggers plugin load. + -- Invokes the captured proxy callback directly (instead of feedkeys) to + -- avoid headless-mode feedkeys quirks with buffer-local mappings. + it("KeySpec with ft loads plugin on buffer-local proxy fire", function() + local loaded = false + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tfl', function() end, ft = 'lua' }, + }, + config = function() loaded = true end, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local buf = vim.api.nvim_create_buf(true, false) + vim.api.nvim_set_current_buf(buf) + vim.bo[buf].filetype = 'lua' + helpers.flush_pending() + + local proxy + for _, map in ipairs(vim.api.nvim_buf_get_keymap(buf, 'n')) do + if map.lhs == ' tfl' then + proxy = map + end + end + assert.is_not_nil(proxy, "buffer-local proxy should be installed") + assert.is_not_nil(proxy.callback, "proxy must have a Lua callback") + proxy.callback() + helpers.flush_pending() + + assert.is_true(loaded, + "Plugin must load when ft-scoped buffer-local proxy fires") + + vim.api.nvim_buf_delete(buf, { force = true }) + end) end) From 66945e768610af087e5f82e2200ed00f216485d0 Mon Sep 17 00:00:00 2001 From: zuqini Date: Sun, 24 May 2026 01:06:55 -0700 Subject: [PATCH 2/8] feat: close four more lazy.nvim parity gaps surfaced in review Follow-up to 77fd9a4. Cross-handler review against lazy.nvim flagged four additional gaps; the cmd-proxy count/nargs and +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). * rhs (keys.lua): strip `expr` / `replace_keycodes` from the opts passed to keymap.map. The rhs is the literal string ``, 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. --- doc/zpack.txt | 5 ++ lua/zpack/lazy.lua | 24 ++++++++++ lua/zpack/lazy_trigger/ft.lua | 25 ++++++++-- lua/zpack/lazy_trigger/keys.lua | 9 +++- lua/zpack/utils.lua | 27 +++++++++++ tests/lazy_event_test.lua | 61 ++++++++++++++++++++----- tests/lazy_ft_test.lua | 81 +++++++++++++++++++++++++++++++++ tests/lazy_keys_test.lua | 30 ++++++++++++ 8 files changed, 245 insertions(+), 17 deletions(-) diff --git a/doc/zpack.txt b/doc/zpack.txt index 8d91d36..7226e7e 100644 --- a/doc/zpack.txt +++ b/doc/zpack.txt @@ -715,6 +715,11 @@ event (string|string[]|EventSpec|(string|EventSpec)[]|fn(plugin), "BufReadPre *.lua". See |zpack.EventSpec|. Auto-sets `lazy=true`. + Note: zpack emits `User VeryLazy` once on UIEnter (or + immediately after setup when vim has already entered), so + user configs can hook |autocmd| `User VeryLazy` for + post-setup delayed init. + *zpack-Spec.pattern* pattern (string|string[], optional) Global fallback pattern(s) applied to all events. If an diff --git a/lua/zpack/lazy.lua b/lua/zpack/lazy.lua index ac0dddd..3e90045 100644 --- a/lua/zpack/lazy.lua +++ b/lua/zpack/lazy.lua @@ -89,6 +89,29 @@ M.is_lazy = function(spec, plugin, src) return false end +---Fire `User VeryLazy` once on UIEnter (or immediately if vim has already +---entered). Matches lazy.nvim's contract so user autocmds keyed on +---`User VeryLazy` work in configs ported from lazy.nvim. Registered AFTER +---per-plugin UIEnter handlers so VeryLazy plugins are loaded by the time +---User VeryLazy fires. +local function fire_very_lazy() + local function emit() + vim.api.nvim_exec_autocmds('User', { pattern = 'VeryLazy', modeline = false }) + end + if vim.v.vim_did_enter == 1 then + vim.schedule(emit) + else + vim.api.nvim_create_autocmd('UIEnter', { + group = state.lazy_group, + once = true, + nested = true, + callback = function() + vim.schedule(emit) + end, + }) + end +end + ---@param ctx zpack.ProcessContext M.process_all = function(ctx) if next(state.src_with_pending_build) ~= nil then @@ -115,6 +138,7 @@ M.process_all = function(ctx) end cmd_handler.setup(ctx.registered_lazy_packs) keys_handler.setup(ctx.registered_lazy_packs) + fire_very_lazy() end return M diff --git a/lua/zpack/lazy_trigger/ft.lua b/lua/zpack/lazy_trigger/ft.lua index 5fbcde6..cf89159 100644 --- a/lua/zpack/lazy_trigger/ft.lua +++ b/lua/zpack/lazy_trigger/ft.lua @@ -10,10 +10,29 @@ local M = {} M.setup = function(pack_spec, ft) local filetypes = util.normalize_string_list(ft) + -- Source the plugin's ftdetect/* now so its filetype rules are active + -- before any file is opened. Without this, `ft = ''` for a plugin + -- that ships its own filetype detection (e.g. `ftdetect/rust.vim`) silently + -- never triggers, because vim.pack defers the rules behind `:packadd`. + local registry_entry = state.spec_registry[pack_spec.src] + local plugin_path = registry_entry and registry_entry.plugin and registry_entry.plugin.path + if plugin_path then + util.source_ftdetect_files(plugin_path) + end + + -- `done` guards against nvim#25526 (same `once = true` autocmd firing + -- twice in the same tick). Mirrors lazy_trigger/event.lua's guard; + -- needed here because the plugin's own `ftplugin/*` sourced during + -- packadd can nest-fire FileType on the same buffer before load_status + -- flips. + local done = false util.autocmd("FileType", function(ev) - -- Same gate as lazy_trigger/event.lua: skip when a sibling already - -- loaded (avoid double-fire) or is mid-load (avoid spurious circular- - -- dependency notify). + if done then + return + end + done = true + -- Skip when a sibling already loaded (avoid double-fire) or is + -- mid-load (avoid spurious circular-dependency notify). local entry = state.spec_registry[pack_spec.src] if entry and entry.load_status ~= "pending" then return diff --git a/lua/zpack/lazy_trigger/keys.lua b/lua/zpack/lazy_trigger/keys.lua index a4bedd2..87c3dbf 100644 --- a/lua/zpack/lazy_trigger/keys.lua +++ b/lua/zpack/lazy_trigger/keys.lua @@ -136,9 +136,14 @@ M.setup = function(registered_pack_specs) local modes = util.normalize_string_list(mode) --[[@as string[] ]] -- rhs never needs the proxy: install as a real no-op so the - -- key acts as a true no-op without loading the plugin. + -- key acts as a true no-op without loading the plugin. `expr` / + -- `replace_keycodes` are stripped: the rhs is the literal string + -- '', so eval'ing it as an expression contradicts the intent. if is_nop_rhs(key[2]) then - local ok, err = pcall(keymap.map, lhs, '', key) + local nop_opts = vim.deepcopy(key) + nop_opts.expr = nil + nop_opts.replace_keycodes = nil + local ok, err = pcall(keymap.map, lhs, '', nop_opts) if not ok then util.schedule_notify( ("Failed to map %s for %s: %s"):format(lhs, pack_spec.name or pack_spec.src, tostring(err)), diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua index 13cf6a2..1e5f0fd 100644 --- a/lua/zpack/utils.lua +++ b/lua/zpack/utils.lua @@ -352,4 +352,31 @@ M.source_after_plugin_files = function(plugin_path) sourced_plugin_paths[plugin_path] = true end +---Track which plugin paths have had their ftdetect/ files sourced +---@type { [string]: true } +local sourced_ftdetect_paths = {} + +---Source ftdetect/ files for a lazy plugin so its filetype rules are in +---place before any file is opened. Without this a plugin specced as +---`ft = 'rust'` would never load on `:edit foo.rs`, because the rules that +---set `&ft = 'rust'` live in the plugin's un-sourced `ftdetect/rust.vim`. +---@param plugin_path string +M.source_ftdetect_files = function(plugin_path) + if sourced_ftdetect_paths[plugin_path] then + return + end + sourced_ftdetect_paths[plugin_path] = true + + local files = vim.fn.glob(plugin_path .. '/ftdetect/*.{vim,lua}', false, true) + -- ftdetect files must execute inside the `filetypedetect` augroup so their + -- autocmds register there (matches Neovim's standard ftdetect sourcing). + for _, file in ipairs(files) do + local ok, err = pcall(vim.cmd, + ('augroup filetypedetect | source %s | augroup END'):format(vim.fn.fnameescape(file))) + if not ok then + M.schedule_notify(("Failed to source %s: %s"):format(file, tostring(err)), vim.log.levels.ERROR) + end + end +end + return M diff --git a/tests/lazy_event_test.lua b/tests/lazy_event_test.lua index c6c4395..6d5608c 100644 --- a/tests/lazy_event_test.lua +++ b/tests/lazy_event_test.lua @@ -776,18 +776,12 @@ describe("Lazy Loading - Events", function() local original_process_spec = loader.process_spec local test_group = vim.api.nvim_create_augroup('ZpackTest', { clear = true }) - require('zpack').setup({ - spec = { - { - 'test/plugin', - event = 'User', - }, - }, - defaults = { confirm = false }, - }) - - helpers.flush_pending() - + -- Install the mock BEFORE setup() so that the auto-emitted `User VeryLazy` + -- (scheduled by lazy.fire_very_lazy when `vim_did_enter == 1`) and the + -- test's explicit `nvim_exec_autocmds('User', {})` both route through it. + -- The once-true `User *` proxy is consumed by whichever dispatch lands + -- first; the explicit one must land first so the empty-match-fallback + -- contract is what's being pinned. local refire_count = 0 loader.process_spec = function(pack_spec) original_process_spec(pack_spec) @@ -799,6 +793,21 @@ describe("Lazy Loading - Events", function() }) end + require('zpack').setup({ + spec = { + { + 'test/plugin', + event = 'User', + }, + }, + defaults = { confirm = false }, + }) + + -- No `flush_pending` before the explicit dispatch: the VeryLazy emit is + -- scheduled (vim.schedule), so it has not yet run. The explicit dispatch + -- below consumes the proxy first with `ev.match = ''`, exercising the + -- empty-match-fallback path. The scheduled emit fires harmlessly later + -- (proxy already consumed, test handler is once-true and consumed). vim.api.nvim_exec_autocmds('User', {}) helpers.flush_pending() @@ -855,4 +864,32 @@ describe("Lazy Loading - Events", function() assert.are.equal(1, exec_call_count, "refire.exec must fire exactly once across two callback invocations") end) + + -- lazy.nvim emits `User VeryLazy` once on UIEnter so configs can hook + -- `nvim_create_autocmd("User", { pattern = "VeryLazy" })` for post-setup + -- delayed init. zpack must mirror that contract for ported configs. + it("setup emits User VeryLazy on UIEnter", function() + local fired = false + local test_group = vim.api.nvim_create_augroup('ZpackVeryLazyTest', { clear = true }) + vim.api.nvim_create_autocmd('User', { + group = test_group, + pattern = 'VeryLazy', + callback = function() fired = true end, + }) + + require('zpack').setup({ + spec = { + { 'test/plugin' }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + vim.api.nvim_exec_autocmds('UIEnter', {}) + helpers.flush_pending() + + vim.api.nvim_del_augroup_by_id(test_group) + + assert.is_true(fired, "User VeryLazy should fire on UIEnter") + end) end) diff --git a/tests/lazy_ft_test.lua b/tests/lazy_ft_test.lua index 2fc948c..a127397 100644 --- a/tests/lazy_ft_test.lua +++ b/tests/lazy_ft_test.lua @@ -277,4 +277,85 @@ describe("Lazy Loading - FileType", function() local src = 'https://github.com/test/plugin' assert.are.equal("pending", state.spec_registry[src].load_status) end) + + -- nvim#25526 (https://github.com/neovim/neovim/issues/25526): `once = true` + -- autocmds can fire twice in the same tick when the plugin's own ftplugin/* + -- (sourced during packadd) nest-fires FileType before load_status flips. + -- Mirrors the event-side guard test. + it("ft proxy callback only refires once on synchronous double-invocation", function() + local refire = require('zpack.lazy_trigger.refire') + local state = require('zpack.state') + local exec_call_count = 0 + local original_exec = refire.exec + refire.exec = function(...) + exec_call_count = exec_call_count + 1 + return original_exec(...) + end + + require('zpack').setup({ + spec = { + { + 'test/plugin', + ft = 'lua', + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local target + for _, au in ipairs(vim.api.nvim_get_autocmds({ group = state.lazy_group })) do + if au.event == 'FileType' then + target = au + break + end + end + assert.is_not_nil(target, "FileType autocmd should be registered") + assert.is_not_nil(target.callback, "Callback must be exposed for the test") + + local ev = { event = 'FileType', buf = vim.api.nvim_get_current_buf(), match = 'lua' } + target.callback(ev) + target.callback(ev) + + refire.exec = original_exec + + assert.are.equal(1, exec_call_count, + "refire.exec must fire exactly once across two callback invocations") + end) + + -- A plugin that ships its own filetype detection (e.g. `ftdetect/foo.vim`) + -- specced as `ft = 'foo'` must have its ftdetect sourced at registration — + -- otherwise opening a `.foo` file never sets `ft = foo`, so the FileType + -- autocmd never fires, and the plugin silently never loads. + it("ft trigger sources the plugin's ftdetect/ files at registration", function() + local plugin_name = 'plugin' + local plugin_dir = vim.fn.stdpath('data') .. '/site/pack/zpack/opt/' .. plugin_name + vim.fn.mkdir(plugin_dir .. '/ftdetect', 'p') + local marker_file = vim.fn.tempname() + local ftdetect_file = plugin_dir .. '/ftdetect/zpackmark.vim' + local f = assert(io.open(ftdetect_file, 'w')) + f:write(("call writefile(['sourced'], '%s')\n"):format(marker_file)) + f:close() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + ft = 'zpackmark', + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local sourced = vim.fn.filereadable(marker_file) == 1 + vim.fn.delete(ftdetect_file) + vim.fn.delete(marker_file) + vim.fn.delete(plugin_dir, 'rf') + + assert.is_true(sourced, + "Plugin's ftdetect/*.vim should be sourced at ft-trigger registration") + end) end) diff --git a/tests/lazy_keys_test.lua b/tests/lazy_keys_test.lua index 8084137..8baff4a 100644 --- a/tests/lazy_keys_test.lua +++ b/tests/lazy_keys_test.lua @@ -875,6 +875,36 @@ describe("Lazy Loading - Keymaps", function() "Plugin must remain unloaded after pressing a -mapped key") end) + -- `` rhs must install a literal-string `` map; `expr` / + -- `replace_keycodes` on the KeySpec are stripped so vim does not try to + -- evaluate the literal `` as a vimscript expression. + it("KeySpec with rhs strips expr/replace_keycodes opts", function() + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tne', '', expr = true, replace_keycodes = false }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local found_map + for _, map in ipairs(vim.api.nvim_get_keymap('n')) do + if map.lhs == ' tne' then + found_map = map + break + end + end + assert.is_not_nil(found_map, "Real keymap should be installed") + assert.are_not.equal(1, found_map.expr, + " install must strip expr=true so the rhs isn't evaluated") + end) + -- Bead zpack_nvim-eyo: case-insensitive match for ``. it("KeySpec with (lowercase) rhs also installs real keymap", function() require('zpack').setup({ From ca16ec4b4749bd54c6460a138eec4349392cb731 Mon Sep 17 00:00:00 2001 From: zuqini Date: Sun, 24 May 2026 12:46:29 -0700 Subject: [PATCH 3/8] fix: close more lazy.nvim parity gaps surfaced in follow-up review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (`{ 'x', ..., ft = 'lua' }` + `{ '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). * + ft scope (lazy_trigger/keys.lua): a `` 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 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 + 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). --- lua/zpack/lazy_trigger/event.lua | 39 +++++++------- lua/zpack/lazy_trigger/ft.lua | 17 ++---- lua/zpack/lazy_trigger/keys.lua | 56 ++++++++++++++------ lua/zpack/merge.lua | 16 +++++- lua/zpack/utils.lua | 17 ++++++ tests/lazy_event_test.lua | 17 +++--- tests/lazy_keys_test.lua | 88 ++++++++++++++++++++++++++++++++ tests/merge_test.lua | 40 +++++++++++++++ 8 files changed, 232 insertions(+), 58 deletions(-) diff --git a/lua/zpack/lazy_trigger/event.lua b/lua/zpack/lazy_trigger/event.lua index b8874b6..d154a6c 100644 --- a/lua/zpack/lazy_trigger/event.lua +++ b/lua/zpack/lazy_trigger/event.lua @@ -76,32 +76,29 @@ M.setup = function(pack_spec, spec, event) if has_very_lazy then -- VeryLazy is synthetic (UIEnter-only); no real event to re-fire. - -- `done` guards against the same `once = true` autocmd firing twice - -- in the same tick (https://github.com/neovim/neovim/issues/25526). - local done = false - util.autocmd("UIEnter", function() - if done then - return - end - done = true + -- When setup() runs after UIEnter (`:luafile`, config reload), the + -- UIEnter autocmd would never fire — schedule the load directly so + -- the plugin still loads before lazy.fire_very_lazy's User VeryLazy + -- emit (which also fast-paths on vim_did_enter). + if vim.v.vim_did_enter == 1 then vim.schedule(function() loader.try_process_spec(pack_spec) end) - end, { group = state.lazy_group, once = true }) + else + util.autocmd("UIEnter", util.once_per_tick(function() + vim.schedule(function() + loader.try_process_spec(pack_spec) + end) + end), { group = state.lazy_group, once = true }) + end end if #other_events > 0 then - local done = false - util.autocmd(other_events, function(ev) - -- `done` guards against nvim#25526 (same `once = true` autocmd - -- firing twice in the same tick). Set before any further work so - -- the second fire bails before refire.exec can double-fire user - -- autocmds. The load_status gate below handles other races (sibling - -- event/ft already loaded, plugin/ files re-entering synchronously). - if done then - return - end - done = true + -- once_per_tick latches before the load_status gate so a second fire + -- in the same tick bails before refire.exec can double-fire user + -- autocmds. The load_status gate handles other races (sibling + -- event/ft already loaded, plugin/ files re-entering synchronously). + util.autocmd(other_events, util.once_per_tick(function(ev) local entry = state.spec_registry[pack_spec.src] if entry and entry.load_status ~= "pending" then return @@ -111,7 +108,7 @@ M.setup = function(pack_spec, spec, event) return end refire.exec(ev, snap) - end, { group = state.lazy_group, once = true, pattern = normalized_event.pattern }) + end), { group = state.lazy_group, once = true, pattern = normalized_event.pattern }) end end end diff --git a/lua/zpack/lazy_trigger/ft.lua b/lua/zpack/lazy_trigger/ft.lua index cf89159..7adf03e 100644 --- a/lua/zpack/lazy_trigger/ft.lua +++ b/lua/zpack/lazy_trigger/ft.lua @@ -20,17 +20,10 @@ M.setup = function(pack_spec, ft) util.source_ftdetect_files(plugin_path) end - -- `done` guards against nvim#25526 (same `once = true` autocmd firing - -- twice in the same tick). Mirrors lazy_trigger/event.lua's guard; - -- needed here because the plugin's own `ftplugin/*` sourced during - -- packadd can nest-fire FileType on the same buffer before load_status - -- flips. - local done = false - util.autocmd("FileType", function(ev) - if done then - return - end - done = true + -- once_per_tick guards against nvim#25526; needed here because the + -- plugin's own `ftplugin/*` sourced during packadd can nest-fire FileType + -- on the same buffer before load_status flips. + util.autocmd("FileType", util.once_per_tick(function(ev) -- Skip when a sibling already loaded (avoid double-fire) or is -- mid-load (avoid spurious circular-dependency notify). local entry = state.spec_registry[pack_spec.src] @@ -42,7 +35,7 @@ M.setup = function(pack_spec, ft) return end refire.exec(ev, snap) - end, { group = state.lazy_group, pattern = filetypes, once = true }) + end), { group = state.lazy_group, pattern = filetypes, once = true }) end return M diff --git a/lua/zpack/lazy_trigger/keys.lua b/lua/zpack/lazy_trigger/keys.lua index 87c3dbf..c6a068c 100644 --- a/lua/zpack/lazy_trigger/keys.lua +++ b/lua/zpack/lazy_trigger/keys.lua @@ -119,6 +119,26 @@ local function any_pack_pending(key_info) return false end +---Install a (buffer-local when `buf` is non-nil) real `` keymap from +---the user's KeySpec. `expr` is stripped so vim does not eval the literal +---string `` as an expression; `keymap.map` nulls `replace_keycodes` +---when `expr` is unset, so it does not need a separate strip. +---@param key zpack.KeySpec +---@param src string +---@param buf? integer +local function install_nop(key, src, buf) + local nop_opts = vim.deepcopy(key) + nop_opts.expr = nil + nop_opts.buffer = buf + local ok, err = pcall(keymap.map, key[1], '', nop_opts) + if not ok then + util.schedule_notify( + ("Failed to map %s for %s: %s"):format(key[1], src, tostring(err)), + vim.log.levels.ERROR + ) + end +end + ---@param registered_pack_specs vim.pack.Spec[] M.setup = function(registered_pack_specs) local key_to_info = {} @@ -135,33 +155,35 @@ M.setup = function(registered_pack_specs) local mode = key.mode or 'n' local modes = util.normalize_string_list(mode) --[[@as string[] ]] + -- Only string/table ft is honored as a scope; anything else is a + -- type error best treated as "no ft" so the proxy/nop stays global. + local ft_scope = (type(key.ft) == 'string' or type(key.ft) == 'table') and key.ft or nil + local src = pack_spec.name or pack_spec.src + -- rhs never needs the proxy: install as a real no-op so the - -- key acts as a true no-op without loading the plugin. `expr` / - -- `replace_keycodes` are stripped: the rhs is the literal string - -- '', so eval'ing it as an expression contradicts the intent. + -- key acts as a true no-op without loading the plugin. ft-scoped + -- installs buffer-locally on matching FileType so the + -- suppression is scoped, matching lazy.nvim's ft-on-Nop behavior. if is_nop_rhs(key[2]) then - local nop_opts = vim.deepcopy(key) - nop_opts.expr = nil - nop_opts.replace_keycodes = nil - local ok, err = pcall(keymap.map, lhs, '', nop_opts) - if not ok then - util.schedule_notify( - ("Failed to map %s for %s: %s"):format(lhs, pack_spec.name or pack_spec.src, tostring(err)), - vim.log.levels.ERROR - ) + if ft_scope then + util.autocmd("FileType", function(ev) + install_nop(key, src, ev.buf) + end, { + group = state.lazy_group, + pattern = util.normalize_string_list(ft_scope), + }) + else + install_nop(key, src, nil) end else - -- Only string/table ft is honored as a scope; anything else is a - -- type error best treated as "no ft" so the proxy stays global. - local ft = (type(key.ft) == 'string' or type(key.ft) == 'table') and key.ft or nil for _, m in ipairs(modes) do - local key_id = create_key_id(lhs, m, ft) + local key_id = create_key_id(lhs, m, ft_scope) if not key_to_info[key_id] then key_to_info[key_id] = { split_mode = m, pack_specs = {}, key_spec = key, - ft = ft, + ft = ft_scope, } end table.insert(key_to_info[key_id].pack_specs, pack_spec) diff --git a/lua/zpack/merge.lua b/lua/zpack/merge.lua index c25eb8c..4ef8f08 100644 --- a/lua/zpack/merge.lua +++ b/lua/zpack/merge.lua @@ -61,7 +61,11 @@ local function to_array(val) return { val } end ----Get unique key for a value (handles KeySpec with mode) +---Get unique key for a value (handles KeySpec with mode + ft). +---ft is included so two specs with the same lhs/mode but disjoint ft +---scopes (`{x, ..., ft='lua'}` + `{x, ..., ft='rust'}`) +---survive merge instead of the second silently dropping. Matches the +---ft-aware dedup in lazy_trigger/keys.lua's create_key_id. ---@param v any ---@return string local function get_unique_key(v) @@ -75,7 +79,15 @@ local function get_unique_key(v) table.sort(sorted) mode = table.concat(sorted, ",") end - return lhs .. ":" .. mode + local ft = "" + if type(v.ft) == "string" then + ft = ":ft=" .. v.ft + elseif type(v.ft) == "table" then + local sorted = vim.list_slice(v.ft) + table.sort(sorted) + ft = ":ft=" .. table.concat(sorted, ",") + end + return lhs .. ":" .. mode .. ft end ---Extend list with unique values diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua index 1e5f0fd..6ac2e4e 100644 --- a/lua/zpack/utils.lua +++ b/lua/zpack/utils.lua @@ -132,6 +132,23 @@ M.autocmd = function(event, callback, opts) }, opts)) end +---Wrap a callback so a synchronous second invocation no-ops. Guards against +---nvim#25526 (https://github.com/neovim/neovim/issues/25526) — a `once = true` +---autocmd that nested-fires in the same tick is dispatched twice before the +---autocmd-deletion takes effect. +---@param callback function +---@return function +M.once_per_tick = function(callback) + local done = false + return function(...) + if done then + return + end + done = true + return callback(...) + end +end + ---Resolve a function-form spec field; a throw becomes a structured notify ---and a nil return instead of aborting the caller. ---@param field any diff --git a/tests/lazy_event_test.lua b/tests/lazy_event_test.lua index 6d5608c..619abd9 100644 --- a/tests/lazy_event_test.lua +++ b/tests/lazy_event_test.lua @@ -94,8 +94,14 @@ describe("Lazy Loading - Events", function() ) end) - it("VeryLazy event creates UIEnter autocmd", function() + it("VeryLazy plugin loads when setup runs post-UIEnter", function() + -- Test environment has vim_did_enter == 1, so the per-plugin UIEnter + -- autocmd path is skipped and the load is scheduled directly. Without + -- this fast-path, `event = 'VeryLazy'` plugins would never load after + -- a `:luafile` / config reload because UIEnter is already in the past. local state = require('zpack.state') + assert.are.equal(1, vim.v.vim_did_enter, + "Test prerequisite: this test exercises the post-UIEnter setup path") require('zpack').setup({ spec = { @@ -108,11 +114,10 @@ describe("Lazy Loading - Events", function() }) helpers.flush_pending() - local autocmds = vim.api.nvim_get_autocmds({ group = state.lazy_group }) - assert.is_not_nil( - helpers.find_autocmd(autocmds, 'UIEnter'), - "VeryLazy should create UIEnter autocmd" - ) + + local src = 'https://github.com/test/plugin' + assert.are.equal('loaded', state.spec_registry[src].load_status, + "VeryLazy plugin should be loaded via the post-UIEnter fast-path") end) it("multiple EventSpecs with different patterns", function() diff --git a/tests/lazy_keys_test.lua b/tests/lazy_keys_test.lua index 8baff4a..9811009 100644 --- a/tests/lazy_keys_test.lua +++ b/tests/lazy_keys_test.lua @@ -1142,4 +1142,92 @@ describe("Lazy Loading - Keymaps", function() vim.api.nvim_buf_delete(buf, { force = true }) end) + + -- ft-scoped : the suppression must be buffer-local to the matching + -- ft, not global. lazy.nvim honors ft on Nop maps; without scoping, a + -- `{ 'x', '', ft = 'lua' }` would silently mask the key in + -- every buffer. + it("KeySpec with + ft installs no global keymap", function() + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tnft', '', ft = 'lua' }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + for _, map in ipairs(vim.api.nvim_get_keymap('n')) do + assert.are_not.equal(' tnft', map.lhs, + "ft-scoped must not install a global keymap") + end + end) + + it("KeySpec with + ft installs buffer-locally on matching FileType", function() + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tnb', '', ft = 'lua' }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local buf = vim.api.nvim_create_buf(true, false) + vim.api.nvim_set_current_buf(buf) + vim.bo[buf].filetype = 'lua' + helpers.flush_pending() + + local found + for _, map in ipairs(vim.api.nvim_buf_get_keymap(buf, 'n')) do + if map.lhs == ' tnb' then + found = map + end + end + assert.is_not_nil(found, + "Buffer-local should be installed on matching FileType") + assert.is_nil(found.callback, + " install must be a real keymap (no callback), not a proxy") + assert.are.equal(buf, found.buffer, "Mapping must be buffer-local to buf") + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it("KeySpec with + ft skips non-matching filetypes", function() + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tnm', '', ft = 'lua' }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local buf = vim.api.nvim_create_buf(true, false) + vim.api.nvim_set_current_buf(buf) + vim.bo[buf].filetype = 'rust' + helpers.flush_pending() + + for _, map in ipairs(vim.api.nvim_buf_get_keymap(buf, 'n')) do + assert.are_not.equal(' tnm', map.lhs, + "Non-matching filetype must not receive the install") + end + + vim.api.nvim_buf_delete(buf, { force = true }) + end) end) diff --git a/tests/merge_test.lua b/tests/merge_test.lua index 5144bdb..b3a7f0b 100644 --- a/tests/merge_test.lua +++ b/tests/merge_test.lua @@ -394,4 +394,44 @@ describe("Merge Module Unit Tests", function() assert.are.equal(1, #merged.keys) end) + + -- Two specs declaring the same lhs/mode but disjoint ft scopes are NOT + -- duplicates — the user wants both keys, scoped to different filetypes. + -- get_unique_key must include ft so the second spec survives merge and + -- reaches lazy_trigger/keys.lua's ft-aware dedup. + it("keys with same lhs/mode but different ft scopes both survive merge", function() + require('zpack').setup({ + spec = { + { 'test/plugin', keys = { { 'a', 'Lua', ft = 'lua' } } }, + { 'test/plugin', keys = { { 'a', 'Rust', ft = 'rust' } } }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local state = require('zpack.state') + local src = 'https://github.com/test/plugin' + local merged = state.spec_registry[src].merged_spec + + assert.are.equal(2, #merged.keys, + "Both ft-scoped keys should survive merge") + end) + + it("keys with ft as list in different order are deduplicated", function() + require('zpack').setup({ + spec = { + { 'test/plugin', keys = { { 'a', ft = { 'lua', 'rust' }, desc = 'first' } } }, + { 'test/plugin', keys = { { 'a', ft = { 'rust', 'lua' }, desc = 'second' } } }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local state = require('zpack.state') + local src = 'https://github.com/test/plugin' + local merged = state.spec_registry[src].merged_spec + + assert.are.equal(1, #merged.keys, + "Ft lists with same members in different order should dedup") + end) end) From b3e216e6bd51535f140dde46d98c959ae9d63e72 Mon Sep 17 00:00:00 2001 From: zuqini Date: Sun, 24 May 2026 13:23:14 -0700 Subject: [PATCH 4/8] fix: close review-followup gaps (augroup leak, apply_keys ft, lifecycle cleanups) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- doc/zpack.txt | 19 ++-- docs/spec.md | 5 +- lua/zpack/keymap.lua | 66 +++++++++++-- lua/zpack/lazy.lua | 27 +---- lua/zpack/lazy_trigger/event.lua | 33 ++++++- lua/zpack/lazy_trigger/ft.lua | 4 +- lua/zpack/lazy_trigger/keys.lua | 50 +++++++--- lua/zpack/merge.lua | 5 +- lua/zpack/types.lua | 2 +- lua/zpack/utils.lua | 21 ++-- tests/lazy_keys_test.lua | 154 +++++++++++++++++++++++++++++ tests/source_plugin_files_test.lua | 119 ++++++++++++++++++++++ 12 files changed, 432 insertions(+), 73 deletions(-) diff --git a/doc/zpack.txt b/doc/zpack.txt index 7226e7e..33d9e02 100644 --- a/doc/zpack.txt +++ b/doc/zpack.txt @@ -859,7 +859,8 @@ pattern (string|string[], optional) [2] = function() end, -- RHS function desc = "description", -- Keymap description mode = "n"|{"n","v"}, -- Mode(s), default: "n" - ft = "lua"|{"lua","vim"}, -- FileType scope; install proxy buffer-locally only + ft = "lua"|{"lua","vim"}, -- FileType scope; install buffer-locally on matching FileType only + buffer = true|0|7, -- Buffer scope: true/0 = current buffer; integer = specific buffer remap = true|false, -- Allow remapping, default: false nowait = true|false, -- Default: false expr = true|false, -- RHS is an expression, default: false @@ -918,13 +919,19 @@ replace_keycodes (boolean, optional) When `expr` is true, replace keycodes in the resulting string. Default: true when `expr` is true; otherwise unused. + *zpack.KeySpec.buffer* +buffer (integer|boolean, optional) + Buffer scope (lazy.nvim parity). `true` or `0` installs the + keymap buffer-locally in the current buffer; an integer + installs it in that specific buffer. Honored by both the + lazy proxy and the real keymap. + *zpack.KeySpec.ft* ft (string|string[], optional) - FileType scope (lazy.nvim parity). When set, the lazy - proxy is installed buffer-locally on a matching FileType - event instead of being installed globally up-front. After - the plugin loads, the real keymap is installed globally - via the spec's `[2]` rhs. + FileType scope (lazy.nvim parity). The keymap (both the + lazy proxy and the real keymap installed on load) is + buffer-local to buffers matching the given filetype, via + a FileType autocmd that also catches future buffers. *zpack.KeySpec.nop* A KeySpec whose `[2]` is `''` (any case) or the empty string is diff --git a/docs/spec.md b/docs/spec.md index e32151b..d41f215 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -29,7 +29,7 @@ -- Lazy loading triggers (auto-sets lazy=true unless overridden) -- All triggers can also be functions that receive zpack.Plugin and return the respective type - event = string|string[]|zpack.EventSpec|(string|zpack.EventSpec)[]|function(plugin), -- Autocommand event(s). Supports 'VeryLazy' and inline patterns: "BufReadPre *.lua" + event = string|string[]|zpack.EventSpec|(string|zpack.EventSpec)[]|function(plugin), -- Autocommand event(s). Supports 'VeryLazy' and inline patterns: "BufReadPre *.lua". zpack auto-emits `User VeryLazy` once on UIEnter so user-config `autocmd User VeryLazy` hooks fire. pattern = string|string[], -- Global fallback pattern(s) for all events cmd = string|string[]|function(plugin), -- Command(s) to create keys = zpack.KeySpec|zpack.KeySpec[]|function(plugin), -- Keymap(s) to create @@ -83,7 +83,8 @@ The plugin data object passed to hooks and trigger functions: [2] = function() end, -- RHS function desc = "description", -- Keymap description mode = "n"|{"n","v"}, -- Mode(s), default: "n" - ft = "lua"|{"lua","vim"}, -- FileType scope; proxy installs buffer-locally only + ft = "lua"|{"lua","vim"}, -- FileType scope; keymap installs buffer-locally on matching FileType only + buffer = true|0|7, -- Buffer scope: true/0 = current buffer; integer = specific buffer remap = true|false, -- Allow remapping, default: false nowait = true|false, -- Default: false expr = true|false, -- RHS is an expression, default: false diff --git a/lua/zpack/keymap.lua b/lua/zpack/keymap.lua index 7dcfed9..ed64480 100644 --- a/lua/zpack/keymap.lua +++ b/lua/zpack/keymap.lua @@ -1,4 +1,5 @@ local util = require('zpack.utils') +local state = require('zpack.state') local M = {} @@ -30,6 +31,43 @@ M.map = function(lhs, rhs, opts) vim.keymap.set(opts.mode or { 'n' }, lhs, rhs, set_opts) end +---@param key zpack.KeySpec +---@param buf integer +---@param src string +local function install_buffer_local(key, buf, src) + local opts = vim.tbl_extend('force', {}, key, { buffer = buf }) + local ok, err = pcall(M.map, key[1], key[2], opts) + if not ok then + util.schedule_notify( + ("Failed to map %s for %s: %s"):format(key[1], src, tostring(err)), + vim.log.levels.ERROR + ) + end +end + +---@param key zpack.KeySpec +---@param src string +local function apply_ft_scoped(key, src) + local patterns = util.normalize_string_list(key.ft) --[[@as string[] ]] + local pat_set = {} + for _, p in ipairs(patterns) do pat_set[p] = true end + -- Catch future matching buffers. + vim.api.nvim_create_autocmd("FileType", { + group = state.lazy_group, + pattern = patterns, + callback = function(ev) + install_buffer_local(key, ev.buf, src) + end, + }) + -- Install in currently-matching buffers — their FileType has already + -- fired, so the autocmd above won't reach them. + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(buf) and pat_set[vim.bo[buf].filetype] then + install_buffer_local(key, buf, src) + end + end +end + ---@param keys zpack.KeySpec|zpack.KeySpec[]|string ---@param src string Plugin identifier for the failure notify M.apply_keys = function(keys, src) @@ -37,15 +75,25 @@ M.apply_keys = function(keys, src) for _, key in ipairs(key_list) do if key[2] ~= nil then - -- pcall per key so one malformed spec doesn't strand its siblings. - -- lazy_trigger/keys.lua's post-load maparg gate ensures an unmapped - -- lhs doesn't fall through to bare keystrokes typed in the buffer. - local ok, err = pcall(M.map, key[1], key[2], key) - if not ok then - util.schedule_notify( - ("Failed to map %s for %s: %s"):format(key[1], src, tostring(err)), - vim.log.levels.ERROR - ) + -- ft scope (lazy.nvim parity): install via FileType autocmd + iterate + -- already-matching buffers so the real keymap stays buffer-local. + -- Without this, a global apply_keys could silently overwrite a sibling + -- plugin that claimed the same lhs under a disjoint ft. + local has_ft = type(key.ft) == 'string' + or (type(key.ft) == 'table' and next(key.ft --[[@as table]]) ~= nil) + if has_ft then + apply_ft_scoped(key, src) + else + -- pcall per key so one malformed spec doesn't strand its siblings. + -- lazy_trigger/keys.lua's post-load maparg gate ensures an unmapped + -- lhs doesn't fall through to bare keystrokes typed in the buffer. + local ok, err = pcall(M.map, key[1], key[2], key) + if not ok then + util.schedule_notify( + ("Failed to map %s for %s: %s"):format(key[1], src, tostring(err)), + vim.log.levels.ERROR + ) + end end end end diff --git a/lua/zpack/lazy.lua b/lua/zpack/lazy.lua index 3e90045..f6f53b7 100644 --- a/lua/zpack/lazy.lua +++ b/lua/zpack/lazy.lua @@ -89,29 +89,6 @@ M.is_lazy = function(spec, plugin, src) return false end ----Fire `User VeryLazy` once on UIEnter (or immediately if vim has already ----entered). Matches lazy.nvim's contract so user autocmds keyed on ----`User VeryLazy` work in configs ported from lazy.nvim. Registered AFTER ----per-plugin UIEnter handlers so VeryLazy plugins are loaded by the time ----User VeryLazy fires. -local function fire_very_lazy() - local function emit() - vim.api.nvim_exec_autocmds('User', { pattern = 'VeryLazy', modeline = false }) - end - if vim.v.vim_did_enter == 1 then - vim.schedule(emit) - else - vim.api.nvim_create_autocmd('UIEnter', { - group = state.lazy_group, - once = true, - nested = true, - callback = function() - vim.schedule(emit) - end, - }) - end -end - ---@param ctx zpack.ProcessContext M.process_all = function(ctx) if next(state.src_with_pending_build) ~= nil then @@ -138,7 +115,9 @@ M.process_all = function(ctx) end cmd_handler.setup(ctx.registered_lazy_packs) keys_handler.setup(ctx.registered_lazy_packs) - fire_very_lazy() + -- Producer of the User VeryLazy emit lives next to its per-plugin VeryLazy + -- consumer (event_handler) so the synthetic-event protocol stays in one file. + event_handler.fire_very_lazy() end return M diff --git a/lua/zpack/lazy_trigger/event.lua b/lua/zpack/lazy_trigger/event.lua index d154a6c..ffef5b0 100644 --- a/lua/zpack/lazy_trigger/event.lua +++ b/lua/zpack/lazy_trigger/event.lua @@ -65,6 +65,29 @@ local split_very_lazy = function(events) return has_very_lazy, other_events end +---Fire `User VeryLazy` once on UIEnter (or immediately if vim has already +---entered). Matches lazy.nvim's contract so user autocmds keyed on +---`User VeryLazy` work in configs ported from lazy.nvim. Called from +---lazy.process_all AFTER per-plugin UIEnter handlers register so VeryLazy +---plugins are loaded by the time User VeryLazy fires. +M.fire_very_lazy = function() + local function emit() + vim.api.nvim_exec_autocmds('User', { pattern = 'VeryLazy', modeline = false }) + end + if vim.v.vim_did_enter == 1 then + vim.schedule(emit) + else + vim.api.nvim_create_autocmd('UIEnter', { + group = state.lazy_group, + once = true, + nested = true, + callback = function() + vim.schedule(emit) + end, + }) + end +end + ---@param pack_spec vim.pack.Spec ---@param spec zpack.Spec ---@param event zpack.EventValue @@ -85,7 +108,7 @@ M.setup = function(pack_spec, spec, event) loader.try_process_spec(pack_spec) end) else - util.autocmd("UIEnter", util.once_per_tick(function() + util.autocmd("UIEnter", util.latch_first_call(function() vim.schedule(function() loader.try_process_spec(pack_spec) end) @@ -94,11 +117,11 @@ M.setup = function(pack_spec, spec, event) end if #other_events > 0 then - -- once_per_tick latches before the load_status gate so a second fire - -- in the same tick bails before refire.exec can double-fire user - -- autocmds. The load_status gate handles other races (sibling + -- latch_first_call gates before the load_status check so a second + -- nested fire in the same tick bails before refire.exec can double-fire + -- user autocmds. The load_status gate handles other races (sibling -- event/ft already loaded, plugin/ files re-entering synchronously). - util.autocmd(other_events, util.once_per_tick(function(ev) + util.autocmd(other_events, util.latch_first_call(function(ev) local entry = state.spec_registry[pack_spec.src] if entry and entry.load_status ~= "pending" then return diff --git a/lua/zpack/lazy_trigger/ft.lua b/lua/zpack/lazy_trigger/ft.lua index 7adf03e..9491e55 100644 --- a/lua/zpack/lazy_trigger/ft.lua +++ b/lua/zpack/lazy_trigger/ft.lua @@ -20,10 +20,10 @@ M.setup = function(pack_spec, ft) util.source_ftdetect_files(plugin_path) end - -- once_per_tick guards against nvim#25526; needed here because the + -- latch_first_call guards against nvim#25526; needed here because the -- plugin's own `ftplugin/*` sourced during packadd can nest-fire FileType -- on the same buffer before load_status flips. - util.autocmd("FileType", util.once_per_tick(function(ev) + util.autocmd("FileType", util.latch_first_call(function(ev) -- Skip when a sibling already loaded (avoid double-fire) or is -- mid-load (avoid spurious circular-dependency notify). local entry = state.spec_registry[pack_spec.src] diff --git a/lua/zpack/lazy_trigger/keys.lua b/lua/zpack/lazy_trigger/keys.lua index c6a068c..81fd261 100644 --- a/lua/zpack/lazy_trigger/keys.lua +++ b/lua/zpack/lazy_trigger/keys.lua @@ -54,12 +54,15 @@ end local install_proxy = function(key_info, buf) local lhs = key_info.key_spec[1] local key_spec = key_info.key_spec + -- ft path forces buffer-local in `buf`; otherwise honor the user's + -- `key_spec.buffer` (lazy.nvim parity for unscoped buffer-local keys). + local proxy_buffer = buf or key_spec.buffer keymap.map(lhs, function() -- Mirror the install scope: a global proxy must delete the global -- mapping; a buffer-local proxy must delete the buffer-local one -- (otherwise vim.keymap.del finds nothing and the stale buffer-local -- proxy fires forever on the re-fed lhs). - if buf then + if proxy_buffer then pcall(vim.keymap.del, key_info.split_mode, lhs, { buffer = 0 }) else pcall(vim.keymap.del, key_info.split_mode, lhs) @@ -104,7 +107,7 @@ local install_proxy = function(key_info, buf) silent = key_spec.silent, remap = key_spec.remap, noremap = key_spec.noremap, - buffer = buf, + buffer = proxy_buffer, }) end @@ -129,7 +132,11 @@ end local function install_nop(key, src, buf) local nop_opts = vim.deepcopy(key) nop_opts.expr = nil - nop_opts.buffer = buf + -- ft path forces `buf`; otherwise leave user's `key.buffer` intact (already + -- copied above) so e.g. `{ 'X', '', buffer = true }` stays scoped. + if buf then + nop_opts.buffer = buf + end local ok, err = pcall(keymap.map, key[1], '', nop_opts) if not ok then util.schedule_notify( @@ -155,9 +162,16 @@ M.setup = function(registered_pack_specs) local mode = key.mode or 'n' local modes = util.normalize_string_list(mode) --[[@as string[] ]] - -- Only string/table ft is honored as a scope; anything else is a - -- type error best treated as "no ft" so the proxy/nop stays global. - local ft_scope = (type(key.ft) == 'string' or type(key.ft) == 'table') and key.ft or nil + -- Only string/non-empty-table ft is honored as a scope; an empty + -- table or non-string non-table is treated as "no ft" so the + -- proxy/nop stays global rather than registering an autocmd with + -- an unmatchable empty pattern list. + local ft_scope + if type(key.ft) == 'string' then + ft_scope = key.ft + elseif type(key.ft) == 'table' and next(key.ft --[[@as table]]) ~= nil then + ft_scope = key.ft + end local src = pack_spec.name or pack_spec.src -- rhs never needs the proxy: install as a real no-op so the @@ -197,17 +211,23 @@ M.setup = function(registered_pack_specs) for _, key_info in pairs(key_to_info) do if key_info.ft then -- ft-scoped: install the proxy buffer-locally each time a matching - -- buffer enters the filetype. Once every claiming plugin has loaded, - -- the autocmd no-ops; the global keymap from apply_keys handles - -- subsequent presses. - util.autocmd("FileType", function(ev) - if not any_pack_pending(key_info) then - return - end - install_proxy(key_info, ev.buf) - end, { + -- buffer enters the filetype. Self-delete once every claiming plugin + -- has loaded — apply_keys's own FileType autocmd then handles the + -- real keymap for future matching buffers. + local autocmd_id + autocmd_id = vim.api.nvim_create_autocmd("FileType", { group = state.lazy_group, pattern = util.normalize_string_list(key_info.ft), + callback = function(ev) + if not any_pack_pending(key_info) then + if autocmd_id then + pcall(vim.api.nvim_del_autocmd, autocmd_id) + autocmd_id = nil + end + return + end + install_proxy(key_info, ev.buf) + end, }) else install_proxy(key_info, nil) diff --git a/lua/zpack/merge.lua b/lua/zpack/merge.lua index 4ef8f08..c4cfe74 100644 --- a/lua/zpack/merge.lua +++ b/lua/zpack/merge.lua @@ -64,8 +64,9 @@ end ---Get unique key for a value (handles KeySpec with mode + ft). ---ft is included so two specs with the same lhs/mode but disjoint ft ---scopes (`{x, ..., ft='lua'}` + `{x, ..., ft='rust'}`) ----survive merge instead of the second silently dropping. Matches the ----ft-aware dedup in lazy_trigger/keys.lua's create_key_id. +---survive merge instead of the second silently dropping. Mirrors the +---ft-aware dedup *intent* of lazy_trigger/keys.lua's create_key_id (each +---module owns its own format; only the (lhs, mode, ft) identity is shared). ---@param v any ---@return string local function get_unique_key(v) diff --git a/lua/zpack/types.lua b/lua/zpack/types.lua index 7cafc1a..918c34f 100644 --- a/lua/zpack/types.lua +++ b/lua/zpack/types.lua @@ -12,7 +12,7 @@ ---@field [1] string ---@field [2]? string|fun() ---@field noremap? boolean ----@field ft? string|string[] FileType scope (lazy.nvim parity); install proxy buffer-locally on matching FileType only +---@field ft? string|string[] FileType scope (lazy.nvim parity); keymap installs buffer-locally on matching FileType only (both proxy and real keymap) ---@class zpack.EventSpec ---@field event string|string[] Event name(s) to trigger on diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua index 6ac2e4e..2ca2dde 100644 --- a/lua/zpack/utils.lua +++ b/lua/zpack/utils.lua @@ -132,13 +132,15 @@ M.autocmd = function(event, callback, opts) }, opts)) end ----Wrap a callback so a synchronous second invocation no-ops. Guards against ----nvim#25526 (https://github.com/neovim/neovim/issues/25526) — a `once = true` ----autocmd that nested-fires in the same tick is dispatched twice before the ----autocmd-deletion takes effect. +---Wrap a callback so the second (and subsequent) calls no-op. Used to guard +---an autocmd against nvim#25526 (https://github.com/neovim/neovim/issues/25526) +---— a `once = true` autocmd that nested-fires in the same tick is dispatched +---twice before the autocmd-deletion takes effect. The latch is permanent (no +---per-tick reset); call sites combine this with `once = true` so the wrapping +---autocmd self-deletes after the first dispatch. ---@param callback function ---@return function -M.once_per_tick = function(callback) +M.latch_first_call = function(callback) local done = false return function(...) if done then @@ -387,9 +389,14 @@ M.source_ftdetect_files = function(plugin_path) local files = vim.fn.glob(plugin_path .. '/ftdetect/*.{vim,lua}', false, true) -- ftdetect files must execute inside the `filetypedetect` augroup so their -- autocmds register there (matches Neovim's standard ftdetect sourcing). + -- The augroup begin/end are issued as separate ex-commands rather than + -- `|`-chained with the `source`, because a throw inside the chained source + -- skips the trailing `augroup END` and leaves Neovim in `filetypedetect` + -- — subsequent vimscript `autocmd` statements would silently land there. for _, file in ipairs(files) do - local ok, err = pcall(vim.cmd, - ('augroup filetypedetect | source %s | augroup END'):format(vim.fn.fnameescape(file))) + vim.cmd('augroup filetypedetect') + local ok, err = pcall(vim.cmd.source, file) + vim.cmd('augroup END') if not ok then M.schedule_notify(("Failed to source %s: %s"):format(file, tostring(err)), vim.log.levels.ERROR) end diff --git a/tests/lazy_keys_test.lua b/tests/lazy_keys_test.lua index 9811009..c9e389e 100644 --- a/tests/lazy_keys_test.lua +++ b/tests/lazy_keys_test.lua @@ -1230,4 +1230,158 @@ describe("Lazy Loading - Keymaps", function() vim.api.nvim_buf_delete(buf, { force = true }) end) + + -- An empty-string rhs is treated as a no-op by `is_nop_rhs` for lazy.nvim + -- parity; verify it installs a real keymap and skips the proxy. + it("KeySpec with empty-string rhs installs real keymap and skips proxy", function() + local state = require('zpack.state') + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tne', '' }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local found_map + for _, map in ipairs(vim.api.nvim_get_keymap('n')) do + if map.lhs == ' tne' then + found_map = map + break + end + end + assert.is_not_nil(found_map, "Empty-string rhs should install a real keymap") + assert.is_nil(found_map.callback, + "empty-string rhs must install a non-proxy keymap (no callback)") + + vim.api.nvim_feedkeys(' tne', 'mx', false) + helpers.flush_pending() + + local src = 'https://github.com/test/plugin' + assert.are.equal("pending", state.spec_registry[src].load_status, + "Plugin must remain unloaded after pressing an empty-rhs key") + end) + + -- Regression: two plugins claiming the same lhs under disjoint ft scopes + -- must not collide globally. Before apply_keys honored `key.ft`, the second + -- plugin to load would install a *global* real keymap that silently + -- overwrote the first plugin's keymap in every buffer (including the first + -- plugin's own ft buffers). + it("ft-scoped real keymap stays buffer-local after lazy-load", function() + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tfx', function() _G._test_ft_real_cb = 'A' end, ft = 'lua' }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local buf = vim.api.nvim_create_buf(true, false) + vim.api.nvim_set_current_buf(buf) + vim.bo[buf].filetype = 'lua' + helpers.flush_pending() + + local proxy + for _, map in ipairs(vim.api.nvim_buf_get_keymap(buf, 'n')) do + if map.lhs == ' tfx' then + proxy = map + end + end + assert.is_not_nil(proxy, "buffer-local proxy must be installed on FileType lua") + proxy.callback() + helpers.flush_pending() + + -- After load, no global keymap should exist — the post-load keymap is + -- installed via apply_keys's own FileType autocmd, buffer-local only. + for _, map in ipairs(vim.api.nvim_get_keymap('n')) do + assert.are_not.equal(' tfx', map.lhs, + "ft-scoped real keymap must not install globally after load") + end + + -- A real (non-proxy) keymap should exist in the matching ft buffer. + local real + for _, map in ipairs(vim.api.nvim_buf_get_keymap(buf, 'n')) do + if map.lhs == ' tfx' then real = map end + end + assert.is_not_nil(real, "real keymap must be installed buffer-local in matching ft") + + vim.api.nvim_buf_delete(buf, { force = true }) + _G._test_ft_real_cb = nil + end) + + -- KeySpec.buffer (lazy.nvim parity) must flow through the lazy proxy when + -- no ft scope is set; previously the proxy hardcoded `buffer = nil` and + -- silently dropped the user's intent. + it("KeySpec.buffer scopes the lazy proxy to the requested buffer", function() + local buf = vim.api.nvim_create_buf(true, false) + vim.api.nvim_set_current_buf(buf) + + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tbb', function() end, buffer = buf }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local found_local + for _, map in ipairs(vim.api.nvim_buf_get_keymap(buf, 'n')) do + if map.lhs == ' tbb' then found_local = map end + end + assert.is_not_nil(found_local, + "Buffer-scoped key must install the proxy in the requested buffer") + assert.are.equal(buf, found_local.buffer, "Proxy must be buffer-local to buf") + + for _, map in ipairs(vim.api.nvim_get_keymap('n')) do + assert.are_not.equal(' tbb', map.lhs, + "Buffer-scoped key must not also install a global proxy") + end + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + -- ft = {} normalizes to "no ft scope" — installing an autocmd with an + -- empty pattern list would silently never match, dropping the key. + it("KeySpec with empty ft = {} falls back to a global proxy", function() + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tfe', function() end, ft = {} }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local found_global + for _, map in ipairs(vim.api.nvim_get_keymap('n')) do + if map.lhs == ' tfe' then found_global = map end + end + assert.is_not_nil(found_global, + "Empty `ft = {}` must not silently disable the key — install globally") + assert.is_not_nil(found_global.callback, + "Empty `ft = {}` key should still register as a lazy proxy (callback present)") + end) end) diff --git a/tests/source_plugin_files_test.lua b/tests/source_plugin_files_test.lua index 6a05c67..a3d46bb 100644 --- a/tests/source_plugin_files_test.lua +++ b/tests/source_plugin_files_test.lua @@ -194,3 +194,122 @@ describe("source_after_plugin_files", function() vim.fn.delete(tmpdir, "rf") end) end) + +describe("source_ftdetect_files", function() + before_each(helpers.setup_test_env) + after_each(helpers.cleanup_test_env) + + it("sources lua files from ftdetect/ directory", function() + local utils = require('zpack.utils') + local tmpdir = vim.fn.tempname() + local ftdetect_dir = tmpdir .. "/ftdetect" + vim.fn.mkdir(ftdetect_dir, "p") + + local f = io.open(ftdetect_dir .. "/zztest.lua", "w") + f:write("_G._test_ftdetect_ran = true\n") + f:close() + + _G._test_ftdetect_ran = nil + utils.source_ftdetect_files(tmpdir) + + assert.is_true(_G._test_ftdetect_ran == true, + "ftdetect/ lua file should be sourced") + + _G._test_ftdetect_ran = nil + vim.fn.delete(tmpdir, "rf") + end) + + it("does not source same path twice", function() + local utils = require('zpack.utils') + local tmpdir = vim.fn.tempname() + local ftdetect_dir = tmpdir .. "/ftdetect" + vim.fn.mkdir(ftdetect_dir, "p") + + local f = io.open(ftdetect_dir .. "/counter.lua", "w") + f:write("_G._test_ftdetect_count = (_G._test_ftdetect_count or 0) + 1\n") + f:close() + + _G._test_ftdetect_count = nil + utils.source_ftdetect_files(tmpdir) + utils.source_ftdetect_files(tmpdir) + + assert.are.equal(1, _G._test_ftdetect_count) + + _G._test_ftdetect_count = nil + vim.fn.delete(tmpdir, "rf") + end) + + it("handles missing ftdetect/ directory gracefully", function() + local utils = require('zpack.utils') + local tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, "p") + + local ok, err = pcall(utils.source_ftdetect_files, tmpdir) + assert.is_true(ok, "should not error on missing ftdetect/: " .. tostring(err)) + + vim.fn.delete(tmpdir, "rf") + end) + + it("continues sourcing later files when one throws", function() + local utils = require('zpack.utils') + local tmpdir = vim.fn.tempname() + local ftdetect_dir = tmpdir .. "/ftdetect" + vim.fn.mkdir(ftdetect_dir, "p") + + local f = io.open(ftdetect_dir .. "/a_throws.lua", "w") + f:write("error('intentional throw from a_throws.lua')\n") + f:close() + + f = io.open(ftdetect_dir .. "/b_ok.lua", "w") + f:write("_G._test_ftdetect_b_ran = true\n") + f:close() + + _G._test_ftdetect_b_ran = nil + utils.source_ftdetect_files(tmpdir) + helpers.flush_pending() + + assert.is_true(_G._test_ftdetect_b_ran == true, + "later file must still source after an earlier file throws") + + local saw_notify = false + for _, n in ipairs(_G.test_state.notifications) do + if n.msg:find("Failed to source.*a_throws%.lua") then + saw_notify = true + break + end + end + assert.is_true(saw_notify, "throwing file should surface a structured notify") + + _G._test_ftdetect_b_ran = nil + vim.fn.delete(tmpdir, "rf") + end) + + -- Regression: a throwing ftdetect file used to leak the `filetypedetect` + -- augroup (the `augroup END` was `|`-chained with the `source` and never + -- ran on throw). Any subsequent vimscript `autocmd` then landed in + -- `filetypedetect` instead of the default group. + it("throwing ftdetect file does not leak filetypedetect augroup", function() + local utils = require('zpack.utils') + local tmpdir = vim.fn.tempname() + local ftdetect_dir = tmpdir .. "/ftdetect" + vim.fn.mkdir(ftdetect_dir, "p") + + local f = io.open(ftdetect_dir .. "/boom.lua", "w") + f:write("error('intentional throw')\n") + f:close() + + utils.source_ftdetect_files(tmpdir) + helpers.flush_pending() + + -- Register a vimscript autocmd with no explicit group; if the augroup + -- leaked, this would be reported under `filetypedetect`. + vim.cmd([[autocmd BufRead *.zz_augroup_check echo "test"]]) + local listing = vim.fn.execute("autocmd BufRead *.zz_augroup_check") + + assert.is_nil(listing:match("filetypedetect"), + "subsequent autocmd must not land in the filetypedetect augroup") + + vim.cmd([[autocmd! BufRead *.zz_augroup_check]]) + vim.fn.delete(tmpdir, "rf") + end) +end) From 2f858a99452fa15a8c4c2063259d99096565bdb0 Mon Sep 17 00:00:00 2001 From: zuqini Date: Sun, 24 May 2026 14:05:33 -0700 Subject: [PATCH 5/8] fix: close two more parity gaps from a fifth-pass review 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 +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 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. --- lua/zpack/keymap.lua | 21 ++++++++++----------- lua/zpack/lazy_trigger/keys.lua | 12 +++++++++++- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/lua/zpack/keymap.lua b/lua/zpack/keymap.lua index ed64480..3ef02ca 100644 --- a/lua/zpack/keymap.lua +++ b/lua/zpack/keymap.lua @@ -81,19 +81,18 @@ M.apply_keys = function(keys, src) -- plugin that claimed the same lhs under a disjoint ft. local has_ft = type(key.ft) == 'string' or (type(key.ft) == 'table' and next(key.ft --[[@as table]]) ~= nil) + -- pcall per key so one malformed spec doesn't strand its siblings. + local ok, err if has_ft then - apply_ft_scoped(key, src) + ok, err = pcall(apply_ft_scoped, key, src) else - -- pcall per key so one malformed spec doesn't strand its siblings. - -- lazy_trigger/keys.lua's post-load maparg gate ensures an unmapped - -- lhs doesn't fall through to bare keystrokes typed in the buffer. - local ok, err = pcall(M.map, key[1], key[2], key) - if not ok then - util.schedule_notify( - ("Failed to map %s for %s: %s"):format(key[1], src, tostring(err)), - vim.log.levels.ERROR - ) - end + ok, err = pcall(M.map, key[1], key[2], key) + end + if not ok then + util.schedule_notify( + ("Failed to map %s for %s: %s"):format(key[1], src, tostring(err)), + vim.log.levels.ERROR + ) end end end diff --git a/lua/zpack/lazy_trigger/keys.lua b/lua/zpack/lazy_trigger/keys.lua index 81fd261..9a0bb24 100644 --- a/lua/zpack/lazy_trigger/keys.lua +++ b/lua/zpack/lazy_trigger/keys.lua @@ -180,12 +180,22 @@ M.setup = function(registered_pack_specs) -- suppression is scoped, matching lazy.nvim's ft-on-Nop behavior. if is_nop_rhs(key[2]) then if ft_scope then + local patterns = util.normalize_string_list(ft_scope) --[[@as string[] ]] util.autocmd("FileType", function(ev) install_nop(key, src, ev.buf) end, { group = state.lazy_group, - pattern = util.normalize_string_list(ft_scope), + pattern = patterns, }) + -- Currently-matching buffers already fired FileType; install + -- for them too. Mirrors apply_ft_scoped. + local pat_set = {} + for _, p in ipairs(patterns) do pat_set[p] = true end + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(buf) and pat_set[vim.bo[buf].filetype] then + install_nop(key, src, buf) + end + end else install_nop(key, src, nil) end From 4350a87a2145fecc540f20f5c706298d6e5f43e1 Mon Sep 17 00:00:00 2001 From: zuqini Date: Sun, 24 May 2026 14:36:08 -0700 Subject: [PATCH 6/8] fix: close sixth-pass review gaps (KeySpec.buffer dedup, VeryLazy latch, ft sweep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- lua/zpack/keymap.lua | 22 ++------ lua/zpack/lazy_trigger/event.lua | 8 +-- lua/zpack/lazy_trigger/keys.lua | 86 ++++++++++++++++---------------- lua/zpack/merge.lua | 22 ++++---- lua/zpack/utils.lua | 29 +++++++++++ tests/lazy_keys_test.lua | 81 ++++++++++++++++++++++++++++++ tests/merge_test.lua | 48 ++++++++++++++++++ tests/utils_test.lua | 31 ++++++++++++ 8 files changed, 253 insertions(+), 74 deletions(-) diff --git a/lua/zpack/keymap.lua b/lua/zpack/keymap.lua index 3ef02ca..6924960 100644 --- a/lua/zpack/keymap.lua +++ b/lua/zpack/keymap.lua @@ -49,23 +49,9 @@ end ---@param src string local function apply_ft_scoped(key, src) local patterns = util.normalize_string_list(key.ft) --[[@as string[] ]] - local pat_set = {} - for _, p in ipairs(patterns) do pat_set[p] = true end - -- Catch future matching buffers. - vim.api.nvim_create_autocmd("FileType", { - group = state.lazy_group, - pattern = patterns, - callback = function(ev) - install_buffer_local(key, ev.buf, src) - end, - }) - -- Install in currently-matching buffers — their FileType has already - -- fired, so the autocmd above won't reach them. - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(buf) and pat_set[vim.bo[buf].filetype] then - install_buffer_local(key, buf, src) - end - end + util.install_on_ft(patterns, function(buf) + install_buffer_local(key, buf, src) + end, { group = state.lazy_group }) end ---@param keys zpack.KeySpec|zpack.KeySpec[]|string @@ -79,7 +65,7 @@ M.apply_keys = function(keys, src) -- already-matching buffers so the real keymap stays buffer-local. -- Without this, a global apply_keys could silently overwrite a sibling -- plugin that claimed the same lhs under a disjoint ft. - local has_ft = type(key.ft) == 'string' + local has_ft = (type(key.ft) == 'string' and key.ft ~= '') or (type(key.ft) == 'table' and next(key.ft --[[@as table]]) ~= nil) -- pcall per key so one malformed spec doesn't strand its siblings. local ok, err diff --git a/lua/zpack/lazy_trigger/event.lua b/lua/zpack/lazy_trigger/event.lua index ffef5b0..c60d6fb 100644 --- a/lua/zpack/lazy_trigger/event.lua +++ b/lua/zpack/lazy_trigger/event.lua @@ -77,13 +77,15 @@ M.fire_very_lazy = function() if vim.v.vim_did_enter == 1 then vim.schedule(emit) else + -- latch_first_call guards against nvim#25526 (nested UIEnter in the + -- same tick fires twice before once=true deletion takes effect) so + -- `User VeryLazy` only emits once. vim.api.nvim_create_autocmd('UIEnter', { group = state.lazy_group, once = true, - nested = true, - callback = function() + callback = util.latch_first_call(function() vim.schedule(emit) - end, + end), }) end end diff --git a/lua/zpack/lazy_trigger/keys.lua b/lua/zpack/lazy_trigger/keys.lua index 9a0bb24..7e7d140 100644 --- a/lua/zpack/lazy_trigger/keys.lua +++ b/lua/zpack/lazy_trigger/keys.lua @@ -17,13 +17,25 @@ local ft_key_part = function(ft) return '-ft:' .. table.concat(sorted, ',') end ----Create a unique key identifier from lhs, mode, and (optional) ft scope. ----@param lhs string The key mapping (e.g., "ff") ----@param mode string The mode (e.g., "n", "v") ----@param ft string|string[]|nil Optional filetype scope ----@return string Unique identifier -local create_key_id = function(lhs, mode, ft) - return lhs .. '-' .. mode .. ft_key_part(ft) +---@param buffer any +---@return string +local buffer_key_part = function(buffer) + if buffer == nil then + return '' + end + -- lazy.nvim parity: `buffer = true` and `buffer = 0` both mean "current + -- buffer" — coerce so they share an entry. + local b = buffer == true and 0 or buffer + return '-buf:' .. tostring(b) +end + +---@param lhs string +---@param mode string +---@param ft string|string[]|nil +---@param buffer integer|boolean|nil +---@return string +local create_key_id = function(lhs, mode, ft, buffer) + return lhs .. '-' .. mode .. ft_key_part(ft) .. buffer_key_part(buffer) end ---@param rhs any @@ -122,18 +134,18 @@ local function any_pack_pending(key_info) return false end ----Install a (buffer-local when `buf` is non-nil) real `` keymap from ----the user's KeySpec. `expr` is stripped so vim does not eval the literal ----string `` as an expression; `keymap.map` nulls `replace_keycodes` ----when `expr` is unset, so it does not need a separate strip. +---Install a (buffer-local when `buf` is non-nil) real `` keymap. +---`expr`/`replace_keycodes` are stripped (vim.keymap.set raises when +---`replace_keycodes` is set without `expr`). ---@param key zpack.KeySpec ---@param src string ---@param buf? integer local function install_nop(key, src, buf) local nop_opts = vim.deepcopy(key) nop_opts.expr = nil - -- ft path forces `buf`; otherwise leave user's `key.buffer` intact (already - -- copied above) so e.g. `{ 'X', '', buffer = true }` stays scoped. + nop_opts.replace_keycodes = nil + -- ft path forces `buf`; otherwise leave the user's `key.buffer` intact + -- so e.g. `{ 'X', '', buffer = true }` stays scoped. if buf then nop_opts.buffer = buf end @@ -162,12 +174,10 @@ M.setup = function(registered_pack_specs) local mode = key.mode or 'n' local modes = util.normalize_string_list(mode) --[[@as string[] ]] - -- Only string/non-empty-table ft is honored as a scope; an empty - -- table or non-string non-table is treated as "no ft" so the - -- proxy/nop stays global rather than registering an autocmd with - -- an unmatchable empty pattern list. + -- Empty ft → no scope, so the autocmd doesn't register an + -- unmatchable empty pattern list and silently drop the key. local ft_scope - if type(key.ft) == 'string' then + if type(key.ft) == 'string' and key.ft ~= '' then ft_scope = key.ft elseif type(key.ft) == 'table' and next(key.ft --[[@as table]]) ~= nil then ft_scope = key.ft @@ -181,27 +191,15 @@ M.setup = function(registered_pack_specs) if is_nop_rhs(key[2]) then if ft_scope then local patterns = util.normalize_string_list(ft_scope) --[[@as string[] ]] - util.autocmd("FileType", function(ev) - install_nop(key, src, ev.buf) - end, { - group = state.lazy_group, - pattern = patterns, - }) - -- Currently-matching buffers already fired FileType; install - -- for them too. Mirrors apply_ft_scoped. - local pat_set = {} - for _, p in ipairs(patterns) do pat_set[p] = true end - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(buf) and pat_set[vim.bo[buf].filetype] then - install_nop(key, src, buf) - end - end + util.install_on_ft(patterns, function(buf) + install_nop(key, src, buf) + end, { group = state.lazy_group }) else install_nop(key, src, nil) end else for _, m in ipairs(modes) do - local key_id = create_key_id(lhs, m, ft_scope) + local key_id = create_key_id(lhs, m, ft_scope, key.buffer) if not key_to_info[key_id] then key_to_info[key_id] = { split_mode = m, @@ -220,15 +218,14 @@ M.setup = function(registered_pack_specs) -- Create keymaps for _, key_info in pairs(key_to_info) do if key_info.ft then - -- ft-scoped: install the proxy buffer-locally each time a matching - -- buffer enters the filetype. Self-delete once every claiming plugin - -- has loaded — apply_keys's own FileType autocmd then handles the - -- real keymap for future matching buffers. + -- Proxy self-deletes once every claiming plugin has loaded — + -- apply_keys's own FileType autocmd takes over for future buffers. + -- During the sweep all packs are still `pending`, so the self-delete + -- branch never fires before `autocmd_id` is assigned. local autocmd_id - autocmd_id = vim.api.nvim_create_autocmd("FileType", { - group = state.lazy_group, - pattern = util.normalize_string_list(key_info.ft), - callback = function(ev) + autocmd_id = util.install_on_ft( + util.normalize_string_list(key_info.ft) --[[@as string[] ]], + function(buf) if not any_pack_pending(key_info) then if autocmd_id then pcall(vim.api.nvim_del_autocmd, autocmd_id) @@ -236,9 +233,10 @@ M.setup = function(registered_pack_specs) end return end - install_proxy(key_info, ev.buf) + install_proxy(key_info, buf) end, - }) + { group = state.lazy_group } + ) else install_proxy(key_info, nil) end diff --git a/lua/zpack/merge.lua b/lua/zpack/merge.lua index c4cfe74..79f1a89 100644 --- a/lua/zpack/merge.lua +++ b/lua/zpack/merge.lua @@ -61,12 +61,9 @@ local function to_array(val) return { val } end ----Get unique key for a value (handles KeySpec with mode + ft). ----ft is included so two specs with the same lhs/mode but disjoint ft ----scopes (`{x, ..., ft='lua'}` + `{x, ..., ft='rust'}`) ----survive merge instead of the second silently dropping. Mirrors the ----ft-aware dedup *intent* of lazy_trigger/keys.lua's create_key_id (each ----module owns its own format; only the (lhs, mode, ft) identity is shared). +---Identity is (lhs, mode, ft, buffer) so disjoint-scope specs (same lhs +---under different ft/buffer) both survive merge. Format is private — only +---the identity is shared with lazy_trigger/keys.lua's create_key_id. ---@param v any ---@return string local function get_unique_key(v) @@ -81,14 +78,21 @@ local function get_unique_key(v) mode = table.concat(sorted, ",") end local ft = "" - if type(v.ft) == "string" then + if type(v.ft) == "string" and v.ft ~= "" then ft = ":ft=" .. v.ft - elseif type(v.ft) == "table" then + elseif type(v.ft) == "table" and next(v.ft) ~= nil then local sorted = vim.list_slice(v.ft) table.sort(sorted) ft = ":ft=" .. table.concat(sorted, ",") end - return lhs .. ":" .. mode .. ft + local buf = "" + if v.buffer ~= nil then + -- lazy.nvim parity: `buffer = true` and `buffer = 0` both mean "current + -- buffer" — coerce so they share a key. + local b = v.buffer == true and 0 or v.buffer + buf = ":buf=" .. tostring(b) + end + return lhs .. ":" .. mode .. ft .. buf end ---Extend list with unique values diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua index 2ca2dde..512ea1c 100644 --- a/lua/zpack/utils.lua +++ b/lua/zpack/utils.lua @@ -151,6 +151,35 @@ M.latch_first_call = function(callback) end end +---Register a `FileType` autocmd for `patterns` AND call `installer(buf)` +---for every already-loaded matching buffer — their FileType has already +---fired and won't re-fire. +---@param patterns string[] +---@param installer fun(buf: integer) +---@param opts? table Extra opts merged into the autocmd (group, etc.) +---@return integer autocmd_id +M.install_on_ft = function(patterns, installer, opts) + local pat_set = {} + local deduped = {} + for _, p in ipairs(patterns) do + if not pat_set[p] then + pat_set[p] = true + table.insert(deduped, p) + end + end + local autocmd_opts = vim.tbl_extend("force", opts or {}, { + pattern = deduped, + callback = function(ev) installer(ev.buf) end, + }) + local id = vim.api.nvim_create_autocmd("FileType", autocmd_opts) + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(buf) and pat_set[vim.bo[buf].filetype] then + installer(buf) + end + end + return id +end + ---Resolve a function-form spec field; a throw becomes a structured notify ---and a nil return instead of aborting the caller. ---@param field any diff --git a/tests/lazy_keys_test.lua b/tests/lazy_keys_test.lua index c9e389e..62cec3c 100644 --- a/tests/lazy_keys_test.lua +++ b/tests/lazy_keys_test.lua @@ -1358,6 +1358,87 @@ describe("Lazy Loading - Keymaps", function() vim.api.nvim_buf_delete(buf, { force = true }) end) + -- Two DIFFERENT plugins claiming the same lhs+mode but with disjoint + -- `buffer` scopes must each get their own buffer-local proxy. Before + -- create_key_id was buffer-aware, both plugins collapsed into one + -- `key_to_info` entry, the first plugin's `buffer` won, and the second + -- plugin's intended buffer never got a proxy (silent miss). + it("KeySpec.buffer disambiguates the proxy across plugin sources", function() + local buf_a = vim.api.nvim_create_buf(true, false) + local buf_b = vim.api.nvim_create_buf(true, false) + + require('zpack').setup({ + spec = { + { + 'test/plugin-a', + keys = { { 'tbx', function() end, buffer = buf_a } }, + }, + { + 'test/plugin-b', + keys = { { 'tbx', function() end, buffer = buf_b } }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local function has_lhs(buf) + for _, m in ipairs(vim.api.nvim_buf_get_keymap(buf, 'n')) do + if m.lhs == ' tbx' then return true end + end + return false + end + + assert.is_true(has_lhs(buf_a), + "plugin-a's buffer must get its own proxy") + assert.is_true(has_lhs(buf_b), + "plugin-b's buffer must get its own proxy (silent-miss regression)") + + for _, map in ipairs(vim.api.nvim_get_keymap('n')) do + assert.are_not.equal(' tbx', map.lhs, + "Buffer-scoped keys must not also install a global proxy") + end + + vim.api.nvim_buf_delete(buf_a, { force = true }) + vim.api.nvim_buf_delete(buf_b, { force = true }) + end) + + -- ft-scoped lazy proxy must install in buffers that ALREADY match the + -- filetype at setup() time — their FileType event already fired in the + -- past, so the autocmd alone never reaches them. Without the sweep, + -- `:luafile %` (config reload while a matching buffer is current) would + -- leave the lhs untriggerable in that buffer. + it("ft-scoped lazy proxy installs in existing matching buffers at setup", function() + local buf = vim.api.nvim_create_buf(true, false) + vim.api.nvim_set_current_buf(buf) + vim.bo[buf].filetype = 'lua' + helpers.flush_pending() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tff', function() end, ft = 'lua' }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local found + for _, map in ipairs(vim.api.nvim_buf_get_keymap(buf, 'n')) do + if map.lhs == ' tff' then found = map end + end + assert.is_not_nil(found, + "Proxy must install in a buffer already at the ft when setup() runs") + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + -- ft = {} normalizes to "no ft scope" — installing an autocmd with an -- empty pattern list would silently never match, dropping the key. it("KeySpec with empty ft = {} falls back to a global proxy", function() diff --git a/tests/merge_test.lua b/tests/merge_test.lua index b3a7f0b..f56b5f8 100644 --- a/tests/merge_test.lua +++ b/tests/merge_test.lua @@ -434,4 +434,52 @@ describe("Merge Module Unit Tests", function() assert.are.equal(1, #merged.keys, "Ft lists with same members in different order should dedup") end) + + -- Symmetric with the ft case above: two specs declaring the same lhs/mode + -- but disjoint `buffer` scopes are NOT duplicates — get_unique_key must + -- include buffer so the second spec survives merge. + it("keys with same lhs/mode but different buffer scopes both survive merge", function() + local buf_a = vim.api.nvim_create_buf(true, false) + local buf_b = vim.api.nvim_create_buf(true, false) + + require('zpack').setup({ + spec = { + { 'test/plugin', keys = { { 'b', 'A', buffer = buf_a } } }, + { 'test/plugin', keys = { { 'b', 'B', buffer = buf_b } } }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local state = require('zpack.state') + local src = 'https://github.com/test/plugin' + local merged = state.spec_registry[src].merged_spec + + assert.are.equal(2, #merged.keys, + "Both buffer-scoped keys should survive merge") + + vim.api.nvim_buf_delete(buf_a, { force = true }) + vim.api.nvim_buf_delete(buf_b, { force = true }) + end) + + -- lazy.nvim parity: `buffer = true` and `buffer = 0` both mean "current + -- buffer at registration time", so they MUST collapse to one entry — + -- otherwise a user toggling between the two forms gets a duplicate. + it("keys with buffer = true and buffer = 0 are deduplicated", function() + require('zpack').setup({ + spec = { + { 'test/plugin', keys = { { 'c', 'A', buffer = true } } }, + { 'test/plugin', keys = { { 'c', 'B', buffer = 0 } } }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local state = require('zpack.state') + local src = 'https://github.com/test/plugin' + local merged = state.spec_registry[src].merged_spec + + assert.are.equal(1, #merged.keys, + "buffer = true and buffer = 0 should hash to the same entry") + end) end) diff --git a/tests/utils_test.lua b/tests/utils_test.lua index 6d83dd3..9c69326 100644 --- a/tests/utils_test.lua +++ b/tests/utils_test.lua @@ -161,3 +161,34 @@ describe("is_semver_like utility", function() assert.are.equal(false, utils.is_semver_like({})) end) end) + +describe('latch_first_call', function() + it("invokes the inner callback exactly once across repeated calls", function() + local utils = require('zpack.utils') + local count = 0 + local latched = utils.latch_first_call(function() count = count + 1 end) + + latched() + latched() + latched() + + assert.are.equal(1, count, + "Latch must absorb every call after the first (guards nvim#25526 double-dispatch)") + end) + + it("forwards args and the return value on the first call", function() + local utils = require('zpack.utils') + local seen + local latched = utils.latch_first_call(function(a, b) + seen = { a, b } + return a + b + end) + + local result = latched(2, 3) + assert.are.same({ 2, 3 }, seen) + assert.are.equal(5, result) + + assert.is_nil(latched(99, 1), + "Subsequent calls must not invoke the inner — return nil") + end) +end) From 0c9de8d143f9d191fa5204f5521acbaa05c484d0 Mon Sep 17 00:00:00 2001 From: zuqini Date: Sun, 24 May 2026 15:15:08 -0700 Subject: [PATCH 7/8] fix: close seventh-pass review gaps (try_map, ft_scope helper, install_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. --- doc/zpack.txt | 4 ++- docs/spec.md | 2 +- lua/zpack/keymap.lua | 44 +++++++++++++++--------------- lua/zpack/lazy_trigger/event.lua | 46 +++++++++++++++----------------- lua/zpack/lazy_trigger/keys.lua | 40 +++++++++++---------------- lua/zpack/utils.lua | 24 ++++++++++++++--- 6 files changed, 84 insertions(+), 76 deletions(-) diff --git a/doc/zpack.txt b/doc/zpack.txt index 33d9e02..eb87904 100644 --- a/doc/zpack.txt +++ b/doc/zpack.txt @@ -732,7 +732,9 @@ pattern (string|string[], optional) cmd (string|string[]|function(plugin), optional) Command(s) to create that trigger lazy loading. Can be a function receiving |zpack.Plugin| that returns the value. - Auto-sets `lazy=true`. + Auto-sets `lazy=true`. Tab-completion on the command also + triggers the load so the real command's completer can + answer on the first press. *zpack-Spec.keys* keys (zpack.KeySpec|zpack.KeySpec[]|function(plugin), optional) diff --git a/docs/spec.md b/docs/spec.md index d41f215..287ed42 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -31,7 +31,7 @@ -- All triggers can also be functions that receive zpack.Plugin and return the respective type event = string|string[]|zpack.EventSpec|(string|zpack.EventSpec)[]|function(plugin), -- Autocommand event(s). Supports 'VeryLazy' and inline patterns: "BufReadPre *.lua". zpack auto-emits `User VeryLazy` once on UIEnter so user-config `autocmd User VeryLazy` hooks fire. pattern = string|string[], -- Global fallback pattern(s) for all events - cmd = string|string[]|function(plugin), -- Command(s) to create + cmd = string|string[]|function(plugin), -- Command(s) to create; tab-completion on the command also triggers the load keys = zpack.KeySpec|zpack.KeySpec[]|function(plugin), -- Keymap(s) to create ft = string|string[]|function(plugin), -- FileType(s) to lazy load on diff --git a/lua/zpack/keymap.lua b/lua/zpack/keymap.lua index 6924960..91c9b20 100644 --- a/lua/zpack/keymap.lua +++ b/lua/zpack/keymap.lua @@ -31,18 +31,22 @@ M.map = function(lhs, rhs, opts) vim.keymap.set(opts.mode or { 'n' }, lhs, rhs, set_opts) end ----@param key zpack.KeySpec ----@param buf integer ----@param src string -local function install_buffer_local(key, buf, src) - local opts = vim.tbl_extend('force', {}, key, { buffer = buf }) - local ok, err = pcall(M.map, key[1], key[2], opts) +---Wrap `M.map` with pcall + structured notify. Used at every map site so +---one bad key spec never strands siblings or recurs as autocmd-dispatch noise. +---@param lhs string +---@param rhs string|fun() +---@param opts? table +---@param src string Plugin identifier for the failure notify +---@return boolean ok +M.try_map = function(lhs, rhs, opts, src) + local ok, err = pcall(M.map, lhs, rhs, opts) if not ok then util.schedule_notify( - ("Failed to map %s for %s: %s"):format(key[1], src, tostring(err)), + ("Failed to map %s for %s: %s"):format(lhs, src, tostring(err)), vim.log.levels.ERROR ) end + return ok end ---@param key zpack.KeySpec @@ -50,7 +54,8 @@ end local function apply_ft_scoped(key, src) local patterns = util.normalize_string_list(key.ft) --[[@as string[] ]] util.install_on_ft(patterns, function(buf) - install_buffer_local(key, buf, src) + local opts = vim.tbl_extend('force', {}, key, { buffer = buf }) + M.try_map(key[1], key[2], opts, src) end, { group = state.lazy_group }) end @@ -65,20 +70,17 @@ M.apply_keys = function(keys, src) -- already-matching buffers so the real keymap stays buffer-local. -- Without this, a global apply_keys could silently overwrite a sibling -- plugin that claimed the same lhs under a disjoint ft. - local has_ft = (type(key.ft) == 'string' and key.ft ~= '') - or (type(key.ft) == 'table' and next(key.ft --[[@as table]]) ~= nil) - -- pcall per key so one malformed spec doesn't strand its siblings. - local ok, err - if has_ft then - ok, err = pcall(apply_ft_scoped, key, src) + if util.normalize_ft_scope(key.ft) then + -- pcall the registration plumbing; per-buffer try_map handles its own throws. + local ok, err = pcall(apply_ft_scoped, key, src) + if not ok then + util.schedule_notify( + ("Failed to map %s for %s: %s"):format(key[1], src, tostring(err)), + vim.log.levels.ERROR + ) + end else - ok, err = pcall(M.map, key[1], key[2], key) - end - if not ok then - util.schedule_notify( - ("Failed to map %s for %s: %s"):format(key[1], src, tostring(err)), - vim.log.levels.ERROR - ) + M.try_map(key[1], key[2], key, src) end end end diff --git a/lua/zpack/lazy_trigger/event.lua b/lua/zpack/lazy_trigger/event.lua index c60d6fb..929f5d6 100644 --- a/lua/zpack/lazy_trigger/event.lua +++ b/lua/zpack/lazy_trigger/event.lua @@ -65,31 +65,35 @@ local split_very_lazy = function(events) return has_very_lazy, other_events end ----Fire `User VeryLazy` once on UIEnter (or immediately if vim has already ----entered). Matches lazy.nvim's contract so user autocmds keyed on ----`User VeryLazy` work in configs ported from lazy.nvim. Called from ----lazy.process_all AFTER per-plugin UIEnter handlers register so VeryLazy ----plugins are loaded by the time User VeryLazy fires. -M.fire_very_lazy = function() - local function emit() - vim.api.nvim_exec_autocmds('User', { pattern = 'VeryLazy', modeline = false }) - end +---Schedule `cb` on the next tick (if vim has entered) or on the next +---UIEnter (latched against nvim#25526). Shared by the per-plugin VeryLazy +---load and the `User VeryLazy` emit so the latch lives in one place. +---@param cb function +local function on_ui_enter_or_now(cb) if vim.v.vim_did_enter == 1 then - vim.schedule(emit) + vim.schedule(cb) else - -- latch_first_call guards against nvim#25526 (nested UIEnter in the - -- same tick fires twice before once=true deletion takes effect) so - -- `User VeryLazy` only emits once. vim.api.nvim_create_autocmd('UIEnter', { group = state.lazy_group, once = true, callback = util.latch_first_call(function() - vim.schedule(emit) + vim.schedule(cb) end), }) end end +---Fire `User VeryLazy` once on UIEnter (or immediately if vim has already +---entered). Matches lazy.nvim's contract so user autocmds keyed on +---`User VeryLazy` work in configs ported from lazy.nvim. Called from +---lazy.process_all AFTER per-plugin UIEnter handlers register so VeryLazy +---plugins are loaded by the time User VeryLazy fires. +M.fire_very_lazy = function() + on_ui_enter_or_now(function() + vim.api.nvim_exec_autocmds('User', { pattern = 'VeryLazy', modeline = false }) + end) +end + ---@param pack_spec vim.pack.Spec ---@param spec zpack.Spec ---@param event zpack.EventValue @@ -105,17 +109,9 @@ M.setup = function(pack_spec, spec, event) -- UIEnter autocmd would never fire — schedule the load directly so -- the plugin still loads before lazy.fire_very_lazy's User VeryLazy -- emit (which also fast-paths on vim_did_enter). - if vim.v.vim_did_enter == 1 then - vim.schedule(function() - loader.try_process_spec(pack_spec) - end) - else - util.autocmd("UIEnter", util.latch_first_call(function() - vim.schedule(function() - loader.try_process_spec(pack_spec) - end) - end), { group = state.lazy_group, once = true }) - end + on_ui_enter_or_now(function() + loader.try_process_spec(pack_spec) + end) end if #other_events > 0 then diff --git a/lua/zpack/lazy_trigger/keys.lua b/lua/zpack/lazy_trigger/keys.lua index 7e7d140..5aa66c7 100644 --- a/lua/zpack/lazy_trigger/keys.lua +++ b/lua/zpack/lazy_trigger/keys.lua @@ -12,7 +12,7 @@ local ft_key_part = function(ft) return '' end local ft_list = util.normalize_string_list(ft) --[[@as string[] ]] - local sorted = { unpack(ft_list) } + local sorted = vim.list_slice(ft_list) table.sort(sorted) return '-ft:' .. table.concat(sorted, ',') end @@ -62,14 +62,15 @@ end ---Install a (buffer-local when `buf` is non-nil) proxy that lazy-loads the ---plugins claiming this lhs on first press. ---@param key_info table +---@param src string ---@param buf? integer -local install_proxy = function(key_info, buf) +local install_proxy = function(key_info, src, buf) local lhs = key_info.key_spec[1] local key_spec = key_info.key_spec -- ft path forces buffer-local in `buf`; otherwise honor the user's -- `key_spec.buffer` (lazy.nvim parity for unscoped buffer-local keys). local proxy_buffer = buf or key_spec.buffer - keymap.map(lhs, function() + keymap.try_map(lhs, function() -- Mirror the install scope: a global proxy must delete the global -- mapping; a buffer-local proxy must delete the buffer-local one -- (otherwise vim.keymap.del finds nothing and the stale buffer-local @@ -120,7 +121,7 @@ local install_proxy = function(key_info, buf) remap = key_spec.remap, noremap = key_spec.noremap, buffer = proxy_buffer, - }) + }, src) end ---@param key_info table @@ -149,13 +150,7 @@ local function install_nop(key, src, buf) if buf then nop_opts.buffer = buf end - local ok, err = pcall(keymap.map, key[1], '', nop_opts) - if not ok then - util.schedule_notify( - ("Failed to map %s for %s: %s"):format(key[1], src, tostring(err)), - vim.log.levels.ERROR - ) - end + keymap.try_map(key[1], '', nop_opts, src) end ---@param registered_pack_specs vim.pack.Spec[] @@ -176,12 +171,7 @@ M.setup = function(registered_pack_specs) -- Empty ft → no scope, so the autocmd doesn't register an -- unmatchable empty pattern list and silently drop the key. - local ft_scope - if type(key.ft) == 'string' and key.ft ~= '' then - ft_scope = key.ft - elseif type(key.ft) == 'table' and next(key.ft --[[@as table]]) ~= nil then - ft_scope = key.ft - end + local ft_patterns = util.normalize_ft_scope(key.ft) local src = pack_spec.name or pack_spec.src -- rhs never needs the proxy: install as a real no-op so the @@ -189,9 +179,8 @@ M.setup = function(registered_pack_specs) -- installs buffer-locally on matching FileType so the -- suppression is scoped, matching lazy.nvim's ft-on-Nop behavior. if is_nop_rhs(key[2]) then - if ft_scope then - local patterns = util.normalize_string_list(ft_scope) --[[@as string[] ]] - util.install_on_ft(patterns, function(buf) + if ft_patterns then + util.install_on_ft(ft_patterns, function(buf) install_nop(key, src, buf) end, { group = state.lazy_group }) else @@ -199,13 +188,14 @@ M.setup = function(registered_pack_specs) end else for _, m in ipairs(modes) do - local key_id = create_key_id(lhs, m, ft_scope, key.buffer) + local key_id = create_key_id(lhs, m, ft_patterns, key.buffer) if not key_to_info[key_id] then key_to_info[key_id] = { split_mode = m, pack_specs = {}, key_spec = key, - ft = ft_scope, + src = src, + ft = ft_patterns, } end table.insert(key_to_info[key_id].pack_specs, pack_spec) @@ -224,7 +214,7 @@ M.setup = function(registered_pack_specs) -- branch never fires before `autocmd_id` is assigned. local autocmd_id autocmd_id = util.install_on_ft( - util.normalize_string_list(key_info.ft) --[[@as string[] ]], + key_info.ft, function(buf) if not any_pack_pending(key_info) then if autocmd_id then @@ -233,12 +223,12 @@ M.setup = function(registered_pack_specs) end return end - install_proxy(key_info, buf) + install_proxy(key_info, key_info.src, buf) end, { group = state.lazy_group } ) else - install_proxy(key_info, nil) + install_proxy(key_info, key_info.src, nil) end end end diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua index 512ea1c..8b8d074 100644 --- a/lua/zpack/utils.lua +++ b/lua/zpack/utils.lua @@ -120,6 +120,20 @@ M.normalize_string_list = function(val) return type(val) == "string" and { val } or val --[[@as string[] ]] end +---Returns the normalized pattern list (nil when unscoped) for a KeySpec's +---`ft`. Shared by keys.lua's proxy install + keymap.lua's apply_ft_scoped. +---@param ft any +---@return string[]? patterns nil when `ft` is not an effective scope +M.normalize_ft_scope = function(ft) + if type(ft) == 'string' and ft ~= '' then + return { ft } + end + if type(ft) == 'table' and next(ft) ~= nil then + return ft + end + return nil +end + ---Create an autocmd with callback ---@param event string|string[] ---@param callback function @@ -151,9 +165,13 @@ M.latch_first_call = function(callback) end end ----Register a `FileType` autocmd for `patterns` AND call `installer(buf)` ----for every already-loaded matching buffer — their FileType has already ----fired and won't re-fire. +---Register a `FileType` autocmd + synchronously call `installer(buf)` for +---already-loaded matching buffers. Installer must catch its own throws — +---a throwing installer strands the autocmd in the caller's group. The +---sweep runs before the id is returned, so callers' self-deleting closures +---can rely on `autocmd_id` still being nil during the sweep. Sweep matches +---by literal filetype; globs (`markdown.*`) work in the autocmd path but +---skip the sweep. ---@param patterns string[] ---@param installer fun(buf: integer) ---@param opts? table Extra opts merged into the autocmd (group, etc.) From accb443dfa9e13e7432933427341b7406f4ba8de Mon Sep 17 00:00:00 2001 From: zuqini Date: Sun, 24 May 2026 15:32:42 -0700 Subject: [PATCH 8/8] fix: close eighth-pass review gaps (ft predicate consolidation, latch retry) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lua/zpack/lazy_trigger/keys.lua | 4 ++-- lua/zpack/merge.lua | 9 +++++---- lua/zpack/utils.lua | 3 ++- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/lua/zpack/lazy_trigger/keys.lua b/lua/zpack/lazy_trigger/keys.lua index 5aa66c7..dbb39fe 100644 --- a/lua/zpack/lazy_trigger/keys.lua +++ b/lua/zpack/lazy_trigger/keys.lua @@ -8,10 +8,10 @@ local M = {} ---@param ft any ---@return string local ft_key_part = function(ft) - if type(ft) ~= 'string' and type(ft) ~= 'table' then + local ft_list = util.normalize_ft_scope(ft) + if not ft_list then return '' end - local ft_list = util.normalize_string_list(ft) --[[@as string[] ]] local sorted = vim.list_slice(ft_list) table.sort(sorted) return '-ft:' .. table.concat(sorted, ',') diff --git a/lua/zpack/merge.lua b/lua/zpack/merge.lua index 79f1a89..3c3eba9 100644 --- a/lua/zpack/merge.lua +++ b/lua/zpack/merge.lua @@ -1,3 +1,5 @@ +local util = require('zpack.utils') + local M = {} M.OVERRIDE = "override" @@ -78,10 +80,9 @@ local function get_unique_key(v) mode = table.concat(sorted, ",") end local ft = "" - if type(v.ft) == "string" and v.ft ~= "" then - ft = ":ft=" .. v.ft - elseif type(v.ft) == "table" and next(v.ft) ~= nil then - local sorted = vim.list_slice(v.ft) + local ft_list = util.normalize_ft_scope(v.ft) + if ft_list then + local sorted = vim.list_slice(ft_list) table.sort(sorted) ft = ":ft=" .. table.concat(sorted, ",") end diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua index 8b8d074..a3dddcb 100644 --- a/lua/zpack/utils.lua +++ b/lua/zpack/utils.lua @@ -160,8 +160,9 @@ M.latch_first_call = function(callback) if done then return end + local result = callback(...) done = true - return callback(...) + return result end end