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,