From f34ee3da80e864ff545169bc2ced856bf5822260 Mon Sep 17 00:00:00 2001 From: Kyle <50718101+kylesower@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:20:56 -0500 Subject: [PATCH] feat(api): nvim_set_option_value(operation=...) #39849 Problem: `nvim_set_option_value` cannot "update" options similar to `:set opt=`, `:set opt+=`, etc. The Lua impls of "vim.opt" / "vim.o" have incomplete, bespoke reimplementations of those operations. ref #38420 Solution: - Add `operation` param to `nvim_set_option_value`, which may be "set", "append", "prepend", or "remove". - Use this feature to implement `vim.opt` / `vim.o`. --- runtime/doc/api.txt | 8 + runtime/doc/lua.txt | 6 + runtime/doc/news.txt | 9 + runtime/lua/vim/_core/options.lua | 259 ++++++-------------- runtime/lua/vim/_meta/api.gen.lua | 6 + runtime/lua/vim/_meta/api_keysets.gen.lua | 2 + src/nvim/api/keysets_defs.h | 2 + src/nvim/api/options.c | 143 ++++++++++- src/nvim/option.c | 42 ++-- src/nvim/option.h | 23 ++ test/functional/api/vim_spec.lua | 167 ++++++++++++- test/functional/lua/option_and_var_spec.lua | 174 +++++++++++-- 12 files changed, 598 insertions(+), 243 deletions(-) diff --git a/runtime/doc/api.txt b/runtime/doc/api.txt index cbfe46fbce..f45f9eb538 100644 --- a/runtime/doc/api.txt +++ b/runtime/doc/api.txt @@ -3596,6 +3596,11 @@ nvim_set_option_value({name}, {value}, {opts}) • {value} (`any`) New option value • {opts} (`vim.api.keyset.option`) Optional parameters • buf: Buffer number. Used for setting buffer local option. + • dry_run: (`boolean?`, default: false) If true, then the + option value won't be set. + • operation: One of "set", "append", "prepend", or "remove". + Corresponds to |:set=|, |:set+=|, |:set^=|, and |:set-=|. + Default is "set". • scope: One of "global" or "local". Analogous to |:setglobal| and |:setlocal|, respectively. • tab: |tab-ID| for tab-local options (currently only @@ -3604,6 +3609,9 @@ nvim_set_option_value({name}, {value}, {opts}) it is switched-to. • win: |window-ID|. Used for setting window local option. + Return: ~ + (`any`) Option value + ============================================================================== Tabpage Functions *api-tabpage* diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index 2a684af19f..cc2b8ffd16 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -961,6 +961,12 @@ Vim options can be accessed through |vim.o|, which behaves like Vimscript To set a string value: Vimscript: `set wildignore=*.o,*.a,__pycache__` Lua: `vim.o.wildignore = '*.o,*.a,__pycache__'` + Lua (alt): `vim.o.wildignore = { '*.o', '*.a', '__pycache__' }` + + To set a key:value type option: + Vimscript: `set listchars=eol:~,space:-` + Lua: `vim.o.listchars = 'eol:~,space:-'` + Lua (alt): `vim.o.listchars = { eol = '~', space = '-' }` Similarly, there is |vim.bo| and |vim.wo| for setting buffer-scoped and window-scoped options. Note that this must NOT be confused with diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index f2f4b52bf2..aa850b8f3d 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -69,6 +69,8 @@ API • |nvim_create_autocmd()|, |nvim_exec_autocmds()| and |nvim_clear_autocmds()| no longer treat an empty non-nil pattern as nil. • |nvim_clear_autocmds()| no longer treats an empty array event as nil. +• |vim.o|, |vim.opt|, and |nvim_set_option_value()| expand `~` and environment + variables (|expand-env|). DIAGNOSTICS @@ -105,6 +107,9 @@ LUA • "standalone" Lua interpreter mode `nvim -ll` was removed. Use |-l| script mode instead. +• |vim.opt| no longer supports chaining multiple infix operators (e.g. + `vim.opt.wildignore + '*.o' + '*.obj'`). Instead, use tables: + `vim.opt.wildignore + {'*.o', '*.obj'}` OPTIONS @@ -148,6 +153,9 @@ API • |nvim_buf_set_extmark()| `virt_lines_overflow` accepts "wrap" to enable wrapping onto extra rows and "auto" which enables horizontal scrolling when 'nowrap' is set and wrapping when 'wrap' is set. +• |nvim_set_option_value()| accepts a new `operation` field to modify the + existing option value. +• |nvim_set_option_value()| returns the new option value. BUILD @@ -260,6 +268,7 @@ LUA • |vim.log| provides a logging interface. • |vim.pack.get()| output includes revision of a pending update. • |vim.pack.get()| can fetch new updates before computing the output. +• |vim.o| now accepts table style values for assignment. OPTIONS diff --git a/runtime/lua/vim/_core/options.lua b/runtime/lua/vim/_core/options.lua index 50e0c1ef18..5bbf47419b 100644 --- a/runtime/lua/vim/_core/options.lua +++ b/runtime/lua/vim/_core/options.lua @@ -92,6 +92,7 @@ --- Invalid or unset key returns `nil`. --- +local M = {} local api = vim.api -- TODO(tjdevries): Improve option metadata so that this doesn't have to be hardcoded. @@ -167,7 +168,7 @@ local function new_buf_opt_accessor(bufnr) end, __newindex = function(_, k, v) - return api.nvim_set_option_value(k, v, { buf = bufnr or 0 }) + api.nvim_set_option_value(k, v, { buf = bufnr or 0 }) end, }) end @@ -195,7 +196,7 @@ local function new_win_opt_accessor(winid, bufnr) end, __newindex = function(_, k, v) - return api.nvim_set_option_value(k, v, { + api.nvim_set_option_value(k, v, { scope = bufnr and 'local' or nil, win = winid or 0, }) @@ -221,6 +222,12 @@ end --- To set a string value: --- Vimscript: `set wildignore=*.o,*.a,__pycache__` --- Lua: `vim.o.wildignore = '*.o,*.a,__pycache__'` +--- Lua (alt): `vim.o.wildignore = { '*.o', '*.a', '__pycache__' }` +--- +--- To set a key:value type option: +--- Vimscript: `set listchars=eol:~,space:-` +--- Lua: `vim.o.listchars = 'eol:~,space:-'` +--- Lua (alt): `vim.o.listchars = { eol = '~', space = '-' }` --- --- Similarly, there is |vim.bo| and |vim.wo| for setting buffer-scoped and --- window-scoped options. Note that this must NOT be confused with @@ -243,7 +250,7 @@ vim.o = setmetatable({}, { return api.nvim_get_option_value(k, {}) end, __newindex = function(_, k, v) - return api.nvim_set_option_value(k, v, {}) + api.nvim_set_option_value(k, v, {}) end, }) @@ -264,7 +271,7 @@ vim.go = setmetatable({}, { return api.nvim_get_option_value(k, { scope = 'global' }) end, __newindex = function(_, k, v) - return api.nvim_set_option_value(k, v, { scope = 'global' }) + api.nvim_set_option_value(k, v, { scope = 'global' }) end, }) @@ -350,25 +357,7 @@ local function passthrough(_, x) return x end -local function tbl_merge(left, right) - return vim.tbl_extend('force', left, right) -end - ---- @param t table ---- @param value any|any[] -local function tbl_remove(t, value) - if type(value) == 'string' then - t[value] = nil - else - for _, v in ipairs(value) do - t[v] = nil - end - end - - return t -end - -local valid_types = { +local type_mappings = { boolean = { 'boolean' }, number = { 'number' }, string = { 'string' }, @@ -377,6 +366,14 @@ local valid_types = { map = { 'string', 'table' }, } +local function valid_types(name, metatype) + -- Allow strings for special number options + if name == 'wildchar' or name == 'wildcharm' then + return { 'number', 'string' } + end + return type_mappings[metatype] +end + -- Map of functions to take a Lua style value and convert to vimoption_T style value. -- Each function takes (info, lua_value) -> vim_value local to_vim_value = { @@ -445,14 +442,24 @@ local to_vim_value = { } --- Convert a Lua value to a vimoption_T value -local function convert_value_to_vim(name, info, value) - if value == nil then +function M.convert_value_to_vim(name, value, operation) + if value == nil or value == vim.NIL then return vim.NIL end - assert_valid_value(name, value, valid_types[info.metatype]) + local info = get_options_info(name) or error('Not a valid option name: ' .. name) + assert_valid_value(name, value, valid_types(name, info.metatype)) - return to_vim_value[info.metatype](info, value) + local vim_value = to_vim_value[info.metatype](info, value) + -- In lua, we allow vim.opt.listchars = vim.opt.listchars - 'space' + -- In vim, the key requires a colon on the end. See stropt_handle_keymatch. + if operation == 'remove' and info.metatype == 'map' then + if not vim_value:find(':') then + vim_value = vim_value .. ':' + end + end + + return vim_value end -- Map of OptionType to functions that take vimoption_T values and convert to Lua values. @@ -462,15 +469,7 @@ local to_lua_value = { number = passthrough, string = passthrough, - array = function(info, value) - if type(value) == 'table' then - if not info.allows_duplicates then - value = remove_duplicate_values(value) - end - - return value - end - + array = function(_, value) -- Empty strings mean that there is nothing there, -- so empty table should be returned. if value == '' then @@ -561,136 +560,16 @@ local to_lua_value = { } --- Converts a vimoption_T style value to a Lua value -local function convert_value_to_lua(info, option_value) +function M.convert_value_to_lua(name, option_value) + local info = get_options_info(name) or error('Not a valid option name: ' .. name) return to_lua_value[info.metatype](info, option_value) end -local prepend_methods = { - number = function() - error("The '^' operator is not currently supported for") - end, - - string = function(left, right) - return right .. left - end, - - array = function(left, right) - for i = #right, 1, -1 do - table.insert(left, 1, right[i]) - end - - return left - end, - - map = tbl_merge, - set = tbl_merge, -} - ---- Handles the '^' operator -local function prepend_value(info, current, new) - return prepend_methods[info.metatype]( - convert_value_to_lua(info, current), - convert_value_to_lua(info, new) - ) -end - -local add_methods = { - --- @param left integer - --- @param right integer - number = function(left, right) - return left + right - end, - - --- @param left string - --- @param right string - string = function(left, right) - return left .. right - end, - - --- @param left string[] - --- @param right string[] - --- @return string[] - array = function(left, right) - for _, v in ipairs(right) do - table.insert(left, v) - end - - return left - end, - - map = tbl_merge, - set = tbl_merge, -} - ---- Handles the '+' operator -local function add_value(info, current, new) - return add_methods[info.metatype]( - convert_value_to_lua(info, current), - convert_value_to_lua(info, new) - ) -end - ---- @param t table ---- @param val any -local function remove_one_item(t, val) - if vim.islist(t) then - local remove_index = nil - for i, v in ipairs(t) do - if v == val then - remove_index = i - end - end - - if remove_index then - table.remove(t, remove_index) - end - else - t[val] = nil - end -end - -local remove_methods = { - --- @param left integer - --- @param right integer - number = function(left, right) - return left - right - end, - - string = function() - error('Subtraction not supported for strings.') - end, - - --- @param left string[] - --- @param right string[] - --- @return string[] - array = function(left, right) - if type(right) == 'string' then - remove_one_item(left, right) - else - for _, v in ipairs(right) do - remove_one_item(left, v) - end - end - - return left - end, - - map = tbl_remove, - set = tbl_remove, -} - ---- Handles the '-' operator -local function remove_value(info, current, new) - return remove_methods[info.metatype](convert_value_to_lua(info, current), new) -end - local function create_option_accessor(scope) --- @diagnostic disable-next-line: no-unknown local option_mt - local function make_option(name, value) - local info = get_options_info(name) or error('Not a valid option name: ' .. name) - + local function make_option(name, value, op_count) if type(value) == 'table' and getmetatable(value) == option_mt then assert(name == value._name, "must be the same value, otherwise that's weird.") @@ -701,50 +580,63 @@ local function create_option_accessor(scope) return setmetatable({ _name = name, _value = value, - _info = info, + _op_count = op_count, }, option_mt) end option_mt = { - -- To set a value, instead use: - -- opt[my_option] = value - _set = function(self) - local value = convert_value_to_vim(self._name, self._info, self._value) - api.nvim_set_option_value(self._name, value, { scope = scope }) - end, - get = function(self) - return convert_value_to_lua(self._info, self._value) + return M.convert_value_to_lua(self._name, self._value) end, append = function(self, right) - --- @diagnostic disable-next-line: no-unknown - self._value = add_value(self._info, self._value, right) - self:_set() + vim.api.nvim_set_option_value(self._name, right, { operation = 'append', scope = scope }) + end, + + __infix = function(self, right, operation) + -- TODO(kylesower): support multiple infix operations. Right now this + -- doesn't work because nvim_set_option_value uses get_option_newval to + -- merge the values, and that always expects a varp pointer that points + -- to the existing option value. Thus, when a new value is computed with + -- an infix op, but the option isn't updated, subsequent infix ops still + -- use the outdated option value. + -- This could be resolved by updating the option value when computing + -- the infix ops; however, this would then turn the infix ops into + -- assignments. The full solution to the problem requires computing the + -- infix op of two arbitrary values (not just one value compared to an + -- existing option). + if self._op_count > 0 then + error('Multiple vim.opt infix operations unsupported') + end + return make_option( + self._name, + vim.api.nvim_set_option_value( + self._name, + right, + { operation = operation, scope = scope, dry_run = true } + ), + self._op_count + 1 + ) end, __add = function(self, right) - return make_option(self._name, add_value(self._info, self._value, right)) + return self:__infix(right, 'append') end, prepend = function(self, right) - --- @diagnostic disable-next-line: no-unknown - self._value = prepend_value(self._info, self._value, right) - self:_set() + vim.api.nvim_set_option_value(self._name, right, { operation = 'prepend', scope = scope }) end, __pow = function(self, right) - return make_option(self._name, prepend_value(self._info, self._value, right)) + return self:__infix(right, 'prepend') end, remove = function(self, right) - --- @diagnostic disable-next-line: no-unknown - self._value = remove_value(self._info, self._value, right) - self:_set() + vim.api.nvim_set_option_value(self._name, right, { operation = 'remove', scope = scope }) end, __sub = function(self, right) - return make_option(self._name, remove_value(self._info, self._value, right)) + return self:__infix(right, 'remove') end, } option_mt.__index = option_mt @@ -754,11 +646,12 @@ local function create_option_accessor(scope) -- vim.opt_global must get global value only -- vim.opt_local may fall back to global value like vim.opt local opts = { scope = scope == 'global' and 'global' or nil } - return make_option(k, api.nvim_get_option_value(k, opts)) + return make_option(k, api.nvim_get_option_value(k, opts), 0) end, __newindex = function(_, k, v) - make_option(k, v):_set() + local option = make_option(k, v, 0) + api.nvim_set_option_value(option._name, option._value, { scope = scope }) end, }) end @@ -927,3 +820,5 @@ vim.opt_local = create_option_accessor('local') --- @nodoc vim.opt_global = create_option_accessor('global') + +return M diff --git a/runtime/lua/vim/_meta/api.gen.lua b/runtime/lua/vim/_meta/api.gen.lua index c596bee759..fcfe124b26 100644 --- a/runtime/lua/vim/_meta/api.gen.lua +++ b/runtime/lua/vim/_meta/api.gen.lua @@ -2305,12 +2305,18 @@ function vim.api.nvim_set_option(name, value) end --- @param value any New option value --- @param opts vim.api.keyset.option Optional parameters --- - buf: Buffer number. Used for setting buffer local option. +--- - dry_run: (`boolean?`, default: false) If true, then the +--- option value won't be set. +--- - operation: One of "set", "append", "prepend", or "remove". +--- Corresponds to `:set=`, `:set+=`, `:set^=`, and `:set-=`. +--- Default is "set". --- - scope: One of "global" or "local". Analogous to --- `:setglobal` and `:setlocal`, respectively. --- - tab: `tab-ID` for tab-local options (currently only 'cmdheight'). Tabpage 0 --- means the current tabpage. If a non-current tab is given, the value will take --- effect when it is switched-to. --- - win: `window-ID`. Used for setting window local option. +--- @return any # Option value function vim.api.nvim_set_option_value(name, value, opts) end --- Sets a global (g:) variable. diff --git a/runtime/lua/vim/_meta/api_keysets.gen.lua b/runtime/lua/vim/_meta/api_keysets.gen.lua index 3f5a465984..905d908811 100644 --- a/runtime/lua/vim/_meta/api_keysets.gen.lua +++ b/runtime/lua/vim/_meta/api_keysets.gen.lua @@ -381,7 +381,9 @@ error('Cannot require a meta file') --- @class vim.api.keyset.option --- @field buf? integer +--- @field dry_run? boolean --- @field filetype? string +--- @field operation? string --- @field scope? string --- @field tab? integer --- @field win? integer diff --git a/src/nvim/api/keysets_defs.h b/src/nvim/api/keysets_defs.h index 2f3049d731..f7e7dee866 100644 --- a/src/nvim/api/keysets_defs.h +++ b/src/nvim/api/keysets_defs.h @@ -170,6 +170,8 @@ typedef struct { Buffer buf; Tabpage tab; String filetype; + String operation; + Boolean dry_run; } Dict(option); typedef struct { diff --git a/src/nvim/api/options.c b/src/nvim/api/options.c index 6778dbf12a..23d92b293b 100644 --- a/src/nvim/api/options.c +++ b/src/nvim/api/options.c @@ -13,11 +13,14 @@ #include "nvim/buffer.h" #include "nvim/buffer_defs.h" #include "nvim/globals.h" +#include "nvim/lua/executor.h" #include "nvim/memline.h" #include "nvim/memory.h" #include "nvim/memory_defs.h" #include "nvim/option.h" +#include "nvim/option_defs.h" #include "nvim/option_vars.h" +#include "nvim/strings.h" #include "nvim/types_defs.h" #include "nvim/vim_defs.h" #include "nvim/window.h" @@ -26,7 +29,8 @@ static int validate_option_value_args(Dict(option) *opts, char *name, bool allow_tab, OptIndex *opt_idxp, int *opt_flags, OptScope *scope, - void **from, char **filetype, Error *err) + void **from, char **filetype, set_op_T *operation, + bool *dry_run, Error *err) { #define HAS_KEY_X(d, v) HAS_KEY(d, option, v) // Validate incompatible argument combinations first, then resolve handles and scope. @@ -105,6 +109,34 @@ static int validate_option_value_args(Dict(option) *opts, char *name, bool allow return FAIL; } + if (operation != NULL && HAS_KEY_X(opts, operation)) { + if (strequal(opts->operation.data, "set")) { + *operation = OP_NONE; + } else if (strequal(opts->operation.data, "append")) { + *operation = OP_ADDING; + } else if (strequal(opts->operation.data, "prepend")) { + *operation = OP_PREPENDING; + } else if (strequal(opts->operation.data, "remove")) { + *operation = OP_REMOVING; + } else { + VALIDATE_EXP(false, "operation", "'set', 'append', 'prepend', or 'remove'", NULL, { + return FAIL; + }); + } + + VALIDATE_CON(*operation == OP_NONE || option_has_type(*opt_idxp, + kOptValTypeString) + || option_has_type(*opt_idxp, kOptValTypeNumber), + opts->operation.data, + "boolean options", { + return FAIL; + }); + } + + if (dry_run != NULL && HAS_KEY_X(opts, dry_run)) { + *dry_run = opts->dry_run; + } + // Reject keys whose scope the option doesn't support. VALIDATE_CON(!HAS_KEY_X(opts, tab) || option_has_scope(*opt_idxp, kOptScopeTab), "tab", name, { return FAIL; }); @@ -235,7 +267,7 @@ Object nvim_get_option_value(String name, Dict(option) *opts, Error *err) char *filetype = NULL; if (!validate_option_value_args(opts, name.data, true, &opt_idx, &opt_flags, &scope, &from, - &filetype, err)) { + &filetype, NULL, NULL, err)) { return (Object)OBJECT_INIT; } @@ -288,6 +320,11 @@ err: /// @param value New option value /// @param opts Optional parameters /// - buf: Buffer number. Used for setting buffer local option. +/// - dry_run: (`boolean?`, default: false) If true, then the +/// option value won't be set. +/// - operation: One of "set", "append", "prepend", or "remove". +/// Corresponds to |:set=|, |:set+=|, |:set^=|, and |:set-=|. +/// Default is "set". /// - scope: One of "global" or "local". Analogous to /// |:setglobal| and |:setlocal|, respectively. /// - tab: |tab-ID| for tab-local options (currently only 'cmdheight'). Tabpage 0 @@ -295,17 +332,20 @@ err: /// effect when it is switched-to. /// - win: |window-ID|. Used for setting window local option. /// @param[out] err Error details, if any -void nvim_set_option_value(uint64_t channel_id, String name, Object value, Dict(option) *opts, - Error *err) +/// @return Option value +Object nvim_set_option_value(uint64_t channel_id, String name, Object value, Dict(option) *opts, + Arena *arena, Error *err) FUNC_API_SINCE(9) { OptIndex opt_idx = 0; int opt_flags = 0; OptScope scope = kOptScopeGlobal; + set_op_T operation = OP_NONE; void *to = NULL; + bool dry_run = false; if (!validate_option_value_args(opts, name.data, true, &opt_idx, &opt_flags, &scope, &to, NULL, - err)) { - return; + &operation, &dry_run, err)) { + return NIL; } // If: @@ -320,20 +360,99 @@ void nvim_set_option_value(uint64_t channel_id, String name, Object value, Dict( } } + // Convert the incoming Lua object (which could be a table) into the proper + // OptVal type (string even for list/dict style options) + Error lua_err = ERROR_INIT; + MAXSIZE_TEMP_ARRAY(args, 3); + ADD_C(args, STRING_OBJ(name)); + ADD_C(args, value); + ADD_C(args, CSTR_AS_OBJ(set_op_get_name(operation))); + Object vim_val = + NLUA_EXEC_STATIC("return require('vim._core.options').convert_value_to_vim(...)", + args, kRetObject, NULL, &lua_err); + VALIDATE(!ERROR_SET(&lua_err), "%s", lua_err.msg, { + api_clear_error(&lua_err); + return NIL; + }); bool error = false; - OptVal optval = object_as_optval(value, &error); + OptVal optval_right = object_as_optval(vim_val, &error); // Handle invalid option value type. // Don't use `name` in the error message here, because `name` can be any String. // No need to check if value type actually matches the types for the option, as set_option_value() // already handles that. VALIDATE_EXP(!error, "value", "valid option type", api_typename(value.type), { - return; + return NIL; }); - WITH_SCRIPT_CONTEXT(channel_id, { - set_option_value_for(name.data, opt_idx, optval, opt_flags, scope, to, err); - }); + OptVal merged_val = NIL_OPTVAL; + const char *errmsg = NULL; + vimoption_T *option = get_option(opt_idx); + + // Need to use varp specific to buf/win to ensure that merges are handled + // correctly when the supplied buf/win are different than curbuf/curwin. + buf_T *buf = scope == kOptScopeBuf ? to : curbuf; + win_T *win = scope == kOptScopeWin ? to : curwin; + void *varp = get_varp_from(option, buf, win); + char *argp = NULL; + + switch (optval_right.type) { + case kOptValTypeNil: + break; + case kOptValTypeString: { + char *optval_escaped = escape_option_str_cmdline(optval_right.data.string.data); + // We need a leading equal sign because get_option_newval is used for + // cmdline stuff and expects an = + argp = arena_printf(arena, "=%s", optval_escaped).data; + XFREE_CLEAR(optval_escaped); + break; + } + case kOptValTypeNumber: + argp = arena_printf(arena, "=%" PRId64, optval_right.data.number).data; + break; + case kOptValTypeBoolean: + merged_val = optval_right; + break; + } + + optval_free(optval_right); + + if (optval_right.type == kOptValTypeNumber || optval_right.type == kOptValTypeString) { + OptVal oldval = optval_from_varp(opt_idx, varp); + merged_val = get_option_newval(opt_idx, opt_flags, PREFIX_NONE, &argp, 0, operation, + option->flags, varp, &oldval, NULL, 0, &errmsg); + VALIDATE(errmsg == NULL, "%s", errmsg, { + return NIL; + }); + } + + if (!dry_run) { + WITH_SCRIPT_CONTEXT(channel_id, { + set_option_value_for(name.data, opt_idx, merged_val, opt_flags, scope, to, err); + }); + } + + if (merged_val.type == kOptValTypeString) { + // Convert the return type to lua for string/list/map style option + lua_err = ERROR_INIT; + MAXSIZE_TEMP_ARRAY(lua_args, 2); + ADD_C(lua_args, STRING_OBJ(name)); + ADD_C(lua_args, STRING_OBJ(merged_val.data.string)); + Object lua_val = + NLUA_EXEC_STATIC("return require('vim._core.options').convert_value_to_lua(...)", + lua_args, kRetObject, arena, &lua_err); + + optval_free(merged_val); + + VALIDATE(!ERROR_SET(&lua_err), "%s", lua_err.msg, { + api_clear_error(&lua_err); + return NIL; + }); + + return lua_val; + } + + return optval_as_object(merged_val); } /// Gets the option information for all options. @@ -393,7 +512,7 @@ DictAs(get_option_info) nvim_get_option_info2(String name, Dict(option) *opts, A void *from = NULL; // TODO(justinmk): support tab-local option. if (!validate_option_value_args(opts, name.data, false, &opt_idx, &opt_flags, &scope, &from, NULL, - err)) { + NULL, NULL, err)) { return (Dict)ARRAY_DICT_INIT; } diff --git a/src/nvim/option.c b/src/nvim/option.c index 246abecec0..32b2b9545a 100644 --- a/src/nvim/option.c +++ b/src/nvim/option.c @@ -151,13 +151,6 @@ static char *p_vsts_nopaste; #define OPTION_COUNT ARRAY_SIZE(options) -/// :set boolean option prefix -typedef enum { - PREFIX_NO = 0, ///< "no" prefix - PREFIX_NONE, ///< no prefix - PREFIX_INV, ///< "inv" prefix -} set_prefix_T; - #include "option.c.generated.h" // options[] is initialized in options.generated.h. @@ -1100,14 +1093,15 @@ static void stropt_remove_dupflags(char *newval, uint32_t flags) /// Get the string value specified for a ":set" command. The following set options are supported: /// set {opt}={val} /// set {opt}:{val} -static char *stropt_get_newval(int nextchar, OptIndex opt_idx, char **argp, void *varp, - const char *origval, set_op_T *op_arg, uint32_t flags) +static char *stropt_get_newval(OptIndex opt_idx, char **argp, void *varp, const char *origval, + set_op_T *op_arg) { char *arg = *argp; set_op_T op = *op_arg; char *save_arg = NULL; char *newval; const char *s = NULL; + uint32_t flags = options[opt_idx].flags; arg++; // jump to after the '=' or ':' @@ -1326,18 +1320,29 @@ const char *find_option_end(const char *arg, OptIndex *opt_idxp) /// Get new option value from argp. Allocated OptVal must be freed by caller. /// Can unset local value of an option when ":set {option}<" is used. -static OptVal get_option_newval(OptIndex opt_idx, int opt_flags, set_prefix_T prefix, char **argp, - int nextchar, set_op_T op, uint32_t flags, void *varp, char *errbuf, - const size_t errbuflen, const char **errmsg) +OptVal get_option_newval(OptIndex opt_idx, int opt_flags, set_prefix_T prefix, char **argp, + int nextchar, set_op_T op, uint32_t flags, void *varp, + OptVal *oldval_override, char *errbuf, const size_t errbuflen, + const char **errmsg) FUNC_ATTR_WARN_UNUSED_RESULT { assert(varp != NULL); vimoption_T *opt = &options[opt_idx]; char *arg = *argp; - // When setting the local value of a global option, the old value may be the global value. - const bool oldval_is_global = option_is_global_local(opt_idx) && (opt_flags & OPT_LOCAL); - OptVal oldval = optval_from_varp(opt_idx, oldval_is_global ? get_varp(opt) : varp); + + OptVal oldval; + if (oldval_override != NULL) { + // Allow overriding the oldval. This is needed to handle the case where + // options for buffers/windows other than curbuf/curwin are updated. It can + // also support merging arbitrary values if necessary down the road. + oldval = *oldval_override; + } else { + // When setting the local value of a global option, the old value may be the global value. + const bool oldval_is_global = option_is_global_local(opt_idx) && (opt_flags & OPT_LOCAL); + oldval = optval_from_varp(opt_idx, oldval_is_global ? get_varp(opt) : varp); + } + OptVal newval = NIL_OPTVAL; if (nextchar == '&') { @@ -1434,8 +1439,7 @@ static OptVal get_option_newval(OptIndex opt_idx, int opt_flags, set_prefix_T pr case kOptValTypeString: { const char *oldval_str = oldval.data.string.data; // Get the new value for the option - const char *newval_str = stropt_get_newval(nextchar, opt_idx, argp, varp, oldval_str, &op, - flags); + const char *newval_str = stropt_get_newval(opt_idx, argp, varp, oldval_str, &op); newval = CSTR_AS_OPTVAL(newval_str); break; } @@ -1555,7 +1559,7 @@ static void do_one_set_option(int opt_flags, char **argp, bool *did_show, char * } OptVal newval = get_option_newval(opt_idx, opt_flags, prefix, argp, nextchar, op, flags, varp, - errbuf, errbuflen, errmsg); + NULL, errbuf, errbuflen, errmsg); if (newval.type == kOptValTypeNil || *errmsg != NULL) { return; @@ -6072,7 +6076,7 @@ int ExpandSettings(expand_T *xp, regmatch_T *regmatch, char *fuzzystr, int *numM /// Escape an option value that can be used on the command-line with :set. /// Caller needs to free the returned string, unless NULL is returned. -static char *escape_option_str_cmdline(char *var) +char *escape_option_str_cmdline(char *var) { // A backslash is required before some characters. This is the reverse of // what happens in do_set(). diff --git a/src/nvim/option.h b/src/nvim/option.h index 38d69768aa..db5018b163 100644 --- a/src/nvim/option.h +++ b/src/nvim/option.h @@ -33,6 +33,13 @@ typedef enum { OPT_SKIPRTP = 0x80, ///< "skiprtp" in 'sessionoptions' } OptionSetFlags; +/// :set boolean option prefix +typedef enum { + PREFIX_NO = 0, ///< "no" prefix + PREFIX_NONE, ///< no prefix + PREFIX_INV, ///< "inv" prefix +} set_prefix_T; + /// Get name of OptValType as a string. static inline const char *optval_type_get_name(const OptValType type) { @@ -49,6 +56,22 @@ static inline const char *optval_type_get_name(const OptValType type) UNREACHABLE; } +/// Get name of set_op_T as a string +static inline const char *set_op_get_name(const set_op_T op) +{ + switch (op) { + case OP_NONE: + return "set"; + case OP_ADDING: + return "append"; + case OP_PREPENDING: + return "prepend"; + case OP_REMOVING: + return "remove"; + } + UNREACHABLE; +} + // OptVal helper macros. #define NIL_OPTVAL ((OptVal) { .type = kOptValTypeNil }) #define BOOLEAN_OPTVAL(b) ((OptVal) { .type = kOptValTypeBoolean, .data.boolean = b }) diff --git a/test/functional/api/vim_spec.lua b/test/functional/api/vim_spec.lua index b9f18500d0..9d6485ac0e 100644 --- a/test/functional/api/vim_spec.lua +++ b/test/functional/api/vim_spec.lua @@ -1940,16 +1940,16 @@ describe('API', function() "Invalid 'scope': expected String, got Integer", pcall_err(api.nvim_get_option_value, 'scrolloff', { scope = 42 }) ) - eq( - "Invalid 'value': expected valid option type, got Array", + matches( + "Invalid option type 'table' for 'scrolloff', should be number", pcall_err(api.nvim_set_option_value, 'scrolloff', {}, {}) ) - eq( - "Invalid value for option 'scrolloff': expected number, got boolean true", + matches( + "Invalid option type 'boolean' for 'scrolloff', should be number", pcall_err(api.nvim_set_option_value, 'scrolloff', true, {}) ) - eq( - 'Invalid value for option \'scrolloff\': expected number, got string "wrong"', + matches( + "Invalid option type 'string' for 'scrolloff', should be number", pcall_err(api.nvim_set_option_value, 'scrolloff', 'wrong', {}) ) local tab1 = api.nvim_get_current_tabpage() @@ -2243,6 +2243,161 @@ describe('API', function() eq(0, eval('g:modeline')) eq(1, eval('g:bufloaded')) end) + + it('expands env vars', function() + -- I don't know why setting vim.env.INCLUDE doesn't work here. + clear { env = { INCLUDE = '/dev/null' } } + api.nvim_set_option_value('path', '$INCLUDE', {}) + eq('/dev/null', api.nvim_get_option_value('path', {})) + end) + + it('expands ~', function() + clear { env = { HOME = '/dev/null' } } + api.nvim_set_option_value('rtp', '~', {}) + eq('/dev/null', api.nvim_get_option_value('rtp', {})) + end) + + it('allows setting, appending, prepending, and removing lists', function() + api.nvim_set_option_value('wildignore', { '*.o', '*.obj' }, {}) + eq('*.o,*.obj', api.nvim_get_option_value('wildignore', {})) + + api.nvim_set_option_value('wildignore', { '*.a', '*.b', '*.c' }, { operation = 'append' }) + eq('*.o,*.obj,*.a,*.b,*.c', api.nvim_get_option_value('wildignore', {})) + + api.nvim_set_option_value('wildignore', { '1', '2', '3' }, { operation = 'prepend' }) + eq('1,2,3,*.o,*.obj,*.a,*.b,*.c', api.nvim_get_option_value('wildignore', {})) + + -- NOTE: you can't remove something like { '1', '*.obj' } because lists + -- only support removing items in order. Behavior matches :set-= + api.nvim_set_option_value('wildignore', { '1', '2', '3' }, { operation = 'remove' }) + eq('*.o,*.obj,*.a,*.b,*.c', api.nvim_get_option_value('wildignore', {})) + end) + + it('allows setting, appending, prepending, removing tables', function() + -- NOTE: order is dependent on lua's hash map implementation. I don't + -- *think* order matters for the map style options + api.nvim_set_option_value('listchars', { eol = '~', space = '-' }, {}) + eq('eol:~,space:-', api.nvim_get_option_value('listchars', {})) + + api.nvim_set_option_value( + 'listchars', + { multispace = '---+', tab = '>-' }, + { operation = 'append' } + ) + eq('eol:~,space:-,multispace:---+,tab:>-', api.nvim_get_option_value('listchars', {})) + + api.nvim_set_option_value('listchars', { lead = '.' }, { operation = 'prepend' }) + eq('lead:.,eol:~,space:-,multispace:---+,tab:>-', api.nvim_get_option_value('listchars', {})) + + api.nvim_set_option_value( + 'listchars', + { lead = '', multispace = '' }, + { operation = 'remove' } + ) + eq('eol:~,space:-,tab:>-', api.nvim_get_option_value('listchars', {})) + end) + + it('returns the new option value', function() + eq( + { eol = '~', space = '-' }, + api.nvim_set_option_value('listchars', { eol = '~', space = '-' }, {}) + ) + eq('eol:~,space:-', api.nvim_get_option_value('listchars', {})) + eq( + { eol = '~', space = '-', tab = '>-' }, + api.nvim_set_option_value('listchars', { tab = '>-' }, { operation = 'append' }) + ) + eq('eol:~,space:-,tab:>-', api.nvim_get_option_value('listchars', {})) + end) + + it("doesn't update option value with dry_run flag", function() + local listchars = api.nvim_get_option_value('listchars', {}) + eq( + { eol = '~', space = '-' }, + api.nvim_set_option_value('listchars', { eol = '~', space = '-' }, { dry_run = true }) + ) + eq(listchars, api.nvim_get_option_value('listchars', {})) + end) + + it('merges options against non-current window', function() + api.nvim_set_option_value('listchars', {}, { operation = 'set' }) + + local oldwin = fn.win_getid() + command('split') + local curwin = fn.win_getid() + neq(oldwin, curwin) + + eq( + { eol = '~' }, + api.nvim_set_option_value( + 'listchars', + { eol = '~' }, + { operation = 'append', win = oldwin } + ) + ) + eq( + { tab = '>-' }, + api.nvim_set_option_value( + 'listchars', + { tab = '>-' }, + { operation = 'append', win = curwin } + ) + ) + eq( + { eol = '~', space = '-' }, + api.nvim_set_option_value( + 'listchars', + { space = '-' }, + { operation = 'append', win = oldwin } + ) + ) + eq( + { lead = '.', tab = '>-' }, + api.nvim_set_option_value( + 'listchars', + { lead = '.' }, + { operation = 'append', win = curwin } + ) + ) + end) + + it('merges options against non-current buffer', function() + eq({}, api.nvim_set_option_value('completeopt', {}, { operation = 'set' })) + + local oldbuf = fn.bufnr() + command('new') + local curbuf = fn.bufnr() + neq(oldbuf, curbuf) + + eq( + { 'fuzzy' }, + api.nvim_set_option_value( + 'completeopt', + { 'fuzzy' }, + { operation = 'append', buf = oldbuf } + ) + ) + eq( + { 'longest' }, + api.nvim_set_option_value( + 'completeopt', + { 'longest' }, + { operation = 'append', buf = curbuf } + ) + ) + eq( + { 'fuzzy', 'menu' }, + api.nvim_set_option_value('completeopt', { 'menu' }, { operation = 'append', buf = oldbuf }) + ) + eq( + { 'longest', 'noinsert' }, + api.nvim_set_option_value( + 'completeopt', + { 'noinsert' }, + { operation = 'append', buf = curbuf } + ) + ) + end) end) describe('nvim_{get,set}_current_buf, nvim_list_bufs', function() diff --git a/test/functional/lua/option_and_var_spec.lua b/test/functional/lua/option_and_var_spec.lua index f4f54a4f30..bb4e9f2d69 100644 --- a/test/functional/lua/option_and_var_spec.lua +++ b/test/functional/lua/option_and_var_spec.lua @@ -526,6 +526,95 @@ describe('lua stdlib', function() end) describe('options', function() + describe('vim.o', function() + it('works for boolean values', function() + eq_exec_lua(false, function() + vim.o.number = false + return vim.o.number + end) + end) + + it('works for number values', function() + eq_exec_lua(10, function() + vim.o.tabstop = 10 + return vim.o.tabstop + end) + end) + + it('works for string values', function() + eq_exec_lua('hello world', function() + vim.o.makeprg = 'hello world' + return vim.o.makeprg + end) + end) + + it('works for array type options', function() + eq_exec_lua('hello,world', function() + vim.o.wildignore = { 'hello', 'world' } + return vim.o.wildignore + end) + end) + + it('works for key:value type options using string', function() + eq_exec_lua('eol:~,space:-', function() + vim.o.listchars = 'eol:~,space:-' + return vim.o.listchars + end) + end) + + it('works for key:value type options using list of strings', function() + eq_exec_lua('eol:~,space:-', function() + vim.o.listchars = { 'eol:~', 'space:-' } + return vim.o.listchars + end) + end) + + it('works for key:value type options using table', function() + eq_exec_lua('eol:~,space:.', function() + vim.o.listchars = { eol = '~', space = '.' } + return vim.o.listchars + end) + end) + + it('works for set type flaglists', function() + eq_exec_lua('tcro', function() + vim.o.formatoptions = 'tcro' + return vim.o.formatoptions + end) + end) + + it('works for set type flaglists using tables', function() + eq_exec_lua({ t = true, c = true, r = true, o = true }, function() + vim.o.formatoptions = { t = true, c = true, r = true, o = true } + -- use vim.opt to convert to lua value + return vim.opt.formatoptions:get() + end) + end) + + it('works for weird number options', function() + eq_exec_lua(9, function() + vim.o.wildchar = '' + return vim.o.wildchar + end) + end) + + it('expands env vars', function() + eq_exec_lua('/dev/null', function() + vim.fn.setenv('INCLUDE', '/dev/null') + vim.o.path = '$INCLUDE' + return vim.o.path + end) + end) + + it('expands ~', function() + eq_exec_lua('/dev/null', function() + vim.fn.setenv('HOME', '/dev/null') + vim.o.rtp = '~' + return vim.o.rtp + end) + end) + end) + describe('vim.bo', function() it('can get and set options', function() eq('', fn.luaeval 'vim.bo.filetype') @@ -847,12 +936,17 @@ describe('lua stdlib', function() end) end) - it('allows adding dict style', function() - eq_exec_lua('eol:~,space:_', function() - vim.opt.listchars = { eol = '~', space = '.' } - vim.opt.listchars = vim.opt.listchars + { space = '-' } + { space = '_' } - return vim.o.listchars - end) + it('does not allow adding dict style multiple times', function() + matches( + 'Multiple vim.opt infix operations unsupported', + pcall_err( + exec_lua, + [[ + vim.opt.listchars = { eol = '~', space = '.' } + vim.opt.listchars = vim.opt.listchars + { space = '-' } + { space = '_' } + ]] + ) + ) end) it('allows completely new keys', function() @@ -871,20 +965,30 @@ describe('lua stdlib', function() end) end) - it('allows subtracting dict style', function() - eq_exec_lua('', function() - vim.opt.listchars = { eol = '~', space = '.' } - vim.opt.listchars = vim.opt.listchars - 'space' - 'eol' - return vim.o.listchars - end) + it('does not allow subtracting dict style multiple times', function() + matches( + 'Multiple vim.opt infix operations unsupported', + pcall_err( + exec_lua, + [[ + vim.opt.listchars = { eol = '~', space = '.' } + vim.opt.listchars = vim.opt.listchars - 'space' - 'eol' + ]] + ) + ) end) - it('allows subtracting dict style multiple times', function() - eq_exec_lua('eol:~', function() - vim.opt.listchars = { eol = '~', space = '.' } - vim.opt.listchars = vim.opt.listchars - 'space' - 'space' - return vim.o.listchars - end) + it('does not allow subtracting dict style multiple times with the same key', function() + matches( + 'Multiple vim.opt infix operations unsupported', + pcall_err( + exec_lua, + [[ + vim.opt.listchars = { eol = '~', space = '.' } + vim.opt.listchars = vim.opt.listchars - 'space' - 'space' + ]] + ) + ) end) it('allows adding a key:value string to a listchars', function() @@ -898,6 +1002,7 @@ describe('lua stdlib', function() it('allows prepending a key:value string to a listchars', function() eq_exec_lua('eol:~,space:.,tab:>~', function() vim.opt.listchars = { eol = '~', space = '.' } + -- Operator overloading is cursed. vim.opt.listchars = vim.opt.listchars ^ 'tab:>~' return vim.o.listchars end) @@ -965,12 +1070,17 @@ describe('lua stdlib', function() end) end) - it('allows adding multiple times', function() - eq_exec_lua('foo,bar,baz', function() - vim.opt.wildignore = 'foo' - vim.opt.wildignore = vim.opt.wildignore + 'bar' + 'baz' - return vim.o.wildignore - end) + it('does not allow adding multiple times', function() + matches( + 'Multiple vim.opt infix operations unsupported', + pcall_err( + exec_lua, + [[ + vim.opt.wildignore = 'foo' + vim.opt.wildignore = vim.opt.wildignore + 'bar' + 'baz' + ]] + ) + ) end) it('removes values when you use minus', function() @@ -1211,6 +1321,22 @@ describe('lua stdlib', function() end) end) end) + + it('expands env vars', function() + eq_exec_lua({ '/dev/null' }, function() + vim.env.INCLUDE = '/dev/null' + vim.opt.path = '$INCLUDE' + return vim.opt.path:get() + end) + end) + + it('expands ~', function() + eq_exec_lua({ '/dev/null' }, function() + vim.env.HOME = '/dev/null' + vim.opt.rtp = '~' + return vim.opt.rtp:get() + end) + end) end) -- vim.opt describe('vim.opt_local', function()