From 6ffbb2962c2ef9b2174e19f2e609fe6962e7a6b4 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Mon, 13 Jul 2026 14:46:58 -0500
Subject: [PATCH 1/3] test(api): allow added optional params
---
test/functional/api/version_spec.lua | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/test/functional/api/version_spec.lua b/test/functional/api/version_spec.lua
index 2b5ef149ae..0a55f30ee7 100644
--- a/test/functional/api/version_spec.lua
+++ b/test/functional/api/version_spec.lua
@@ -93,6 +93,9 @@ describe('api metadata', function()
error(('"%s": parameter %d was optional, now required'):format(old_fn.name, idx))
end
end
+ while #new_fn.parameters > #old_fn.parameters and new_fn.parameters[#new_fn.parameters][3] do
+ table.remove(new_fn.parameters)
+ end
old_fn = normalize_func_metadata(old_fn)
new_fn = normalize_func_metadata(new_fn)
if old_fn.return_type == 'void' then
@@ -248,6 +251,22 @@ describe('api metadata', function()
)
end)
+ it('optional param may be added', function()
+ assert_func_backcompat({
+ name = 'nvim_example',
+ method = false,
+ since = 1,
+ return_type = 'void',
+ parameters = { { 'String', 'src', false } },
+ }, {
+ name = 'nvim_example',
+ method = false,
+ since = 1,
+ return_type = 'void',
+ parameters = { { 'String', 'src', false }, { 'Dict', 'opts', true } },
+ })
+ end)
+
it('UI events are compatible with old metadata or have new level', function()
local ui_events_new = name_table(api_info.ui_events)
local ui_events_compat = {}
From 1facf1e0818a15588deea4809292cd32cf362bed Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Sat, 25 Jul 2026 10:25:56 -0700
Subject: [PATCH 2/3] fix(gen): parameter order follows the prototype, not the
docs
---
src/gen/cdoc_parser.lua | 32 +++++++++++++++++++++-----------
1 file changed, 21 insertions(+), 11 deletions(-)
diff --git a/src/gen/cdoc_parser.lua b/src/gen/cdoc_parser.lua
index f15ea52a23..5b1c132b42 100644
--- a/src/gen/cdoc_parser.lua
+++ b/src/gen/cdoc_parser.lua
@@ -143,26 +143,36 @@ local function process_proto(item, state)
cur_obj.name = item.name
cur_obj.params = cur_obj.params or {}
+ local documented = {} --- @type table
+ local matched = {} --- @type table
+ local params = {} --- @type nvim.cdoc.parser.param[]
+
+ for _, p in ipairs(cur_obj.params) do
+ documented[p.name] = documented[p.name] or p
+ end
+
for _, p in ipairs(item.parameters) do
local event_type = 'vim.api.keyset.events|vim.api.keyset.events[]'
local event = (item.name == 'nvim_create_autocmd' or item.name == 'nvim_exec_autocmds')
and p[2] == 'event'
- local param = { name = p[2], type = event and event_type or api_type(p[1]) }
- local added = false
-
- for _, cp in ipairs(cur_obj.params) do
- if cp.name == param.name then
- cp.type = param.type
- added = true
- break
- end
+ local param = documented[p[2]]
+ if param then
+ matched[param] = true
+ else
+ param = { name = p[2] } --[[@as nvim.cdoc.parser.param]]
end
+ param.type = event and event_type or api_type(p[1])
+ params[#params + 1] = param
+ end
- if not added then
- table.insert(cur_obj.params, param)
+ for _, p in ipairs(cur_obj.params) do
+ if not matched[p] then
+ params[#params + 1] = p
end
end
+ cur_obj.params = params
+
cur_obj.returns = cur_obj.returns or { {
name = '',
type = '',
From 22d44ef8754448294833fcbc197defaca355f3b5 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Sat, 25 Jul 2026 10:26:01 -0700
Subject: [PATCH 3/3] fix(api): add `lhs` option for keymap deletion
---
runtime/doc/api.txt | 17 +++++++-----
runtime/doc/lua.txt | 2 ++
runtime/doc/map.txt | 6 +++--
runtime/doc/news.txt | 2 ++
runtime/lua/vim/_meta/api.gen.lua | 16 ++++++-----
runtime/lua/vim/_meta/api_keysets.gen.lua | 3 +++
runtime/lua/vim/keymap.lua | 9 +++++--
src/nvim/api/buffer.c | 14 +++++++---
src/nvim/api/keysets_defs.h | 5 ++++
src/nvim/api/vim.c | 12 ++++++---
src/nvim/mapping.c | 15 +++++------
test/functional/api/keymap_spec.lua | 33 +++++++++++++++++++++++
test/functional/lua/vim_spec.lua | 17 ++++++++++++
13 files changed, 120 insertions(+), 31 deletions(-)
diff --git a/runtime/doc/api.txt b/runtime/doc/api.txt
index 9e679567be..b6e9e5d5ce 100644
--- a/runtime/doc/api.txt
+++ b/runtime/doc/api.txt
@@ -620,7 +620,7 @@ nvim_del_current_line() *nvim_del_current_line()*
not allowed when |textlock| is active
Since: 0.1.0
-nvim_del_keymap({mode}, {lhs}) *nvim_del_keymap()*
+nvim_del_keymap({mode}, {lhs}, {opts}) *nvim_del_keymap()*
Unmaps a global |mapping| for the given mode.
To unmap a buffer-local mapping, use |nvim_buf_del_keymap()|.
@@ -629,8 +629,10 @@ nvim_del_keymap({mode}, {lhs}) *nvim_del_keymap()*
Since: 0.4.0
Parameters: ~
- • {mode} (`string`)
- • {lhs} (`string`)
+ • {mode} (`string`) Mode short-name ("n", "i", "v", ...)
+ • {lhs} (`string`) Left-hand-side |{lhs}| of the mapping.
+ • {opts} (`vim.api.keyset.keymap_del?`) Optional parameters.
+ • lhs: When true, only match {lhs}, not {rhs}.
See also: ~
• |nvim_set_keymap()|
@@ -2447,7 +2449,8 @@ nvim_buf_call({buf}, {fn}) *nvim_buf_call()*
Return: ~
(`any`) Value(s) returned by `fn()`.
-nvim_buf_del_keymap({buf}, {mode}, {lhs}) *nvim_buf_del_keymap()*
+ *nvim_buf_del_keymap()*
+nvim_buf_del_keymap({buf}, {mode}, {lhs}, {opts})
Unmaps a buffer-local |mapping| for the given mode.
Attributes: ~
@@ -2455,8 +2458,10 @@ nvim_buf_del_keymap({buf}, {mode}, {lhs}) *nvim_buf_del_keymap()*
Parameters: ~
• {buf} (`integer`) Buffer id, or 0 for current buffer
- • {mode} (`string`)
- • {lhs} (`string`)
+ • {mode} (`string`) Mode short-name ("n", "i", "v", ...)
+ • {lhs} (`string`) Left-hand-side |{lhs}| of the mapping.
+ • {opts} (`vim.api.keyset.keymap_del?`) Optional parameters.
+ • lhs: When true, only match {lhs}, not {rhs}.
See also: ~
• |nvim_del_keymap()|
diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt
index eed0eef9c2..addccb4214 100644
--- a/runtime/doc/lua.txt
+++ b/runtime/doc/lua.txt
@@ -3798,6 +3798,7 @@ Lua module: vim.keymap *vim.keymap*
vim.keymap.del({modes}, {lhs}, {opts}) *vim.keymap.del()*
Removes a mapping, or removes each (mode, lhs) pair if `lhs` is a list.
+
Examples: >lua
vim.keymap.del('n', 'lhs')
vim.keymap.del({'n', 'i', 'v'}, 'w', { buf = 5 })
@@ -3809,6 +3810,7 @@ vim.keymap.del({modes}, {lhs}, {opts}) *vim.keymap.del()*
• {opts} (`table?`) A table with the following fields:
• {buf}? (`integer`) Remove a mapping from the given buffer.
`0` for current.
+ • {lhs}? (`boolean`) When true, only match {lhs}, not {rhs}.
See also: ~
• |vim.keymap.set()|
diff --git a/runtime/doc/map.txt b/runtime/doc/map.txt
index ff5b9f5448..cf15a1a9fb 100644
--- a/runtime/doc/map.txt
+++ b/runtime/doc/map.txt
@@ -86,8 +86,10 @@ modes.
Remove the mapping of {lhs} for the modes where the
map command applies. The mapping may remain defined
for other modes where it applies.
- It also works when {lhs} matches the {rhs} of a
- mapping. This is for when an abbreviation applied.
+ If no mapping with {lhs} exists, it also works when
+ {lhs} matches the {rhs} of a mapping (see
+ |vim.keymap.del()| to avoid that). This is for
+ when an abbreviation applied.
Note: Trailing spaces are included in the {lhs}.
See |map-trailing-white|.
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
index 3117bb2012..da41073887 100644
--- a/runtime/doc/news.txt
+++ b/runtime/doc/news.txt
@@ -432,6 +432,8 @@ These existing features changed their behavior.
• |vim.lsp.util.open_floating_preview()| windows (e.g. |vim.lsp.buf.hover()|)
converted to normal windows (e.g. with |CTRL-W_H|) are no longer closed
automatically.
+• |nvim_del_keymap()|, |nvim_buf_del_keymap()| and |vim.keymap.del()| can
+ match only {lhs}, not {rhs}, with `opts.lhs=true`.
==============================================================================
REMOVED FEATURES *news-removed*
diff --git a/runtime/lua/vim/_meta/api.gen.lua b/runtime/lua/vim/_meta/api.gen.lua
index b001da83ff..2999a8cdff 100644
--- a/runtime/lua/vim/_meta/api.gen.lua
+++ b/runtime/lua/vim/_meta/api.gen.lua
@@ -333,9 +333,11 @@ function vim.api.nvim_buf_del_extmark(buf, ns_id, id) end
---
--- @see vim.api.nvim_del_keymap
--- @param buf integer Buffer id, or 0 for current buffer
---- @param mode string
---- @param lhs string
-function vim.api.nvim_buf_del_keymap(buf, mode, lhs) end
+--- @param mode string Mode short-name ("n", "i", "v", ...)
+--- @param lhs string Left-hand-side `{lhs}` of the mapping.
+--- @param opts vim.api.keyset.keymap_del? Optional parameters.
+--- - lhs: When true, only match {lhs}, not {rhs}.
+function vim.api.nvim_buf_del_keymap(buf, mode, lhs, opts) end
--- Deletes a named mark in the buffer. See `mark-motions`.
---
@@ -1069,9 +1071,11 @@ function vim.api.nvim_del_current_line() end
--- To unmap a buffer-local mapping, use `nvim_buf_del_keymap()`.
---
--- @see vim.api.nvim_set_keymap
---- @param mode string
---- @param lhs string
-function vim.api.nvim_del_keymap(mode, lhs) end
+--- @param mode string Mode short-name ("n", "i", "v", ...)
+--- @param lhs string Left-hand-side `{lhs}` of the mapping.
+--- @param opts vim.api.keyset.keymap_del? Optional parameters.
+--- - lhs: When true, only match {lhs}, not {rhs}.
+function vim.api.nvim_del_keymap(mode, lhs, opts) end
--- Deletes an uppercase/file named mark. See `mark-motions`.
---
diff --git a/runtime/lua/vim/_meta/api_keysets.gen.lua b/runtime/lua/vim/_meta/api_keysets.gen.lua
index dd14260893..185f658ad1 100644
--- a/runtime/lua/vim/_meta/api_keysets.gen.lua
+++ b/runtime/lua/vim/_meta/api_keysets.gen.lua
@@ -373,6 +373,9 @@ error('Cannot require a meta file')
--- @field silent? boolean
--- @field unique? boolean
+--- @class vim.api.keyset.keymap_del
+--- @field lhs? boolean
+
--- @class vim.api.keyset.ns_opts
--- @field wins? any[]
diff --git a/runtime/lua/vim/keymap.lua b/runtime/lua/vim/keymap.lua
index 4b9a32ff8c..8b68b75cf3 100644
--- a/runtime/lua/vim/keymap.lua
+++ b/runtime/lua/vim/keymap.lua
@@ -119,8 +119,11 @@ end
---
--- Remove a mapping from the given buffer. `0` for current.
--- @field buf? integer
+--- When true, only match {lhs}, not {rhs}.
+--- @field lhs? boolean
--- Removes a mapping, or removes each (mode, lhs) pair if `lhs` is a list.
+---
--- Examples:
---
--- ```lua
@@ -152,12 +155,14 @@ function keymap.del(modes, lhs, opts)
buf = opts.buffer == true and 0 or opts.buffer --[[@as integer?]]
end
+ local api_opts = { lhs = opts.lhs }
+
for _, m in ipairs(modes) do
for _, l in ipairs(lhs) do
if buf then
- vim.api.nvim_buf_del_keymap(buf, m, l)
+ vim.api.nvim_buf_del_keymap(buf, m, l, api_opts)
else
- vim.api.nvim_del_keymap(m, l)
+ vim.api.nvim_del_keymap(m, l, api_opts)
end
end
end
diff --git a/src/nvim/api/buffer.c b/src/nvim/api/buffer.c
index ac63af888a..4ac3cc932c 100644
--- a/src/nvim/api/buffer.c
+++ b/src/nvim/api/buffer.c
@@ -890,19 +890,25 @@ void nvim_buf_set_keymap(uint64_t channel_id, Buffer buf, String mode, String lh
Dict(keymap) *opts, Error *err)
FUNC_API_SINCE(6)
{
- modify_keymap(channel_id, buf, false, mode, lhs, rhs, opts, err);
+ modify_keymap(channel_id, buf, MAPTYPE_MAP, mode, lhs, rhs, opts, err);
}
/// Unmaps a buffer-local |mapping| for the given mode.
///
/// @see |nvim_del_keymap()|
///
-/// @param buf Buffer id, or 0 for current buffer
-void nvim_buf_del_keymap(uint64_t channel_id, Buffer buf, String mode, String lhs, Error *err)
+/// @param buf Buffer id, or 0 for current buffer
+/// @param mode Mode short-name ("n", "i", "v", ...)
+/// @param lhs Left-hand-side |{lhs}| of the mapping.
+/// @param opts Optional parameters.
+/// - lhs: When true, only match {lhs}, not {rhs}.
+void nvim_buf_del_keymap(uint64_t channel_id, Buffer buf, String mode, String lhs,
+ Dict(keymap_del) *opts, Error *err)
FUNC_API_SINCE(6)
{
String rhs = { .data = "", .size = 0 };
- modify_keymap(channel_id, buf, true, mode, lhs, rhs, NULL, err);
+ int maptype = opts && opts->lhs ? MAPTYPE_UNMAP_LHS : MAPTYPE_UNMAP;
+ modify_keymap(channel_id, buf, maptype, mode, lhs, rhs, NULL, err);
}
/// Sets a buffer-scoped (b:) variable
diff --git a/src/nvim/api/keysets_defs.h b/src/nvim/api/keysets_defs.h
index 2474f5ef3a..a2f8578863 100644
--- a/src/nvim/api/keysets_defs.h
+++ b/src/nvim/api/keysets_defs.h
@@ -94,6 +94,11 @@ typedef struct {
Boolean replace_keycodes;
} Dict(keymap);
+typedef struct {
+ OptionalKeys is_set__keymap_del_;
+ Boolean lhs;
+} Dict(keymap_del);
+
typedef struct {
Boolean builtin;
} Dict(get_commands);
diff --git a/src/nvim/api/vim.c b/src/nvim/api/vim.c
index 562b0be8ab..c595d47b2e 100644
--- a/src/nvim/api/vim.c
+++ b/src/nvim/api/vim.c
@@ -1630,18 +1630,24 @@ void nvim_set_keymap(uint64_t channel_id, String mode, String lhs, String rhs, D
Error *err)
FUNC_API_SINCE(6)
{
- modify_keymap(channel_id, -1, false, mode, lhs, rhs, opts, err);
+ modify_keymap(channel_id, -1, MAPTYPE_MAP, mode, lhs, rhs, opts, err);
}
/// Unmaps a global |mapping| for the given mode.
///
/// To unmap a buffer-local mapping, use |nvim_buf_del_keymap()|.
///
+/// @param mode Mode short-name ("n", "i", "v", ...)
+/// @param lhs Left-hand-side |{lhs}| of the mapping.
+/// @param opts Optional parameters.
+/// - lhs: When true, only match {lhs}, not {rhs}.
+///
/// @see |nvim_set_keymap()|
-void nvim_del_keymap(uint64_t channel_id, String mode, String lhs, Error *err)
+void nvim_del_keymap(uint64_t channel_id, String mode, String lhs, Dict(keymap_del) *opts,
+ Error *err)
FUNC_API_SINCE(6)
{
- nvim_buf_del_keymap(channel_id, -1, mode, lhs, err);
+ nvim_buf_del_keymap(channel_id, -1, mode, lhs, opts, err);
}
/// Returns a 2-tuple (Array), where item 0 is the current channel id and item
diff --git a/src/nvim/mapping.c b/src/nvim/mapping.c
index 74d35a4206..c62d30efc5 100644
--- a/src/nvim/mapping.c
+++ b/src/nvim/mapping.c
@@ -2738,11 +2738,13 @@ void ex_abclear(exarg_T *eap)
/// Arguments are handled like @ref nvim_set_keymap unless noted.
/// @param buffer Buffer handle for a specific buffer, or 0 for the current
/// buffer, or -1 to signify global behavior ("all buffers")
-/// @param is_unmap When true, removes the mapping that matches {lhs}.
-void modify_keymap(uint64_t channel_id, Buffer buffer, bool is_unmap, String mode, String lhs,
+/// @param maptype MAPTYPE_MAP to set a mapping, MAPTYPE_UNMAP to remove the mapping that
+/// matches {lhs} or {rhs}, MAPTYPE_UNMAP_LHS to only match {lhs}.
+void modify_keymap(uint64_t channel_id, Buffer buffer, int maptype, String mode, String lhs,
String rhs, Dict(keymap) *opts, Error *err)
{
LuaRef lua_funcref = LUA_NOREF;
+ bool is_unmap = maptype == MAPTYPE_UNMAP || maptype == MAPTYPE_UNMAP_LHS;
bool global = (buffer == -1);
if (global) {
buffer = 0;
@@ -2834,14 +2836,11 @@ void modify_keymap(uint64_t channel_id, Buffer buffer, bool is_unmap, String mod
}
// buf_do_map() reads noremap/unmap as its own argument.
- int maptype_val = MAPTYPE_MAP;
- if (is_unmap) {
- maptype_val = MAPTYPE_UNMAP;
- } else if (is_noremap) {
- maptype_val = MAPTYPE_NOREMAP;
+ if (maptype == MAPTYPE_MAP && is_noremap) {
+ maptype = MAPTYPE_NOREMAP;
}
- switch (buf_do_map(maptype_val, &parsed_args, mode_val, is_abbrev, target_buf)) {
+ switch (buf_do_map(maptype, &parsed_args, mode_val, is_abbrev, target_buf)) {
case 0:
break;
case 1:
diff --git a/test/functional/api/keymap_spec.lua b/test/functional/api/keymap_spec.lua
index e1fc4ca0c0..b398d42dab 100644
--- a/test/functional/api/keymap_spec.lua
+++ b/test/functional/api/keymap_spec.lua
@@ -594,6 +594,39 @@ describe('nvim_set_keymap, nvim_del_keymap', function()
eq('Invalid (empty) LHS', pcall_err(api.nvim_del_keymap, '', ''))
end)
+ it('del_keymap preserves {rhs} fallback by default #30258', function()
+ api.nvim_set_keymap('n', 'ge', 'q', {})
+ api.nvim_del_keymap('n', 'q')
+ eq({}, get_mapargs('n', 'ge'))
+ end)
+
+ it('del_keymap with opts.lhs matches only {lhs}, never {rhs} #30258', function()
+ api.nvim_set_keymap('n', 'ge', 'q', {})
+ eq('E31: No such mapping', pcall_err(api.nvim_del_keymap, 'n', 'q', { lhs = true }))
+ eq(generate_mapargs('n', 'ge', 'q'), get_mapargs('n', 'ge'))
+
+ api.nvim_del_keymap('n', 'ge', { lhs = true })
+ eq({}, get_mapargs('n', 'ge'))
+ end)
+
+ it('del_keymap for abbreviations with opts.lhs matches only {lhs}, never {rhs} #30258', function()
+ api.nvim_set_keymap('ia', 'foo', 'bar', {})
+ eq('E31: No such mapping', pcall_err(api.nvim_del_keymap, 'ia', 'bar', { lhs = true }))
+ eq(generate_mapargs('ia', 'foo', 'bar'), get_mapargs('ia', 'foo'))
+
+ api.nvim_del_keymap('ia', 'foo', { lhs = true })
+ eq({}, get_mapargs('ia', 'foo'))
+ end)
+
+ it('buf_del_keymap with opts.lhs matches only {lhs}, never {rhs} #30258', function()
+ api.nvim_buf_set_keymap(0, 'n', 'ge', 'q', {})
+ eq('E31: No such mapping', pcall_err(api.nvim_buf_del_keymap, 0, 'n', 'q', { lhs = true }))
+ eq(generate_mapargs('n', 'ge', 'q', { buffer = 1 }), get_mapargs('n', 'ge'))
+
+ api.nvim_buf_del_keymap(0, 'n', 'ge', { lhs = true })
+ eq({}, get_mapargs('n', 'ge'))
+ end)
+
it('error if LHS longer than MAXMAPLEN', function()
-- assume MAXMAPLEN of 50 chars, as declared in mapping_defs.h
local MAXMAPLEN = 50
diff --git a/test/functional/lua/vim_spec.lua b/test/functional/lua/vim_spec.lua
index 844bef111f..03ceb6345f 100644
--- a/test/functional/lua/vim_spec.lua
+++ b/test/functional/lua/vim_spec.lua
@@ -3049,6 +3049,23 @@ describe('vim.keymap', function()
eq('\nNo mapping found', n.exec_capture('nmap qwer'))
end)
+ it('unmap with lhs option', function()
+ eq(
+ 'q',
+ exec_lua [[
+ vim.keymap.set('n', 'ge', 'q')
+ local ok, err = pcall(vim.keymap.del, 'n', 'q', { lhs = true })
+ assert(not ok and err:match('E31: No such mapping'), err)
+ return vim.fn.maparg('ge', 'n')
+ ]]
+ )
+
+ exec_lua [[
+ vim.keymap.del('n', 'ge', { lhs = true })
+ ]]
+ eq('\nNo mapping found', n.exec_capture('nmap ge'))
+ end)
+
it('buffer-local mappings', function()
eq(
0,