diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index 9601537c8d..d1f244c76f 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -717,6 +717,16 @@ vim.NIL *vim.NIL* is equivalent to a missing value: `{"foo", nil}` is the same as `{"foo"}` +vim.empty_dict() *vim.empty_dict()* + Creates a special table which will be converted to an empty + dictionary when converting lua values to vimL or API types. The + table is empty, and this property is marked using a metatable. An + empty table `{}` without this metatable will default to convert to + an array/list. + + Note: if numeric keys are added to the table, the metatable will be + ignored and the dict converted to a list/array anyway. + vim.rpcnotify({channel}, {method}[, {args}...]) *vim.rpcnotify()* Sends {event} to {channel} via |RPC| and returns immediately. If {channel} is 0, the event is broadcast to all channels. diff --git a/runtime/lua/vim/inspect.lua b/runtime/lua/vim/inspect.lua index 0f3b908dc1..0448ea487f 100644 --- a/runtime/lua/vim/inspect.lua +++ b/runtime/lua/vim/inspect.lua @@ -244,6 +244,11 @@ function Inspector:putTable(t) local nonSequentialKeys, nonSequentialKeysLength, sequenceLength = getNonSequentialKeys(t) local mt = getmetatable(t) + if (vim and sequenceLength == 0 and nonSequentialKeysLength == 0 + and mt == vim._empty_dict_mt) then + self:puts(tostring(t)) + return + end self:puts('{') self:down(function() diff --git a/runtime/lua/vim/shared.lua b/runtime/lua/vim/shared.lua index b5b04d7757..6df9bf1c2f 100644 --- a/runtime/lua/vim/shared.lua +++ b/runtime/lua/vim/shared.lua @@ -275,9 +275,15 @@ function vim.tbl_flatten(t) end -- Determine whether a Lua table can be treated as an array. +-- +-- An empty table `{}` will default to being treated as an array. +-- Use `vim.emtpy_dict()` to create a table treated as an +-- empty dict. Empty tables returned by `rpcrequest()` and +-- `vim.fn` functions can be checked using this function +-- whether they represent empty API arrays and vimL lists. --- --@params Table ---@returns true: A non-empty array, false: A non-empty table, nil: An empty table +--@returns true: An array-like table, false: A dict-like or mixed table function vim.tbl_islist(t) if type(t) ~= 'table' then return false @@ -296,7 +302,12 @@ function vim.tbl_islist(t) if count > 0 then return true else - return nil + -- TODO(bfredl): in the future, we will always be inside nvim + -- then this check can be deleted. + if vim._empty_dict_mt == nil then + return nil + end + return getmetatable(t) ~= vim._empty_dict_mt end end diff --git a/src/nvim/lua/converter.c b/src/nvim/lua/converter.c index 09d1a68898..fca74b5901 100644 --- a/src/nvim/lua/converter.c +++ b/src/nvim/lua/converter.c @@ -156,6 +156,13 @@ static LuaTableProps nlua_traverse_table(lua_State *const lstate) && other_keys_num == 0 && ret.string_keys_num == 0)) { ret.type = kObjectTypeArray; + if (tsize == 0 && lua_getmetatable(lstate, -1)) { + nlua_pushref(lstate, nlua_empty_dict_ref); + if (lua_rawequal(lstate, -2, -1)) { + ret.type = kObjectTypeDictionary; + } + lua_pop(lstate, 2); + } } else if (ret.string_keys_num == tsize) { ret.type = kObjectTypeDictionary; } else { @@ -465,6 +472,8 @@ static bool typval_conv_special = false; nlua_create_typed_table(lstate, 0, 0, kObjectTypeDictionary); \ } else { \ lua_createtable(lstate, 0, 0); \ + nlua_pushref(lstate, nlua_empty_dict_ref); \ + lua_setmetatable(lstate, -2); \ } \ } while (0) @@ -695,6 +704,10 @@ void nlua_push_Dictionary(lua_State *lstate, const Dictionary dict, nlua_create_typed_table(lstate, 0, 0, kObjectTypeDictionary); } else { lua_createtable(lstate, 0, (int)dict.size); + if (dict.size == 0 && !special) { + nlua_pushref(lstate, nlua_empty_dict_ref); + lua_setmetatable(lstate, -2); + } } for (size_t i = 0; i < dict.size; i++) { nlua_push_String(lstate, dict.items[i].key, special); diff --git a/src/nvim/lua/executor.c b/src/nvim/lua/executor.c index 2cd6c0db66..242d4e18d1 100644 --- a/src/nvim/lua/executor.c +++ b/src/nvim/lua/executor.c @@ -324,6 +324,13 @@ static int nlua_state_init(lua_State *const lstate) FUNC_ATTR_NONNULL_ALL nlua_nil_ref = nlua_ref(lstate, -1); lua_setfield(lstate, -2, "NIL"); + // vim._empty_dict_mt + lua_createtable(lstate, 0, 0); + lua_pushcfunction(lstate, &nlua_empty_dict_tostring); + lua_setfield(lstate, -2, "__tostring"); + nlua_empty_dict_ref = nlua_ref(lstate, -1); + lua_setfield(lstate, -2, "_empty_dict_mt"); + // internal vim._treesitter... API nlua_add_treesitter(lstate); @@ -665,6 +672,12 @@ static int nlua_nil_tostring(lua_State *lstate) return 1; } +static int nlua_empty_dict_tostring(lua_State *lstate) +{ + lua_pushstring(lstate, "vim.empty_dict()"); + return 1; +} + #ifdef WIN32 /// os.getenv: override os.getenv to maintain coherency. #9681 diff --git a/src/nvim/lua/executor.h b/src/nvim/lua/executor.h index 32f66b629c..3259fc0fa1 100644 --- a/src/nvim/lua/executor.h +++ b/src/nvim/lua/executor.h @@ -13,6 +13,7 @@ void nlua_add_api_functions(lua_State *lstate) REAL_FATTR_NONNULL_ALL; EXTERN LuaRef nlua_nil_ref INIT(= LUA_NOREF); +EXTERN LuaRef nlua_empty_dict_ref INIT(= LUA_NOREF); #define set_api_error(s, err) \ do { \ diff --git a/src/nvim/lua/vim.lua b/src/nvim/lua/vim.lua index e7c5458102..8ba550ea31 100644 --- a/src/nvim/lua/vim.lua +++ b/src/nvim/lua/vim.lua @@ -243,6 +243,10 @@ function vim.schedule_wrap(cb) end) end +function vim.empty_dict() + return setmetatable({}, vim._empty_dict_mt) +end + -- vim.fn.{func}(...) vim.fn = setmetatable({}, { __index = function(t, key) diff --git a/test/functional/lua/vim_spec.lua b/test/functional/lua/vim_spec.lua index 17ffcd8d86..e879f8b925 100644 --- a/test/functional/lua/vim_spec.lua +++ b/test/functional/lua/vim_spec.lua @@ -354,7 +354,8 @@ describe('lua stdlib', function() end) it('vim.tbl_islist', function() - eq(NIL, exec_lua("return vim.tbl_islist({})")) + eq(true, exec_lua("return vim.tbl_islist({})")) + eq(false, exec_lua("return vim.tbl_islist(vim.empty_dict())")) eq(true, exec_lua("return vim.tbl_islist({'a', 'b', 'c'})")) eq(false, exec_lua("return vim.tbl_islist({'a', '32', a='hello', b='baz'})")) eq(false, exec_lua("return vim.tbl_islist({1, a='hello', b='baz'})")) @@ -458,6 +459,19 @@ describe('lua stdlib', function() ]])) eq({3, 'aa', true, NIL}, exec_lua([[return ret]])) + eq({{}, {}, false, true}, exec_lua([[ + vim.rpcrequest(chan, 'nvim_exec', 'let xx = {}\nlet yy = []', false) + local dict = vim.rpcrequest(chan, 'nvim_eval', 'xx') + local list = vim.rpcrequest(chan, 'nvim_eval', 'yy') + return {dict, list, vim.tbl_islist(dict), vim.tbl_islist(list)} + ]])) + + exec_lua([[ + vim.rpcrequest(chan, 'nvim_set_var', 'aa', {}) + vim.rpcrequest(chan, 'nvim_set_var', 'bb', vim.empty_dict()) + ]]) + eq({1, 1}, eval('[type(g:aa) == type([]), type(g:bb) == type({})]')) + -- error handling eq({false, 'Invalid channel: 23'}, exec_lua([[return {pcall(vim.rpcrequest, 23, 'foo')}]])) @@ -486,7 +500,7 @@ describe('lua stdlib', function() }) screen:attach() exec_lua([[ - local timer = vim.loop.new_timer() + timer = vim.loop.new_timer() timer:start(20, 0, function () -- notify ok (executed later when safe) vim.rpcnotify(chan, 'nvim_set_var', 'yy', {3, vim.NIL}) @@ -505,6 +519,32 @@ describe('lua stdlib', function() ]]} feed('') eq({3, NIL}, meths.get_var('yy')) + + exec_lua([[timer:close()]]) + end) + + it('vim.empty_dict()', function() + eq({true, false, true, true}, exec_lua([[ + vim.api.nvim_set_var('listy', {}) + vim.api.nvim_set_var('dicty', vim.empty_dict()) + local listy = vim.fn.eval("listy") + local dicty = vim.fn.eval("dicty") + return {vim.tbl_islist(listy), vim.tbl_islist(dicty), next(listy) == nil, next(dicty) == nil} + ]])) + + -- vim.empty_dict() gives new value each time + -- equality is not overriden (still by ref) + -- non-empty table uses the usual heuristics (ignores the tag) + eq({false, {"foo"}, {namey="bar"}}, exec_lua([[ + local aa = vim.empty_dict() + local bb = vim.empty_dict() + local equally = (aa == bb) + aa[1] = "foo" + bb["namey"] = "bar" + return {equally, aa, bb} + ]])) + + eq("{ {}, vim.empty_dict() }", exec_lua("return vim.inspect({{}, vim.empty_dict()})")) end) it('vim.validate', function()