From 6fb4d0050a93a33438655115bd3c8130bf00233f Mon Sep 17 00:00:00 2001 From: Lewis Russell Date: Wed, 20 Nov 2024 10:21:35 +0000 Subject: [PATCH] feat(options): allow setting function values to vim.o/go/bo Problem: Assigning function values to options is tedious. Solution: Allow setting function values to options directly. --- runtime/doc/lsp.txt | 7 +- runtime/doc/lua.txt | 8 ++ runtime/lua/vim/_options.lua | 162 +++++++++++++++++++++++++- runtime/lua/vim/lsp.lua | 7 +- test/functional/lua/option_spec.lua | 79 ++++++++++--- test/functional/testnvim/exec_lua.lua | 2 +- 6 files changed, 242 insertions(+), 23 deletions(-) diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt index d9e536b79b..a587e52ab6 100644 --- a/runtime/doc/lsp.txt +++ b/runtime/doc/lsp.txt @@ -746,8 +746,11 @@ formatexpr({opts}) *vim.lsp.formatexpr()* Currently only supports a single client. This can be set via `setlocal formatexpr=v:lua.vim.lsp.formatexpr()` or (more typically) in - `on_attach` via - `vim.bo[bufnr].formatexpr = 'v:lua.vim.lsp.formatexpr(#{timeout_ms:250})'`. + `on_attach` via: >lua + vim.bo[bufnr].formatexpr = function() + return vim.lsp.formatexpr({ timeout_ms = 250 }) + end +< Parameters: ~ • {opts} (`table?`) A table with the following fields: diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index dad3d92238..759277cd49 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -1293,6 +1293,14 @@ window-scoped options. Note that this must NOT be confused with |local-options| and |:setlocal|. There is also |vim.go| that only accesses the global value of a |global-local| option, see |:setglobal|. +Unlike |nvim_set_option_value()|, |vim.o|, |vim.go|, |vim.bo| +and |vim.wo| can be assigned function values. + +Example: >lua + vim.bo.tagfunc = vim.lsp.tagfunc + vim.wo[1000].foldexpr = vim.treesitter.foldexpr +< + *vim.opt_local* *vim.opt_global* diff --git a/runtime/lua/vim/_options.lua b/runtime/lua/vim/_options.lua index 77d7054626..c4f908384f 100644 --- a/runtime/lua/vim/_options.lua +++ b/runtime/lua/vim/_options.lua @@ -134,6 +134,145 @@ local function get_options_info(name) return info end +local func_opts = { + -- Options that can be set as a function + func = { + completefunc = true, + findfunc = true, + omnifunc = true, + operatorfunc = true, + quickfixtextfunc = true, + tagfunc = true, + thesaurusfunc = true, + }, + + -- Options that can be set as an expression + expr = { + diffexpr = true, + foldexpr = true, + formatexpr = true, + includeexpr = true, + indentexpr = true, + modelineexpr = true, + patchexpr = true, + }, + + -- Options that can be set as an expression with a prefix of '%!' + pct_bang_expr = { + statuscolumn = true, + statusline = true, + tabline = true, + winbar = true, + }, + + -- Options that can be set as an ex command if prefixed with ':' + ex_cmd_expr = { + keywordprg = true, + }, +} + +--- @param v integer? +--- @return integer +local function resolve_win(v) + return assert((not v or v == 0) and api.nvim_get_current_win() or v) +end + +--- @type table +local all_func_opts = {} +for _, v in pairs(func_opts) do + for k in pairs(v) do + all_func_opts[k] = true + end +end + +--- @type table +vim._func_opts = setmetatable({}, { __mode = 'v' }) + +--- If the option name is a function option convert it to a string +--- format and store the function value. +--- @param name string +--- @param value any +--- @param info vim.api.keyset.get_option_info +--- @param opts vim.api.keyset.option +local function apply_func_opt(name, value, info, opts) + local idxs --- @type any[] + + -- Find a table keep a references to the function value for an option. + -- We store a strong reference to the function in vim.g/b/w so it can be correctly garbage + -- collected when the buffer/window is closed. However because `v:lua` does not support + -- indexing with [], we need to store a weak reference to the function in another table under a + -- single string key. + if info.scope == 'global' or info.global_local and opts.scope == 'global' then + idxs = { 'g', '_func_opts' } + elseif info.scope == 'buf' then + idxs = { 'b', vim._resolve_bufnr(opts.buf), '_func_opts' } + else + assert(info.scope == 'win') + idxs = { 'w', resolve_win(opts.win), '_func_opts' } + if opts.scope == 'local' then + idxs[#idxs + 1] = vim._resolve_bufnr(opts.buf) + end + end + + local fvalue = type(value) == 'function' and value or nil + + -- If we have a function value, ensure the table for the strong references exists + if fvalue then + local t = vim --- @type table + for _, k in ipairs(idxs) do + t[k] = t[k] or {} + t = t[k] + end + end + + --- @type table + local t = vim.tbl_get(vim, unpack(idxs)) + if t then + -- Note fvalue as nil is used to GC + t[name] = fvalue + end + + if not fvalue then + return value + end + + local vlua_key_parts = {} --- @type string[] + for _, k in ipairs(idxs) do + vlua_key_parts[#vlua_key_parts + 1] = k ~= '_func_opts' and k or nil + end + vlua_key_parts[#vlua_key_parts + 1] = name + + local vlua_key = table.concat(vlua_key_parts, '_') + + vim._func_opts[vlua_key] = fvalue + + local expr_pfx = ( + func_opts.pct_bang_expr[name] and '%!' + or func_opts.ex_cmd_expr[name] and ':call ' + or '' + ) + + local call_sfx = func_opts.func[name] and '' or '()' + + return ('%sv:lua.vim._func_opts.%s%s'):format(expr_pfx, vlua_key, call_sfx) +end + +--- @param name string +--- @param value any +--- @param opts vim.api.keyset.option +local function set_option_value(name, value, opts) + local info = api.nvim_get_option_info2(name, {}) + + -- Resolve name if it is a short name + name = info.name + + if all_func_opts[name] then + value = apply_func_opt(name, value, info, opts) + end + + api.nvim_set_option_value(name, value, opts) +end + --- Environment variables defined in the editor session. --- See |expand-env| and |:let-environment| for the Vimscript behavior. --- Invalid or unset key returns `nil`. @@ -158,6 +297,7 @@ vim.env = setmetatable({}, { end, }) +--- @param bufnr? integer local function new_buf_opt_accessor(bufnr) return setmetatable({}, { __index = function(_, k) @@ -167,8 +307,10 @@ local function new_buf_opt_accessor(bufnr) return api.nvim_get_option_value(k, { buf = bufnr or 0 }) end, + --- @param k string + --- @param v any __newindex = function(_, k, v) - return api.nvim_set_option_value(k, v, { buf = bufnr or 0 }) + return set_option_value(k, v, { buf = bufnr or 0 }) end, }) end @@ -196,7 +338,7 @@ local function new_win_opt_accessor(winid, bufnr) end, __newindex = function(_, k, v) - return api.nvim_set_option_value(k, v, { + return set_option_value(k, v, { scope = bufnr and 'local' or nil, win = winid or 0, }) @@ -227,6 +369,14 @@ end --- window-scoped options. Note that this must NOT be confused with --- |local-options| and |:setlocal|. There is also |vim.go| that only accesses the --- global value of a |global-local| option, see |:setglobal|. +--- +--- Unlike |nvim_set_option_value()|, |vim.o|, |vim.go|, |vim.bo| +--- and |vim.wo| can be assigned function values. +--- +--- Example: >lua +--- vim.bo.tagfunc = vim.lsp.tagfunc +--- vim.wo[1000].foldexpr = vim.treesitter.foldexpr +--- < --- --- Get or set |options|. Like `:set`. Invalid key is an error. @@ -245,8 +395,10 @@ vim.o = setmetatable({}, { __index = function(_, k) return api.nvim_get_option_value(k, {}) end, + --- @param k string + --- @param v any __newindex = function(_, k, v) - return api.nvim_set_option_value(k, v, {}) + return set_option_value(k, v, {}) end, }) @@ -268,8 +420,10 @@ vim.go = setmetatable({}, { __index = function(_, k) return api.nvim_get_option_value(k, { scope = 'global' }) end, + --- @param k string + --- @param v any __newindex = function(_, k, v) - return api.nvim_set_option_value(k, v, { scope = 'global' }) + return set_option_value(k, v, { scope = 'global' }) end, }) diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index ebdc050405..c3202e9b6a 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -1066,8 +1066,13 @@ end --- --- Currently only supports a single client. This can be set via --- `setlocal formatexpr=v:lua.vim.lsp.formatexpr()` or (more typically) in `on_attach` ---- via `vim.bo[bufnr].formatexpr = 'v:lua.vim.lsp.formatexpr(#{timeout_ms:250})'`. +--- via: --- +--- ```lua +--- vim.bo[bufnr].formatexpr = function() +--- return vim.lsp.formatexpr({ timeout_ms = 250 }) +--- end +--- ``` ---@param opts? vim.lsp.formatexpr.Opts function lsp.formatexpr(opts) opts = opts or {} diff --git a/test/functional/lua/option_spec.lua b/test/functional/lua/option_spec.lua index e7be0588eb..b09d3547f9 100644 --- a/test/functional/lua/option_spec.lua +++ b/test/functional/lua/option_spec.lua @@ -149,7 +149,7 @@ describe('lua stdlib', function() vim.api.nvim_buf_set_var(0, 'nullvar', vim.NIL) vim.api.nvim_buf_set_var(0, 'to_delete', { hello = 'world' }) _G.BUF = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_var(BUF, 'testing', 'bye') + vim.api.nvim_buf_set_var(_G.BUF, 'testing', 'bye') end) eq('hi', fn.luaeval 'vim.b.testing') @@ -164,7 +164,10 @@ describe('lua stdlib', function() return { vim.b.nonexistent == vim.NIL, vim.b.nullvar == vim.NIL } end) - matches([[attempt to index .* nil value]], pcall_err(exec_lua, 'return vim.b[BUF][0].testing')) + matches( + [[attempt to index .* nil value]], + pcall_err(exec_lua, 'return vim.b[BUF][0].testing') + ) eq({ hello = 'world' }, fn.luaeval 'vim.b.to_delete') exec_lua [[ @@ -174,12 +177,18 @@ describe('lua stdlib', function() exec_lua(function() local counter = 0 - local function add_counter() counter = counter + 1 end - local function get_counter() return counter end + local function add_counter() + counter = counter + 1 + end + local function get_counter() + return counter + end vim.b.AddCounter = add_counter vim.b.GetCounter = get_counter - vim.b.fn = {add = add_counter, get = get_counter} - vim.b.AddParens = function(s) return '(' .. s .. ')' end + vim.b.fn = { add = add_counter, get = get_counter } + vim.b.AddParens = function(s) + return '(' .. s .. ')' + end end) eq(0, eval('b:GetCounter()')) @@ -199,12 +208,18 @@ describe('lua stdlib', function() exec_lua(function() local counter = 0 - local function add_counter() counter = counter + 1 end - local function get_counter() return counter end + local function add_counter() + counter = counter + 1 + end + local function get_counter() + return counter + end vim.api.nvim_buf_set_var(0, 'AddCounter', add_counter) vim.api.nvim_buf_set_var(0, 'GetCounter', get_counter) - vim.api.nvim_buf_set_var(0, 'fn', {add = add_counter, get = get_counter}) - vim.api.nvim_buf_set_var(0, 'AddParens', function(s) return '(' .. s .. ')' end) + vim.api.nvim_buf_set_var(0, 'fn', { add = add_counter, get = get_counter }) + vim.api.nvim_buf_set_var(0, 'AddParens', function(s) + return '(' .. s .. ')' + end) end) eq(0, eval('b:GetCounter()')) @@ -261,7 +276,10 @@ describe('lua stdlib', function() eq(NIL, fn.luaeval 'vim.w.nonexistent') eq(NIL, fn.luaeval 'vim.w[WIN].nonexistent') - matches([[attempt to index .* nil value]], pcall_err(exec_lua, 'return vim.w[WIN][0].testing')) + matches( + [[attempt to index .* nil value]], + pcall_err(exec_lua, 'return vim.w[WIN][0].testing') + ) eq({ hello = 'world' }, fn.luaeval 'vim.w.to_delete') exec_lua [[ @@ -456,7 +474,10 @@ describe('lua stdlib', function() eq({ 'one', 'two' }, eval('v:oldfiles')) exec_lua([[vim.v.oldfiles = {}]]) eq({}, eval('v:oldfiles')) - eq('Setting v:oldfiles to value with wrong type', pcall_err(exec_lua, [[vim.v.oldfiles = 'a']])) + eq( + 'Setting v:oldfiles to value with wrong type', + pcall_err(exec_lua, [[vim.v.oldfiles = 'a']]) + ) eq({}, eval('v:oldfiles')) feed('i foo foo foo0/foo') @@ -528,6 +549,13 @@ describe('lua stdlib', function() matches('Expected Lua string$', pcall_err(exec_lua, 'return vim.bo[0][0].autoread')) matches('Invalid buffer id: %-1$', pcall_err(exec_lua, 'return vim.bo[-1].filetype')) end) + + it('can set function values', function() + eq_exec_lua('v:lua.vim._func_opts.b_1_tagfunc', function() + vim.bo.tagfunc = function() end + return vim.bo.tagfunc + end) + end) end) describe('vim.wo', function() @@ -567,7 +595,26 @@ describe('lua stdlib', function() it('errors', function() matches('only bufnr=0 is supported', pcall_err(exec_lua, 'vim.wo[0][10].signcolumn = "no"')) - matches('only bufnr=0 is supported', pcall_err(exec_lua, 'local a = vim.wo[0][10].signcolumn')) + matches( + 'only bufnr=0 is supported', + pcall_err(exec_lua, 'local a = vim.wo[0][10].signcolumn') + ) + end) + + it('can set function values', function() + eq_exec_lua({ '%!v:lua.vim._func_opts.w_1000_statusline()', 'HELLO' }, function() + vim.wo.statusline = function() + return 'HELLO' + end + return { vim.wo.statusline, vim.api.nvim_eval_statusline(vim.wo.statusline, {}).str } + end) + + eq_exec_lua('v:lua.vim._func_opts.w_1000_1_foldexpr()', function() + vim.wo[0][0].foldexpr = function() + return 'HELLO' + end + return vim.wo[0].foldexpr + end) end) end) @@ -1073,7 +1120,10 @@ describe('lua stdlib', function() ) matches( "Invalid option type 'function' for 'listchars'", - pcall_err(exec_lua, [[vim.opt.listchars = function() return "eol:~,space:.,tab:>~" end]]) + pcall_err( + exec_lua, + [[vim.opt.listchars = function() return "eol:~,space:.,tab:>~" end]] + ) ) end) @@ -1198,5 +1248,4 @@ describe('lua stdlib', function() end) end) end) - end) diff --git a/test/functional/testnvim/exec_lua.lua b/test/functional/testnvim/exec_lua.lua index ddd9905ce7..4a55cb2eb3 100644 --- a/test/functional/testnvim/exec_lua.lua +++ b/test/functional/testnvim/exec_lua.lua @@ -86,7 +86,7 @@ function M.handler(bytecode, upvalues, ...) set_upvalues(f, upvalues) -- Run in pcall so we can return any print messages - local ret = { pcall(f, ...) } --- @type any[] + local ret = { xpcall(f, debug.traceback, ...) } --- @type any[] _G.print = orig_print