From 25439dad7e2ba21ff2f78759c68be888a217342c Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:28:24 -0500 Subject: [PATCH] feat(api): relax contract, `opts` + rest params are optional #40581 Problem: |api-contract| allows adding an optional trailing `opts` parameter, but the dispatch generator still requires every parameter, so `opts` cannot be omitted. Solution: Treat a trailing `Dict(...) *opts` parameter as optional in the generated RPC and Lua dispatch wrappers. Co-authored-by: Judit Novak --- runtime/doc/news.txt | 2 + src/gen/gen_api_dispatch.lua | 153 +++++++++++++++++++++++---- test/functional/api/version_spec.lua | 31 ++++++ 3 files changed, 164 insertions(+), 22 deletions(-) diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 9a2cdbbba5..f1d2aa01ab 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -141,6 +141,8 @@ API • |nvim_win_resize()| resizes a window with a specified anchor edge. |nvim_win_set_height()| and |nvim_win_set_width()| are now deprecated. +• |api-contract| allows existing functions to add an optional trailing `opts` + parameter, which clients may omit. • |nvim_buf_call()| and |nvim_win_call()| now preserve multiple return values. • |nvim_set_hl()| supports "font" key. • |nvim_open_win()| `zindex` controls whether the UI will use a dimmed cursor diff --git a/src/gen/gen_api_dispatch.lua b/src/gen/gen_api_dispatch.lua index d7b4dde1b2..9a224d3077 100644 --- a/src/gen/gen_api_dispatch.lua +++ b/src/gen/gen_api_dispatch.lua @@ -59,6 +59,7 @@ end --- @field can_fail? true --- @field has_lua_imp? true --- @field receives_arena? true +--- @field has_opts? true --- @field impl_name? string --- @field remote? boolean --- @field lua? boolean @@ -116,6 +117,13 @@ local function add_function(fn) fn.receives_arena = true fn.parameters[#fn.parameters] = nil end + if + #fn.parameters ~= 0 + and fn.parameters[#fn.parameters][2] == 'opts' + and real_type(fn.parameters[#fn.parameters][1]):match('^KeyDict_') + then + fn.has_opts = true + end end end @@ -431,19 +439,50 @@ for i = 1, #functions do local param = fn.parameters[j] local rt = real_type(param[1]) local converted = 'arg_' .. j - output:write('\n ' .. rt .. ' ' .. converted .. ';') + if fn.has_opts and j == #fn.parameters then + output:write('\n ' .. rt .. ' ' .. converted .. ' = { 0 };') + else + output:write('\n ' .. rt .. ' ' .. converted .. ';') + end end output:write('\n') if not fn.receives_array_args then - output:write('\n if (args.size != ' .. #fn.parameters .. ') {') - output:write( - '\n api_set_error(error, kErrorTypeException, \ + if fn.has_opts then + local minargs = #fn.parameters - 1 + if minargs > 0 then + output:write( + '\n if (args.size < ' .. minargs .. ' || args.size > ' .. #fn.parameters .. ') {' + ) + output:write( + '\n api_set_error(error, kErrorTypeException, \ "Wrong number of arguments: expecting ' - .. #fn.parameters - .. ' but got %zu", args.size);' - ) - output:write('\n goto cleanup;') - output:write('\n }\n') + .. minargs + .. ' to ' + .. #fn.parameters + .. ' but got %zu", args.size);' + ) + else + output:write('\n if (args.size > ' .. #fn.parameters .. ') {') + output:write( + '\n api_set_error(error, kErrorTypeException, \ + "Wrong number of arguments: expecting at most ' + .. #fn.parameters + .. ' but got %zu", args.size);' + ) + end + output:write('\n goto cleanup;') + output:write('\n }\n') + else + output:write('\n if (args.size != ' .. #fn.parameters .. ') {') + output:write( + '\n api_set_error(error, kErrorTypeException, \ + "Wrong number of arguments: expecting ' + .. #fn.parameters + .. ' but got %zu", args.size);' + ) + output:write('\n goto cleanup;') + output:write('\n }\n') + end end -- Validation/conversion for each argument @@ -456,6 +495,10 @@ for i = 1, #functions do output:write('\n ' .. converted .. ' = args.items[' .. (j - 1) .. '];\n') elseif rt:match('^KeyDict_') then converted = '&' .. converted + local optional = fn.has_opts and j == #fn.parameters + if optional then + output:write('\n if (args.size == ' .. j .. ') {') + end output:write('\n if (args.items[' .. (j - 1) .. '].type == kObjectTypeDict) {') --luacheck: ignore 631 output:write('\n memset(' .. converted .. ', 0, sizeof(*' .. converted .. '));') -- TODO: neeeee output:write( @@ -491,6 +534,9 @@ for i = 1, #functions do ) output:write('\n goto cleanup;') output:write('\n }\n') + if optional then + output:write('\n }\n') + end else if rt:match('^Buffer$') or rt:match('^Window$') or rt:match('^Tabpage$') then -- Buffer, Window, and Tabpage have a specific type, but are stored in integer @@ -730,16 +776,50 @@ local function process_function(fn) Error err = ERROR_INIT; Arena arena = ARENA_EMPTY; char *err_param = 0; + ]], + lua_c_function_name + ) + if fn.has_opts then + local minargs = #fn.parameters - 1 + if minargs > 0 then + write_shifted_output( + [[ + if (lua_gettop(lstate) < %i || lua_gettop(lstate) > %i) { + api_set_error(&err, kErrorTypeValidation, "Expected %i to %i arguments"); + goto exit_0; + } + ]], + minargs, + #fn.parameters, + minargs, + #fn.parameters + ) + else + write_shifted_output( + [[ + if (lua_gettop(lstate) > %i) { + api_set_error(&err, kErrorTypeValidation, "Expected at most %i argument%s"); + goto exit_0; + } + ]], + #fn.parameters, + #fn.parameters, + (#fn.parameters == 1) and '' or 's' + ) + end + else + write_shifted_output( + [[ if (lua_gettop(lstate) != %i) { api_set_error(&err, kErrorTypeValidation, "Expected %i argument%s"); goto exit_0; } ]], - lua_c_function_name, - #fn.parameters, - #fn.parameters, - (#fn.parameters == 1) and '' or 's' - ) + #fn.parameters, + #fn.parameters, + (#fn.parameters == 1) and '' or 's' + ) + end lua_c_functions[#lua_c_functions + 1] = { binding = lua_c_function_name, api = fn.name, @@ -784,17 +864,33 @@ local function process_function(fn) end local errshift = 0 local seterr = '' + local optional = fn.has_opts and j == #fn.parameters if param_type:match('^KeyDict_') then - write_shifted_output( - [[ + if optional then + write_shifted_output( + [[ + %s %s = KEYDICT_INIT; + if (lua_gettop(lstate) == %d) { + nlua_pop_keydict(lstate, &%s, %s_get_field, &err_param, &arena, &err); + ]], + param_type, + cparam, + #fn.parameters, + cparam, + param_type + ) + else + write_shifted_output( + [[ %s %s = KEYDICT_INIT; nlua_pop_keydict(lstate, &%s, %s_get_field, &err_param, &arena, &err); ]], - param_type, - cparam, - cparam, - param_type - ) + param_type, + cparam, + cparam, + param_type + ) + end cparam = '&' .. cparam errshift = 1 -- free incomplete dict on error arg_free_code = ' api_luarefs_free_keydict(' @@ -814,7 +910,19 @@ local function process_function(fn) seterr = '\n err_param = "' .. param[2] .. '";' end - write_shifted_output([[ + if optional then + write_shifted_output( + [[ + if (ERROR_SET(&err)) { + goto exit_%u; + } + } + + ]], + #fn.parameters - j + errshift + ) + else + write_shifted_output([[ if (ERROR_SET(&err)) {]] .. seterr .. [[ @@ -822,6 +930,7 @@ local function process_function(fn) } ]], #fn.parameters - j + errshift) + end free_code[#free_code + 1] = arg_free_code cparams = cparam .. ', ' .. cparams end diff --git a/test/functional/api/version_spec.lua b/test/functional/api/version_spec.lua index 7bff668323..ba3bb7a0f2 100644 --- a/test/functional/api/version_spec.lua +++ b/test/functional/api/version_spec.lua @@ -3,6 +3,8 @@ local n = require('test.functional.testnvim')() local clear, fn, eq = n.clear, n.fn, t.eq local api = n.api +local matches = t.matches +local pcall_err = t.pcall_err local function read_mpack_file(fname) local fd = io.open(fname, 'rb') @@ -280,3 +282,32 @@ describe('api metadata', function() end end) end) + +describe('api: optional trailing opts parameter', function() + before_each(clear) + + it('may be omitted', function() + api.nvim_exec2('let g:x = 41') + eq(41, api.nvim_get_var('x')) + eq({ output = 'hi' }, api.nvim_exec2('echo "hi"', { output = true })) + end) + + it('may be omitted when it is the only parameter', function() + eq('table', type(api.nvim_get_context())) + end) + + it('errors on too many arguments', function() + matches( + 'Wrong number of arguments: expecting 1 to 2 but got 3', + pcall_err(api.nvim_exec2, 'echo 1', {}, 'surplus') + ) + matches( + 'Wrong number of arguments: expecting at most 1 but got 2', + pcall_err(api.nvim_get_context, {}, 'surplus') + ) + end) + + it('errors on too few arguments', function() + matches('Wrong number of arguments: expecting 1 to 2 but got 0', pcall_err(api.nvim_exec2)) + end) +end)