From 551cd252ecd67c9b5795bd5530d6ee55c0dfe328 Mon Sep 17 00:00:00 2001 From: zuqini Date: Thu, 7 May 2026 12:03:41 -0700 Subject: [PATCH 1/3] feat: forward expr/silent/noremap/replace_keycodes on KeySpec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses #18. Lazy.nvim-style KeySpec entries that set expr=true (and related boolean opts) were silently dropped because keymap.map only forwarded desc/remap/nowait. Refactor M.map to an opts table and plumb all four standard vim.keymap.set booleans through apply_keys so migrations from lazy.nvim work without surprise. vim.keymap.set ignores noremap and derives it from remap, so translate the lazy.nvim noremap alias into remap before forwarding — otherwise noremap = false would silently still produce a non-remappable keymap. The lazy-trigger proxy mapping intentionally stays non-expr: it must feed the original lhs through nvim_feedkeys so the real (post-load) expr mapping fires. --- doc/zpack.txt | 28 +++ docs/spec.md | 4 + lua/zpack/keymap.lua | 38 +++- lua/zpack/lazy_trigger/keys.lua | 11 +- lua/zpack/types.lua | 16 +- tests/lazy_keys_test.lua | 375 ++++++++++++++++++++++++++++++++ 6 files changed, 454 insertions(+), 18 deletions(-) diff --git a/doc/zpack.txt b/doc/zpack.txt index 9749429..e57d1b8 100644 --- a/doc/zpack.txt +++ b/doc/zpack.txt @@ -820,6 +820,10 @@ pattern (string|string[], optional) mode = "n"|{"n","v"}, -- Mode(s), default: "n" remap = true|false, -- Allow remapping, default: false nowait = true|false, -- Default: false + expr = true|false, -- RHS is an expression, default: false + silent = true|false, -- Suppress command-line output, default: false + noremap = true|false, -- Inverse of `remap` (lazy.nvim alias) + replace_keycodes = true|false, -- Replace keycodes in expr result; defaults to true when expr is true } < @@ -843,11 +847,35 @@ mode (string|string[], optional) remap (boolean, optional) Whether to allow remapping (recursive mapping). When false, the mapping is non-recursive (like noremap). Default: false. + Wins over the `noremap` alias when both are set. *zpack.KeySpec.nowait* nowait (boolean, optional) Whether to use nowait. Default: false. + *zpack.KeySpec.expr* +expr (boolean, optional) + Whether the right-hand side is an expression. When true, the + returned value of the rhs is used as the keys to feed. + Default: false. + + *zpack.KeySpec.silent* +silent (boolean, optional) + Whether the keymap should be silent (suppress command-line + output). Default: false. + + *zpack.KeySpec.noremap* +noremap (boolean, optional) + lazy.nvim compatibility alias for the inverse of `remap`. + Consulted only when `remap` is unset; explicit `remap` always + wins. When neither is set, the keymap is non-remappable + (matching `remap = false`). + + *zpack.KeySpec.replace_keycodes* +replace_keycodes (boolean, optional) + When `expr` is true, replace keycodes in the resulting + string. Default: true when `expr` is true; otherwise unused. + ============================================================================== 11. TIPS & MIGRATION *zpack-tips-and-migration* diff --git a/docs/spec.md b/docs/spec.md index 01624d2..1eaae11 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -85,6 +85,10 @@ The plugin data object passed to hooks and trigger functions: mode = "n"|{"n","v"}, -- Mode(s), default: "n" remap = true|false, -- Allow remapping, default: false nowait = true|false, -- Default: false + expr = true|false, -- RHS is an expression, default: false + silent = true|false, -- Suppress command-line output, default: false + noremap = true|false, -- Inverse of `remap` (lazy.nvim alias) + replace_keycodes = true|false, -- Replace keycodes in expr result; defaults to true when expr is true } ``` diff --git a/lua/zpack/keymap.lua b/lua/zpack/keymap.lua index 4bf1250..8d3060a 100644 --- a/lua/zpack/keymap.lua +++ b/lua/zpack/keymap.lua @@ -2,18 +2,22 @@ local util = require('zpack.utils') local M = {} ----@param mapping string +local SUPPORTED_OPTS = { 'desc', 'remap', 'nowait', 'expr', 'silent', 'replace_keycodes' } + +---@param lhs string ---@param rhs string|fun() ----@param remap? boolean ----@param desc? string ----@param mode? string|string[] ----@param nowait? boolean -M.map = function(mapping, rhs, remap, desc, mode, nowait) - if remap == nil then remap = false end - desc = desc or "" - mode = mode or { 'n' } - if nowait == nil then nowait = false end - vim.keymap.set(mode, mapping, rhs, { desc = desc, remap = remap, nowait = nowait }) +---@param opts? zpack.KeymapOpts +M.map = function(lhs, rhs, opts) + opts = opts or {} + local set_opts = {} + for _, k in ipairs(SUPPORTED_OPTS) do + set_opts[k] = opts[k] + end + -- Mirror Neovim's documented expr→replace_keycodes default so zpack owns the contract. + if set_opts.expr and set_opts.replace_keycodes == nil then + set_opts.replace_keycodes = true + end + vim.keymap.set(opts.mode or { 'n' }, lhs, rhs, set_opts) end ---@param keys zpack.KeySpec|zpack.KeySpec[]|string @@ -22,7 +26,17 @@ M.apply_keys = function(keys) for _, key in ipairs(key_list) do if key[2] ~= nil then - M.map(key[1], key[2], key.remap, key.desc, key.mode, key.nowait) + local opts = { mode = key.mode } + for _, k in ipairs(SUPPORTED_OPTS) do + opts[k] = key[k] + end + -- lazy.nvim compat: noremap is the inverse of remap. Translate at the + -- spec boundary so M.map only speaks vim.keymap.set's vocabulary. + -- Explicit `remap` wins; the alias is consulted only when remap is unset. + if opts.remap == nil and key.noremap ~= nil then + opts.remap = not key.noremap + end + M.map(key[1], key[2], opts) end end end diff --git a/lua/zpack/lazy_trigger/keys.lua b/lua/zpack/lazy_trigger/keys.lua index 0f085c7..8225d80 100644 --- a/lua/zpack/lazy_trigger/keys.lua +++ b/lua/zpack/lazy_trigger/keys.lua @@ -53,7 +53,16 @@ M.setup = function(registered_pack_specs) loader.process_spec(pack_spec) end vim.api.nvim_feedkeys(vim.keycode(lhs), 'm', false) - end, false, key_info.key_spec.desc, key_info.split_mode, false) + end, { + desc = key_info.key_spec.desc, + mode = key_info.split_mode, + -- Forward latency/UX-affecting opts so the first (proxy) press matches + -- subsequent presses through the real keymap. expr/replace_keycodes are + -- excluded — the proxy's rhs is a Lua callback returning nil; making it + -- expr would feed nil as keys and the plugin would never load. + nowait = key_info.key_spec.nowait, + silent = key_info.key_spec.silent, + }) end end diff --git a/lua/zpack/types.lua b/lua/zpack/types.lua index 163e3ab..373180e 100644 --- a/lua/zpack/types.lua +++ b/lua/zpack/types.lua @@ -1,10 +1,16 @@ ----@class zpack.KeySpec ----@field [1] string ----@field [2]? string|fun() ----@field remap? boolean ----@field desc? string +---@class zpack.KeymapOpts ---@field mode? string|string[] +---@field desc? string +---@field remap? boolean ---@field nowait? boolean +---@field expr? boolean +---@field silent? boolean +---@field replace_keycodes? boolean + +---@class zpack.KeySpec : zpack.KeymapOpts +---@field [1] string +---@field [2]? string|fun() +---@field noremap? boolean ---@class zpack.EventSpec ---@field event string|string[] Event name(s) to trigger on diff --git a/tests/lazy_keys_test.lua b/tests/lazy_keys_test.lua index a65703b..0861347 100644 --- a/tests/lazy_keys_test.lua +++ b/tests/lazy_keys_test.lua @@ -125,5 +125,380 @@ return function() helpers.cleanup_test_env() end) + + helpers.test("KeySpec forwards expr=true", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + lazy = false, + keys = { + { 'te', function() return 'foo' end, expr = true }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local keymaps = vim.api.nvim_get_keymap('n') + local found = false + for _, map in ipairs(keymaps) do + if map.lhs == ' te' then + found = true + helpers.assert_equal(map.expr, 1, "expr should be forwarded to vim.keymap.set") + break + end + end + helpers.assert_true(found, "Eager KeySpec should create keymap") + + helpers.cleanup_test_env() + end) + + helpers.test("KeySpec expr=true: rhs return value is fed as keys", function() + helpers.setup_test_env() + + local target_hits = 0 + vim.keymap.set('n', 'gxxq', function() target_hits = target_hits + 1 end) + + local rhs_called = 0 + require('zpack').setup({ + spec = { + { + 'test/plugin', + lazy = false, + keys = { + -- remap=true so the returned 'gxxq' goes through the target mapping below. + { 'gxxe', function() rhs_called = rhs_called + 1; return 'gxxq' end, expr = true, remap = true }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + vim.api.nvim_feedkeys('gxxe', 'mx', false) + helpers.assert_equal(rhs_called, 1, "expr rhs should be invoked when lhs is fed") + helpers.assert_equal(target_hits, 1, "rhs return value should be replayed as keys") + + pcall(vim.keymap.del, 'n', 'gxxe') + pcall(vim.keymap.del, 'n', 'gxxq') + helpers.cleanup_test_env() + end) + + helpers.test("KeySpec forwards silent=true", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + lazy = false, + keys = { + { 'ts', function() end, silent = true }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local keymaps = vim.api.nvim_get_keymap('n') + local found = false + for _, map in ipairs(keymaps) do + if map.lhs == ' ts' then + found = true + helpers.assert_equal(map.silent, 1, "silent should be forwarded to vim.keymap.set") + break + end + end + helpers.assert_true(found, "Eager KeySpec should create keymap") + + helpers.cleanup_test_env() + end) + + helpers.test("KeySpec forwards noremap=true", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + lazy = false, + keys = { + { 'tn', function() end, noremap = true }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local keymaps = vim.api.nvim_get_keymap('n') + local found = false + for _, map in ipairs(keymaps) do + if map.lhs == ' tn' then + found = true + helpers.assert_equal(map.noremap, 1, "noremap should be forwarded to vim.keymap.set") + break + end + end + helpers.assert_true(found, "Eager KeySpec should create keymap") + + helpers.cleanup_test_env() + end) + + -- vim.keymap.set ignores `noremap` and derives it from `remap`. Without the + -- alias translation in keymap.lua, the keymap below would still come out + -- non-remappable (noremap=1) because `remap` defaults to false. + helpers.test("KeySpec forwards noremap=false (lazy.nvim alias for remap=true)", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + lazy = false, + keys = { + { 'tnf', function() end, noremap = false }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local keymaps = vim.api.nvim_get_keymap('n') + local found = false + for _, map in ipairs(keymaps) do + if map.lhs == ' tnf' then + found = true + helpers.assert_equal(map.noremap, 0, "noremap=false should produce a remappable keymap") + break + end + end + helpers.assert_true(found, "Eager KeySpec should create keymap") + + helpers.cleanup_test_env() + end) + + -- Explicit `remap` should win over `noremap` when both are set. + helpers.test("KeySpec: explicit remap takes precedence over noremap alias", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + lazy = false, + keys = { + { 'tnp', function() end, remap = true, noremap = true }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local keymaps = vim.api.nvim_get_keymap('n') + local found = false + for _, map in ipairs(keymaps) do + if map.lhs == ' tnp' then + found = true + helpers.assert_equal(map.noremap, 0, "remap=true wins; resulting keymap should be remappable") + break + end + end + helpers.assert_true(found, "Eager KeySpec should create keymap") + + helpers.cleanup_test_env() + end) + + helpers.test("KeySpec forwards replace_keycodes=true with expr", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + lazy = false, + keys = { + { 'tr', function() return '' end, expr = true, replace_keycodes = true }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local keymaps = vim.api.nvim_get_keymap('n') + local found = false + for _, map in ipairs(keymaps) do + if map.lhs == ' tr' then + found = true + helpers.assert_equal(map.expr, 1, "expr should be forwarded") + helpers.assert_equal(map.replace_keycodes, 1, "replace_keycodes should be forwarded") + break + end + end + helpers.assert_true(found, "Eager KeySpec should create keymap") + + helpers.cleanup_test_env() + end) + + helpers.test("Lazy proxy keymap is not expr", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tx', function() return 'foo' end, expr = true }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local keymaps = vim.api.nvim_get_keymap('n') + local found = false + for _, map in ipairs(keymaps) do + if map.lhs == ' tx' then + found = true + helpers.assert_equal(map.expr, 0, "Lazy proxy mapping must not be expr") + break + end + end + helpers.assert_true(found, "Lazy proxy keymap should be installed") + + helpers.cleanup_test_env() + end) + + helpers.test("KeySpec forwards remap=true alone", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + lazy = false, + keys = { + { 'tra', function() end, remap = true }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local keymaps = vim.api.nvim_get_keymap('n') + local found = false + for _, map in ipairs(keymaps) do + if map.lhs == ' tra' then + found = true + helpers.assert_equal(map.noremap, 0, "remap=true should produce a remappable keymap") + break + end + end + helpers.assert_true(found, "Eager KeySpec should create keymap") + + helpers.cleanup_test_env() + end) + + helpers.test("KeySpec defaults replace_keycodes to true when expr=true", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + lazy = false, + keys = { + { 'trd', function() return '' end, expr = true }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local keymaps = vim.api.nvim_get_keymap('n') + local found = false + for _, map in ipairs(keymaps) do + if map.lhs == ' trd' then + found = true + helpers.assert_equal(map.expr, 1, "expr should be forwarded") + helpers.assert_equal(map.replace_keycodes, 1, "replace_keycodes should default to true when expr=true") + break + end + end + helpers.assert_true(found, "Eager KeySpec should create keymap") + + helpers.cleanup_test_env() + end) + + helpers.test("Lazy proxy keymap forwards nowait=true", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tw', function() end, nowait = true }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local keymaps = vim.api.nvim_get_keymap('n') + local found = false + for _, map in ipairs(keymaps) do + if map.lhs == ' tw' then + found = true + helpers.assert_equal(map.nowait, 1, "Lazy proxy should forward nowait") + break + end + end + helpers.assert_true(found, "Lazy proxy keymap should be installed") + + helpers.cleanup_test_env() + end) + + helpers.test("Lazy proxy keymap forwards silent=true", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tsl', function() end, silent = true }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local keymaps = vim.api.nvim_get_keymap('n') + local found = false + for _, map in ipairs(keymaps) do + if map.lhs == ' tsl' then + found = true + helpers.assert_equal(map.silent, 1, "Lazy proxy should forward silent") + break + end + end + helpers.assert_true(found, "Lazy proxy keymap should be installed") + + helpers.cleanup_test_env() + end) end) end From 7db9ad7b99610867e7a46c381dba5ebc5574da4c Mon Sep 17 00:00:00 2001 From: zuqini Date: Thu, 7 May 2026 13:13:26 -0700 Subject: [PATCH 2/3] fix: forward remap to lazy-trigger proxy and cover gap cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per code-review on #19, the proxy installer in lazy_trigger/keys.lua forwarded desc/mode/nowait/silent but omitted remap, leaving the proxy mapping inconsistent with the post-load real keymap when the user's KeySpec set remap=true (or noremap=false). The user's intent should apply to every press, not just post-load — ``-style remap chains and inspection tools alike read the proxy attributes. Forward remap with the same noremap→remap alias translation that apply_keys performs. expr/replace_keycodes remain the only deliberate omissions — the proxy's rhs is a Lua callback returning nil, so an expr proxy would feed nil keys and the plugin would never load. Tests: - replace_keycodes=false explicit override survives the expr-implied default mirror in keymap.lua (regression test for the `== nil` check that gates the auto-default). - Lazy proxy reflects remap=true and translates noremap=false. - Post-load handoff: pressing the proxy lhs deletes the proxy, runs process_spec, and installs the real expr=true keymap. --- lua/zpack/lazy_trigger/keys.lua | 21 ++++-- tests/lazy_keys_test.lua | 130 ++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 7 deletions(-) diff --git a/lua/zpack/lazy_trigger/keys.lua b/lua/zpack/lazy_trigger/keys.lua index 8225d80..b528691 100644 --- a/lua/zpack/lazy_trigger/keys.lua +++ b/lua/zpack/lazy_trigger/keys.lua @@ -47,6 +47,12 @@ 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 + -- lazy.nvim noremap alias: explicit remap wins; otherwise derive from noremap. + local remap = key_spec.remap + if remap == nil and key_spec.noremap ~= nil then + remap = not key_spec.noremap + end keymap.map(lhs, function() pcall(vim.keymap.del, key_info.split_mode, lhs) for _, pack_spec in ipairs(key_info.pack_specs) do @@ -54,14 +60,15 @@ M.setup = function(registered_pack_specs) end vim.api.nvim_feedkeys(vim.keycode(lhs), 'm', false) end, { - desc = key_info.key_spec.desc, + desc = key_spec.desc, mode = key_info.split_mode, - -- Forward latency/UX-affecting opts so the first (proxy) press matches - -- subsequent presses through the real keymap. expr/replace_keycodes are - -- excluded — the proxy's rhs is a Lua callback returning nil; making it - -- expr would feed nil as keys and the plugin would never load. - nowait = key_info.key_spec.nowait, - silent = key_info.key_spec.silent, + -- Forward user-facing opts so the first (proxy) press matches subsequent + -- presses through the real keymap. expr/replace_keycodes are the only + -- omissions: the proxy's rhs is a Lua callback returning nil, so making + -- it expr would feed nil keys and the plugin would never load. + nowait = key_spec.nowait, + silent = key_spec.silent, + remap = remap, }) end end diff --git a/tests/lazy_keys_test.lua b/tests/lazy_keys_test.lua index 0861347..2e28636 100644 --- a/tests/lazy_keys_test.lua +++ b/tests/lazy_keys_test.lua @@ -500,5 +500,135 @@ return function() helpers.cleanup_test_env() end) + + helpers.test("KeySpec preserves explicit replace_keycodes=false override", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + lazy = false, + keys = { + { 'tro', function() return '' end, expr = true, replace_keycodes = false }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local keymaps = vim.api.nvim_get_keymap('n') + local found = false + for _, map in ipairs(keymaps) do + if map.lhs == ' tro' then + found = true + helpers.assert_equal(map.expr, 1, "expr should be forwarded") + helpers.assert_equal(map.replace_keycodes, 0, "explicit replace_keycodes=false must override the expr-implied default") + break + end + end + helpers.assert_true(found, "Eager KeySpec should create keymap") + + helpers.cleanup_test_env() + end) + + helpers.test("Lazy proxy keymap forwards remap=true", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'txr', function() end, remap = true }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local keymaps = vim.api.nvim_get_keymap('n') + local found = false + for _, map in ipairs(keymaps) do + if map.lhs == ' txr' then + found = true + helpers.assert_equal(map.noremap, 0, "Lazy proxy should reflect remap=true (noremap=0)") + break + end + end + helpers.assert_true(found, "Lazy proxy keymap should be installed") + + helpers.cleanup_test_env() + end) + + helpers.test("Lazy proxy keymap translates noremap=false alias", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'txn', function() end, noremap = false }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local keymaps = vim.api.nvim_get_keymap('n') + local found = false + for _, map in ipairs(keymaps) do + if map.lhs == ' txn' then + found = true + helpers.assert_equal(map.noremap, 0, "Lazy proxy should translate noremap=false to remap=true") + break + end + end + helpers.assert_true(found, "Lazy proxy keymap should be installed") + + helpers.cleanup_test_env() + end) + + helpers.test("Lazy proxy load handoff installs the real expr keymap", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'txp', function() return '' end, expr = true }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local function find_map(lhs) + for _, map in ipairs(vim.api.nvim_get_keymap('n')) do + if map.lhs == lhs then return map end + end + return nil + end + + local proxy = find_map(' txp') + helpers.assert_not_nil(proxy, "Proxy keymap should be installed") + helpers.assert_equal(proxy.expr, 0, "Proxy must not be expr") + + vim.api.nvim_feedkeys(' txp', 'mx', false) + helpers.flush_pending() + + local real = find_map(' txp') + helpers.assert_not_nil(real, "Real keymap should exist after lazy proxy fires") + helpers.assert_equal(real.expr, 1, "Post-load real keymap should be expr=true") + + helpers.cleanup_test_env() + end) end) end From 1119c22fe0cc87cf74a4179420e2e7f20abe3419 Mon Sep 17 00:00:00 2001 From: zuqini Date: Thu, 7 May 2026 13:23:33 -0700 Subject: [PATCH 3/3] fix: drop replace_keycodes without expr; centralize noremap alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vim.keymap.set raises "replace_keycodes requires expr" if the option is forwarded without expr. The SUPPORTED_OPTS pass-through made a KeySpec like { lhs, rhs, replace_keycodes = true } crash apply_keys. Gate the forward on expr inside M.map. The noremap→remap alias translation was duplicated byte-for-byte in keymap.apply_keys and lazy_trigger/keys.lua. Move it into M.map so there is one source of truth, and let apply_keys pass the KeySpec directly (the SUPPORTED_OPTS whitelist filters [1]/[2]/mode out). Tests: - KeySpec drops replace_keycodes when expr is unset (regression). - Lazy proxy load handoff: post-load real keymap forwards silent and the noremap alias (covers the plugin_loader → apply_keys path that the existing eager `lazy = false` tests don't exercise). --- lua/zpack/keymap.lua | 30 +++++++------- lua/zpack/lazy_trigger/keys.lua | 8 +--- tests/lazy_keys_test.lua | 73 +++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 21 deletions(-) diff --git a/lua/zpack/keymap.lua b/lua/zpack/keymap.lua index 8d3060a..597a417 100644 --- a/lua/zpack/keymap.lua +++ b/lua/zpack/keymap.lua @@ -6,16 +6,26 @@ local SUPPORTED_OPTS = { 'desc', 'remap', 'nowait', 'expr', 'silent', 'replace_k ---@param lhs string ---@param rhs string|fun() ----@param opts? zpack.KeymapOpts +---@param opts? zpack.KeySpec|zpack.KeymapOpts M.map = function(lhs, rhs, opts) opts = opts or {} local set_opts = {} for _, k in ipairs(SUPPORTED_OPTS) do set_opts[k] = opts[k] end - -- Mirror Neovim's documented expr→replace_keycodes default so zpack owns the contract. - if set_opts.expr and set_opts.replace_keycodes == nil then - set_opts.replace_keycodes = true + -- lazy.nvim compat: noremap is the inverse of remap. Explicit `remap` wins; + -- the alias is consulted only when remap is unset. + if set_opts.remap == nil and opts.noremap ~= nil then + set_opts.remap = not opts.noremap + end + if set_opts.expr then + -- Mirror Neovim's documented expr→replace_keycodes default so zpack owns the contract. + if set_opts.replace_keycodes == nil then + set_opts.replace_keycodes = true + end + else + -- vim.keymap.set raises when replace_keycodes is set without expr. + set_opts.replace_keycodes = nil end vim.keymap.set(opts.mode or { 'n' }, lhs, rhs, set_opts) end @@ -26,17 +36,7 @@ M.apply_keys = function(keys) for _, key in ipairs(key_list) do if key[2] ~= nil then - local opts = { mode = key.mode } - for _, k in ipairs(SUPPORTED_OPTS) do - opts[k] = key[k] - end - -- lazy.nvim compat: noremap is the inverse of remap. Translate at the - -- spec boundary so M.map only speaks vim.keymap.set's vocabulary. - -- Explicit `remap` wins; the alias is consulted only when remap is unset. - if opts.remap == nil and key.noremap ~= nil then - opts.remap = not key.noremap - end - M.map(key[1], key[2], opts) + M.map(key[1], key[2], key) end end end diff --git a/lua/zpack/lazy_trigger/keys.lua b/lua/zpack/lazy_trigger/keys.lua index b528691..e512471 100644 --- a/lua/zpack/lazy_trigger/keys.lua +++ b/lua/zpack/lazy_trigger/keys.lua @@ -48,11 +48,6 @@ M.setup = function(registered_pack_specs) for _, key_info in pairs(key_to_info) do local lhs = key_info.key_spec[1] local key_spec = key_info.key_spec - -- lazy.nvim noremap alias: explicit remap wins; otherwise derive from noremap. - local remap = key_spec.remap - if remap == nil and key_spec.noremap ~= nil then - remap = not key_spec.noremap - end keymap.map(lhs, function() pcall(vim.keymap.del, key_info.split_mode, lhs) for _, pack_spec in ipairs(key_info.pack_specs) do @@ -68,7 +63,8 @@ M.setup = function(registered_pack_specs) -- it expr would feed nil keys and the plugin would never load. nowait = key_spec.nowait, silent = key_spec.silent, - remap = remap, + remap = key_spec.remap, + noremap = key_spec.noremap, }) end end diff --git a/tests/lazy_keys_test.lua b/tests/lazy_keys_test.lua index 2e28636..b4171df 100644 --- a/tests/lazy_keys_test.lua +++ b/tests/lazy_keys_test.lua @@ -593,6 +593,79 @@ return function() helpers.cleanup_test_env() end) + -- vim.keymap.set raises if replace_keycodes is set without expr. The opts + -- whitelist used to forward replace_keycodes unconditionally, which would + -- crash apply_keys for a user spec like { lhs, rhs, replace_keycodes = true }. + helpers.test("KeySpec drops replace_keycodes when expr is unset", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + lazy = false, + keys = { + { 'trk', function() end, replace_keycodes = true }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + local keymaps = vim.api.nvim_get_keymap('n') + local found = false + for _, map in ipairs(keymaps) do + if map.lhs == ' trk' then + found = true + helpers.assert_equal(map.expr, 0, "expr should not be implied") + helpers.assert_equal(map.replace_keycodes, 0, "replace_keycodes should be dropped when expr is unset") + break + end + end + helpers.assert_true(found, "Eager KeySpec should create keymap without crashing") + + helpers.cleanup_test_env() + end) + + -- Covers the post-trigger apply_keys path (plugin_loader.lua → apply_keys), + -- which the eager `lazy = false` tests above don't exercise. After the proxy + -- fires and the plugin loads, the real keymap must carry the user's opts. + helpers.test("Lazy proxy load handoff: real keymap forwards silent and noremap alias", function() + helpers.setup_test_env() + + require('zpack').setup({ + spec = { + { + 'test/plugin', + keys = { + { 'tlh', function() end, silent = true, noremap = false }, + }, + }, + }, + defaults = { confirm = false }, + }) + + helpers.flush_pending() + + local function find_map(lhs) + for _, map in ipairs(vim.api.nvim_get_keymap('n')) do + if map.lhs == lhs then return map end + end + return nil + end + + vim.api.nvim_feedkeys(' tlh', 'mx', false) + helpers.flush_pending() + + local real = find_map(' tlh') + helpers.assert_not_nil(real, "Real keymap should exist after lazy proxy fires") + helpers.assert_equal(real.silent, 1, "Post-load real keymap should forward silent=true") + helpers.assert_equal(real.noremap, 0, "Post-load real keymap should reflect noremap=false alias") + + helpers.cleanup_test_env() + end) + helpers.test("Lazy proxy load handoff installs the real expr keymap", function() helpers.setup_test_env()