From 7c4845ff463b63324c4d45f03fdd58b7f70e54ba Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Thu, 30 Apr 2026 02:23:28 +0200 Subject: [PATCH] fix(ui): z=, tselect with async vim.ui.select Problem: After 55ceb31, z= and tselect don't work if `vim.ui.select` is an async provider (especially terminal buffers). Solution: Drop the `vim.wait()` approach, use an async approach. fix #39506 --- runtime/lua/vim/_core/ex_cmd.lua | 3 +- runtime/lua/vim/_core/spell.lua | 18 +- runtime/lua/vim/_core/tag.lua | 31 ++- src/nvim/ex_docmd.c | 3 +- src/nvim/help.c | 2 +- src/nvim/lua/executor.c | 33 +-- src/nvim/normal.c | 2 +- src/nvim/spellsuggest.c | 20 +- src/nvim/tag.c | 48 ++-- test/functional/api/command_spec.lua | 10 + test/functional/lua/ui_select_spec.lua | 290 ++++++++++--------------- 11 files changed, 211 insertions(+), 249 deletions(-) diff --git a/runtime/lua/vim/_core/ex_cmd.lua b/runtime/lua/vim/_core/ex_cmd.lua index c877231ea5..a1a7ddb07d 100644 --- a/runtime/lua/vim/_core/ex_cmd.lua +++ b/runtime/lua/vim/_core/ex_cmd.lua @@ -6,7 +6,8 @@ local uv = vim.uv local N_ = vim.fn.gettext --- Parsed ex command arguments for builtin commands, passed from C via `nlua_call_excmd`. ---- Inherits fields from user command args: args, bang, line1, line2, range, count, reg, smods. +--- Inherits fields from user command args: name, args, bang, line1, line2, range, count, reg, smods. +--- Note: For builtin commands `name` is the canonical command name. --- @class vim._core.ExCmdArgs : vim.api.keyset.create_user_command.command_args local M = {} diff --git a/runtime/lua/vim/_core/spell.lua b/runtime/lua/vim/_core/spell.lua index 11ad39d064..888c28da32 100644 --- a/runtime/lua/vim/_core/spell.lua +++ b/runtime/lua/vim/_core/spell.lua @@ -1,4 +1,3 @@ -local select_blocking = require('vim._core.ui').select_blocking local N_ = vim.fn.gettext local M = {} @@ -10,13 +9,15 @@ local M = {} --- @field altscore? integer Secondary score (only set when 'spellsuggest' contains "double" or "best"). --- @field salscore? boolean True if the score came from sound-alike comparison (only set alongside `altscore`). ---- Implements `spell_suggest()` (`z=`) vim.ui.select(). +--- Implements `spell_suggest()` (`z=`) via vim.ui.select(). +--- +--- async: returns immediately, the chosen suggestion is applied later +--- by re-running `:normal! [idx]z=` from `on_choice`. --- --- @param items vim._core.spell.Suggestion[] --- @param bad string The misspelled word being replaced. ---- @return integer? # Selected item (1-indexed), or nil if cancelled. function M.select_suggest(items, bad) - return select_blocking(items, { + vim.ui.select(items, { prompt = N_('Change "%s" to:'):format(bad), kind = 'spell', format_item = function(s) @@ -29,7 +30,14 @@ function M.select_suggest(items, bad) end return ('"%s"%s%s'):format(s.word, extra, score) end, - }) + }, function(_, idx) + if not idx then + return + end + -- Queue ":normal! [idx]z=" as user input, so the recursive spell_suggest runs via the normal + -- input-dispatch loop. Using vim.schedule + vim.cmd can hang bc of "Press ENTER". + vim.fn.feedkeys(vim.keycode(('normal! %dz='):format(idx)), 'in') + end) end return M diff --git a/runtime/lua/vim/_core/tag.lua b/runtime/lua/vim/_core/tag.lua index 12bf4c5eaa..3c0c2f544f 100644 --- a/runtime/lua/vim/_core/tag.lua +++ b/runtime/lua/vim/_core/tag.lua @@ -1,4 +1,3 @@ -local select_blocking = require('vim._core.ui').select_blocking local N_ = vim.fn.gettext local M = {} @@ -13,31 +12,47 @@ local M = {} --- Implements `do_tag()` (`:tselect`, ambiguous `:tag`, …) via vim.ui.select(). --- ---- @param items vim._core.tag.Match[] One per matching tag. ---- @return integer? # Selected item (1-indexed), or nil if cancelled. -function M.select_tag(items) +--- async: returns immediately, the chosen tag is applied later by re-running +--- `:[mods] [idx]tag {tagname}` (or `stag`) from `on_choice`. +--- +--- @param eap vim._core.ExCmdArgs Original :tselect/:stselect/… invocation. +--- @param extra { items: vim._core.tag.Match[], tagname: string } +function M.select_tag(eap, extra) + local items, tagname = extra.items, extra.tagname + -- :stag/:stselect/:stjump need a split when re-invoked. + local stag = eap.name:sub(1, 1) == 's' + -- `eap.mods` is the raw modifier string (e.g. ":vert silent"). + local mods_str = eap.mods ~= '' and (eap.mods .. ' ') or '' + local taglen = 18 for _, m in ipairs(items) do taglen = math.max(taglen, vim.fn.strdisplaywidth(m.tag) + 2) end - return select_blocking(items, { + vim.ui.select(items, { prompt = N_('Type number and (q or empty cancels):'), kind = 'tag', format_item = function(m) local marker = m.cur and '>' or ' ' local kind = m.kind or '' - local extra = m.extra and (' ' .. m.extra) or '' return ('%s %s %-4s %-' .. taglen .. 's %s%s'):format( marker, m.pri, kind, m.tag, m.file, - extra + m.extra and (' ' .. m.extra) or '' ) end, - }) + }, function(_, idx) + if not idx then + return + end + -- Queue ":[mods] [idx](s)tag {tagname}" as user input, so the recursive do_tag runs via the + -- normal input-dispatch loop. Using vim.schedule + vim.cmd can hang bc of "Press ENTER". + local cmd = stag and 'stag' or 'tag' + vim.fn.feedkeys(vim.keycode(('%s%d%s %s'):format(mods_str, idx, cmd, tagname)), 'in') + end) end return M diff --git a/src/nvim/ex_docmd.c b/src/nvim/ex_docmd.c index 373e111eec..2ce80902ea 100644 --- a/src/nvim/ex_docmd.c +++ b/src/nvim/ex_docmd.c @@ -7639,8 +7639,7 @@ static void ex_tag_cmd(exarg_T *eap, const char *name) cmd = DT_LTAG; } - do_tag(eap->arg, cmd, eap->addr_count > 0 ? (int)eap->line2 : 1, - eap->forceit, true); + do_tag(eap, eap->arg, cmd, eap->addr_count > 0 ? (int)eap->line2 : 1, eap->forceit, true); } enum { diff --git a/src/nvim/help.c b/src/nvim/help.c index 78e2690772..e728fac88a 100644 --- a/src/nvim/help.c +++ b/src/nvim/help.c @@ -212,7 +212,7 @@ void ex_help(exarg_T *eap) // It is needed for do_tag top open folds under the cursor. KeyTyped = old_KeyTyped; - do_tag(tag, DT_HELP, 1, false, true); + do_tag(NULL, tag, DT_HELP, 1, false, true); // Delete the empty buffer if we're not using it. Careful: autocommands // may have jumped to another window, check that the buffer is not in a diff --git a/src/nvim/lua/executor.c b/src/nvim/lua/executor.c index 468c706014..7078a0650e 100644 --- a/src/nvim/lua/executor.c +++ b/src/nvim/lua/executor.c @@ -34,6 +34,7 @@ #include "nvim/event/time.h" #include "nvim/ex_cmds.h" #include "nvim/ex_cmds_defs.h" +#include "nvim/ex_docmd.h" #include "nvim/ex_getln.h" #include "nvim/garray.h" #include "nvim/garray_defs.h" @@ -180,6 +181,25 @@ static void nlua_push_cmdmod(lua_State *lstate, const cmdmod_T *cmod) /// Pushes common exarg_T fields (bang, line1, line2, …) onto a table at the top of the stack. static void nlua_push_eap(lua_State *lstate, exarg_T *eap, const cmdmod_T *cmod) { + // Canonical name (for builtin cmds); for usercmds `nlua_do_ucmd` sets "name" to the user-defined name. + if (!IS_USER_CMDIDX(eap->cmdidx) && eap->cmdidx < CMD_SIZE) { + lua_pushstring(lstate, get_command_name(NULL, eap->cmdidx)); + lua_setfield(lstate, -2, "name"); + } + + // Modifier string (e.g. ":vert silent"). Same content as `nvim_parse_cmd().mods`. + // Useful when forwarding the command verbatim, e.g. `feedkeys(''..eap.mods..' …')`. + // + // The size is chosen empirically to hold every modifier with room to spare; bump if more are added. + char mods_buf[200] = { 0 }; + uc_mods(mods_buf, cmod, false); + lua_pushstring(lstate, mods_buf); + lua_setfield(lstate, -2, "mods"); + + // Structured form of `mods`. + nlua_push_cmdmod(lstate, cmod); + lua_setfield(lstate, -2, "smods"); + lua_pushstring(lstate, eap->arg); lua_setfield(lstate, -2, "args"); @@ -211,9 +231,6 @@ static void nlua_push_eap(lua_State *lstate, exarg_T *eap, const cmdmod_T *cmod) } lua_setfield(lstate, -2, "fargs"); } - - nlua_push_cmdmod(lstate, cmod); - lua_setfield(lstate, -2, "smods"); } #if __has_feature(address_sanitizer) @@ -2371,16 +2388,6 @@ int nlua_do_ucmd(ucmd_T *cmd, exarg_T *eap, bool preview) lua_pushstring(lstate, nargs); lua_setfield(lstate, -2, "nargs"); - // User commands also get a string "mods" field (in addition to "smods" from nlua_push_eap). - // - // The size of this buffer is chosen empirically to be large enough to hold - // every possible modifier (with room to spare). If the list of possible - // modifiers grows this may need to be updated. - char buf[200] = { 0 }; - uc_mods(buf, &cmdmod, false); - lua_pushstring(lstate, buf); - lua_setfield(lstate, -2, "mods"); - if (preview) { lua_pushinteger(lstate, cmdpreview_get_ns()); diff --git a/src/nvim/normal.c b/src/nvim/normal.c index aadbebbd46..11ba0e76dc 100644 --- a/src/nvim/normal.c +++ b/src/nvim/normal.c @@ -3622,7 +3622,7 @@ bool get_visual_text(cmdarg_T *cap, char **pp, size_t *lenp) static void nv_tagpop(cmdarg_T *cap) { if (!checkclearopq(cap->oap)) { - do_tag("", DT_POP, cap->count1, false, true); + do_tag(NULL, "", DT_POP, cap->count1, false, true); } } diff --git a/src/nvim/spellsuggest.c b/src/nvim/spellsuggest.c index 5bcb6a03b7..7cf2e05810 100644 --- a/src/nvim/spellsuggest.c +++ b/src/nvim/spellsuggest.c @@ -433,10 +433,8 @@ int spell_check_sps(void) return OK; } -/// Let the user pick a spell suggestion. Delegates to `vim.ui.select()`. -/// -/// @return 1-based index of the chosen suggestion, or 0 if cancelled. -static int select_spell_suggestion(suginfo_T *sug) +/// Let the user pick a spell suggestion. Delegates to (async) `vim.ui.select()`. +static void select_spell_suggestion(suginfo_T *sug) { typval_T items_tv; tv_list_alloc_ret(&items_tv, sug->su_ga.ga_len); @@ -481,18 +479,10 @@ static int select_spell_suggestion(suginfo_T *sug) typval_T bad_tv = { .v_type = VAR_STRING, .vval.v_string = xstrnsave(sug->su_badptr, (size_t)sug->su_badlen) }; typval_T lua_args[] = { items_tv, bad_tv, { .v_type = VAR_UNKNOWN } }; - typval_T rettv = TV_INITIAL_VALUE; - nlua_call_vimfn("vim._core.spell", "select_suggest", lua_args, &rettv); - - int idx = 0; - if (rettv.v_type == VAR_NUMBER) { - idx = (int)rettv.vval.v_number; - } + nlua_call_vimfn("vim._core.spell", "select_suggest", lua_args, NULL); tv_clear(&items_tv); tv_clear(&bad_tv); - tv_clear(&rettv); - return idx; } /// "z=": Find badly spelled word under or after the cursor. @@ -583,8 +573,8 @@ void spell_suggest(int count) smsg(0, _("Only %" PRId64 " suggestions"), (int64_t)sug.su_ga.ga_len); } } else { - // Ask the user (via vim.ui.select) to pick a suggestion. - selected = select_spell_suggestion(&sug); + // Hand off to (async) vim.ui.select(). + select_spell_suggestion(&sug); lines_left = Rows; // avoid more prompt // don't delay for 'smd' in normal_cmd() diff --git a/src/nvim/tag.c b/src/nvim/tag.c index 26162d534e..068f680c64 100644 --- a/src/nvim/tag.c +++ b/src/nvim/tag.c @@ -283,10 +283,11 @@ void set_buflocal_tfu_callback(buf_T *buf) /// type == DT_LTAG: use location list for displaying tag matches /// type == DT_FREE: free cached matches /// +/// @param eap excmd args (forwarded to Lua); may be NULL when the caller is not an excmd (e.g. ``). /// @param tag tag (pattern) to jump to /// @param forceit :ta with ! /// @param verbose print "tag not found" message -void do_tag(char *tag, int type, int count, int forceit, bool verbose) +void do_tag(exarg_T *eap, char *tag, int type, int count, int forceit, bool verbose) { taggy_T *tagstack = curwin->w_tagstack; int tagstackidx = curwin->w_tagstackidx; @@ -657,17 +658,13 @@ void do_tag(char *tag, int type, int count, int forceit, bool verbose) // jump to count'th matching tag. cur_match = count > 0 ? count - 1 : 0; } else if (type == DT_SELECT || (type == DT_JUMP && num_matches > 1)) { - // Ask the user (via vim.ui.select) to pick a tag. - int i = select_tag_match(new_tag, use_tagstack, num_matches, matches); - if (i <= 0 || i > num_matches || got_int) { - // no valid choice: don't change anything - if (use_tagstack) { - tagstack[tagstackidx].fmark = saved_fmark; - tagstackidx = prevtagstackidx; - } - break; + // Hand off to (async) vim.ui.select(). Roll back any pending tagstack changes. + select_tag_match(eap, new_tag, use_tagstack, num_matches, matches, name); + if (use_tagstack) { + tagstack[tagstackidx].fmark = saved_fmark; + tagstackidx = prevtagstackidx; } - cur_match = i - 1; + break; } else if (type == DT_LTAG) { if (add_llist_tags(tag, num_matches, matches) == FAIL) { goto end_do_tag; @@ -791,10 +788,9 @@ end_do_tag: xfree(tofree); } -/// Let the user pick from `matches`. Delegates to `vim.ui.select()`. -/// -/// @return 1-based index of the chosen tag, or 0 if cancelled. -static int select_tag_match(bool new_tag, bool use_tagstack, int num_matches, char **matches) +/// Let the user pick from `matches`. Delegates to (async) `vim.ui.select()`. +static void select_tag_match(exarg_T *eap, bool new_tag, bool use_tagstack, int num_matches, + char **matches, const char *name) { taggy_T *tagstack = curwin->w_tagstack; int tagstackidx = curwin->w_tagstackidx; @@ -806,7 +802,7 @@ static int select_tag_match(bool new_tag, bool use_tagstack, int num_matches, ch os_breakcheck(); if (got_int) { tv_clear(&items_tv); - return 0; + return; } tagptrs_T tagp; parse_match(matches[i], &tagp); @@ -830,18 +826,18 @@ static int select_tag_match(bool new_tag, bool use_tagstack, int num_matches, ch tv_list_append_tv(items_tv.vval.v_list, &item); } - typval_T lua_args[] = { items_tv, { .v_type = VAR_UNKNOWN } }; - typval_T rettv = TV_INITIAL_VALUE; - nlua_call_vimfn("vim._core.tag", "select_tag", lua_args, &rettv); + // Pass items + tag name as a dict via the `extra` slot of nlua_call_excmd. Lua decides whether + // to use `:tag` or `:stag` from `eap.name` (e.g. "tselect" vs "stselect"). + dict_T *extra_d = tv_dict_alloc(); + tv_dict_add_list(extra_d, S_LEN("items"), items_tv.vval.v_list); + items_tv.vval.v_list->lv_refcount++; // dict keeps a ref + tv_dict_add_str(extra_d, S_LEN("tagname"), name); + typval_T extra_tv = { .v_type = VAR_DICT, .vval.v_dict = extra_d }; - int idx = 0; - if (rettv.v_type == VAR_NUMBER) { - idx = (int)rettv.vval.v_number; - } + nlua_call_excmd("vim._core.tag", "select_tag", eap, &cmdmod, &extra_tv); tv_clear(&items_tv); - tv_clear(&rettv); - return idx; + tv_clear(&extra_tv); } /// Add the matching tags to the location list for the current @@ -2321,7 +2317,7 @@ void free_tag_stuff(void) { ga_clear_strings(&tag_fnames); if (curwin != NULL) { - do_tag(NULL, DT_FREE, 0, 0, 0); + do_tag(NULL, NULL, DT_FREE, 0, 0, 0); } tag_freematch(); diff --git a/test/functional/api/command_spec.lua b/test/functional/api/command_spec.lua index f02f041b24..e4564dcf17 100644 --- a/test/functional/api/command_spec.lua +++ b/test/functional/api/command_spec.lua @@ -319,6 +319,7 @@ describe('nvim_create_user_command', function() browse = false, confirm = false, emsg_silent = false, + filter = { force = false, pattern = '' }, hide = false, horizontal = false, keepalt = false, @@ -360,6 +361,7 @@ describe('nvim_create_user_command', function() browse = false, confirm = false, emsg_silent = false, + filter = { force = false, pattern = '' }, hide = false, horizontal = false, keepalt = false, @@ -401,6 +403,7 @@ describe('nvim_create_user_command', function() browse = false, confirm = false, emsg_silent = false, + filter = { force = false, pattern = '' }, hide = false, horizontal = false, keepalt = false, @@ -442,6 +445,7 @@ describe('nvim_create_user_command', function() browse = false, confirm = true, emsg_silent = false, + filter = { force = false, pattern = '' }, hide = false, horizontal = true, keepalt = false, @@ -483,6 +487,7 @@ describe('nvim_create_user_command', function() browse = false, confirm = false, emsg_silent = false, + filter = { force = false, pattern = '' }, hide = false, horizontal = false, keepalt = false, @@ -524,6 +529,7 @@ describe('nvim_create_user_command', function() browse = false, confirm = false, emsg_silent = false, + filter = { force = false, pattern = '' }, hide = false, horizontal = false, keepalt = false, @@ -577,6 +583,7 @@ describe('nvim_create_user_command', function() browse = false, confirm = false, emsg_silent = false, + filter = { force = false, pattern = '' }, hide = false, horizontal = false, keepalt = false, @@ -619,6 +626,7 @@ describe('nvim_create_user_command', function() browse = false, confirm = false, emsg_silent = false, + filter = { force = false, pattern = '' }, hide = false, horizontal = false, keepalt = false, @@ -672,6 +680,7 @@ describe('nvim_create_user_command', function() browse = false, confirm = false, emsg_silent = false, + filter = { force = false, pattern = '' }, hide = false, horizontal = false, keepalt = false, @@ -713,6 +722,7 @@ describe('nvim_create_user_command', function() browse = false, confirm = false, emsg_silent = false, + filter = { force = false, pattern = '' }, hide = false, horizontal = false, keepalt = false, diff --git a/test/functional/lua/ui_select_spec.lua b/test/functional/lua/ui_select_spec.lua index 0404ebd163..e41be74a1f 100644 --- a/test/functional/lua/ui_select_spec.lua +++ b/test/functional/lua/ui_select_spec.lua @@ -1,6 +1,7 @@ -- Tests for vim.ui.select(), including integration with builtins (:tselect, z=). local t = require('test.testutil') +local retry = t.retry local n = require('test.functional.testnvim')() local clear = n.clear local exec_lua = n.exec_lua @@ -11,11 +12,10 @@ local write_file = t.write_file before_each(clear) ---- Mock async vim.ui.select impl. Imitates fzf-lua/telescope/snacks: opens ---- a transient floating window, then schedules on_choice to fire on the next ---- event-loop tick (rather than synchronously). +--- Mock async vim.ui.select impl. Imitates fzf-lua/telescope/snacks: opens a transient floating +--- window, then schedules on_choice to fire on the next event-loop tick. --- ---- Sets `_G._captured` so tests can inspect what was passed to vim.ui.select. +--- Sets `_G._captured` so tests can assert the user choice. --- @param pick integer|nil 1-based index to "pick" (nil cancels). local function setup_async_picker(pick) exec_lua(function() @@ -48,6 +48,39 @@ local function setup_async_picker(pick) end, pick) end +--- Mock fzf-lua-style picker: opens a floating window with a *terminal* buffer running a small +--- shell command. When the command exits we treat the user as having "picked" `pick`. +local function setup_term_picker(pick) + exec_lua(function(pick_, prog) + _G._captured = nil + --- @diagnostic disable-next-line: duplicate-set-field + vim.ui.select = function(items, opts, on_choice) + _G._captured = { items = items, opts = opts } + local buf = vim.api.nvim_create_buf(false, true) + local win = vim.api.nvim_open_win(buf, true, { + relative = 'editor', + row = 1, + col = 1, + width = 30, + height = math.min(#items, 5), + }) + vim.fn.jobstart({ prog }, { + term = true, + on_exit = function() + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + if pick_ then + on_choice(items[pick_], pick_) + else + on_choice(nil, nil) + end + end, + }) + end + end, pick, n.testprg('shell-test')) +end + describe('vim.ui.select()', function() it('can select an item', function() local result = exec_lua [[ @@ -83,7 +116,7 @@ describe('vim.ui.select()', function() end) describe('via :tselect', function() - it('passes items and applies the chosen index', function() + local function prepare_test() -- Create dummy source files so the jump succeeds. write_file('XselTagA.c', 'int foo;\n') write_file('XselTagB.c', 'int foo = 1;\n') @@ -98,49 +131,37 @@ describe('vim.ui.select()', function() .. 'foo\tXselTagA.c\t/^int foo;$/;"\tv\n' .. 'foo\tXselTagB.c\t/^int foo = 1;$/;"\tv\n' ) + api.nvim_set_option_value('tags', 'XselTags', {}) + end + + it('passes items, gets user choice', function() + prepare_test() local got = exec_lua(function() - vim.opt.tags = 'XselTags' - local captured ---@type table? + --- @diagnostic disable-next-line: duplicate-set-field vim.ui.select = function(items, opts, on_choice) - captured = { items = items, kind = opts.kind } + _G._captured = { items = items, kind = opts.kind } -- Pick the second match. on_choice(items[2], 2) end vim.cmd('tselect foo') - return { - kind = captured and captured.kind, - nitems = captured and #captured.items, - item1_tag = captured and captured.items[1].tag, - item2_file = captured and captured.items[2].file, - bufname = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(0), ':t'), - } + end) + -- on_choice queues `:[idx]tag` via feedkeys; let typeahead drain. + retry(nil, 1000, function() + eq('XselTagB.c', api.nvim_eval('expand("%:t")')) + end) + got = exec_lua(function() + return _G._captured end) eq('tag', got.kind) - eq(2, got.nitems) - eq('foo', got.item1_tag) - eq('XselTagB.c', got.item2_file) - -- Picking item 2 should land us in XselTagB.c. - eq('XselTagB.c', got.bufname) + eq(2, #got.items) + eq('foo', got.items[1].tag) + eq('XselTagB.c', got.items[2].file) end) - it('keeps the buffer unchanged when the user cancels', function() - write_file('XselTagA.c', 'int foo;\n') - write_file('XselTagB.c', 'int foo = 1;\n') - finally(function() - os.remove('XselTagA.c') - os.remove('XselTagB.c') - os.remove('XselTags') - end) - write_file( - 'XselTags', - '!_TAG_FILE_FORMAT\t2\t/extended format/\n' - .. 'foo\tXselTagA.c\t/^int foo;$/;"\tv\n' - .. 'foo\tXselTagB.c\t/^int foo = 1;$/;"\tv\n' - ) - - api.nvim_set_option_value('tags', 'XselTags', {}) + it('does nothing when the user cancels', function() + prepare_test() local before = api.nvim_buf_get_name(0) exec_lua(function() @@ -152,45 +173,63 @@ describe('vim.ui.select()', function() eq(before, api.nvim_buf_get_name(0)) end) + + it('+ async picker', function() + prepare_test() + + setup_async_picker(2) + exec_lua([[vim.cmd('tselect foo')]]) + retry(nil, 1000, function() + eq('XselTagB.c', api.nvim_eval('expand("%:t")')) + end) + eq('tag', exec_lua([[return _G._captured and _G._captured.opts.kind]])) + end) + + it('+ async terminal-based picker', function() + prepare_test() + + setup_term_picker(2) + exec_lua([[vim.cmd('tselect foo')]]) + retry(nil, 1000, function() + eq('XselTagB.c', api.nvim_eval('expand("%:t")')) + end) + end) end) describe('via z=', function() - it('passes items and applies the chosen suggestion', function() + local function prepare_test() api.nvim_set_option_value('spell', true, {}) api.nvim_set_option_value('spelllang', 'en_us', {}) - api.nvim_buf_set_lines(0, 0, -1, false, { 'helo' }) + end - local got = exec_lua(function() + it('passes items, gets user choice', function() + prepare_test() + + exec_lua(function() vim.cmd('normal! gg0') - local captured ---@type table? + --- @diagnostic disable-next-line: duplicate-set-field vim.ui.select = function(items, opts, on_choice) - captured = { items = items, kind = opts.kind, prompt = opts.prompt } + _G._captured = { items = items, kind = opts.kind, prompt = opts.prompt } -- Pick the first suggestion. on_choice(items[1], 1) end vim.cmd('normal! z=') - return { - kind = captured and captured.kind, - prompt = captured and captured.prompt, - item1_word = captured and captured.items[1].word, - line = vim.api.nvim_buf_get_lines(0, 0, -1, false)[1], - } end) + -- z= delegates to vim.ui.select, see `_core/spell:select_suggest`. on_choice queues + -- `:normal! [idx]z=` via feedkeys; let typeahead drain. + retry(nil, 1000, function() + t.neq('helo', api.nvim_buf_get_lines(0, 0, -1, false)[1]) + end) + local got = exec_lua([[return _G._captured]]) eq('spell', got.kind) - -- prompt should contain the misspelled word t.matches('helo', got.prompt) - -- The first suggestion replaced the bad word. - t.neq('helo', got.line) - eq(got.item1_word, got.line) + eq(got.items[1].word, api.nvim_buf_get_lines(0, 0, -1, false)[1]) end) - it('keeps the word unchanged when the user cancels', function() - api.nvim_set_option_value('spell', true, {}) - api.nvim_set_option_value('spelllang', 'en_us', {}) - - api.nvim_buf_set_lines(0, 0, -1, false, { 'helo' }) + it('does nothing when the user cancels', function() + prepare_test() exec_lua(function() vim.cmd('normal! gg0') @@ -202,130 +241,31 @@ describe('vim.ui.select()', function() eq('helo', api.nvim_buf_get_lines(0, 0, -1, false)[1]) end) - end) - -- The selection step blocks the C caller via vim.wait(). Async pickers - -- (fzf-lua, telescope, snacks, …) open a transient window and call on_choice - -- on a later event-loop tick. These tests exercise the wait+resume path for - -- each integration. If the C caller doesn't allow the picker to repaint or - -- pump events, these will hang or fail. - describe('async picker', function() - it('z= dispatches selection from a deferred callback', function() - api.nvim_set_option_value('spell', true, {}) - api.nvim_set_option_value('spelllang', 'en_us', {}) - api.nvim_buf_set_lines(0, 0, -1, false, { 'helo' }) + it('+ async picker', function() + prepare_test() setup_async_picker(1) - exec_lua(function() - vim.cmd('normal! gg0z=') + exec_lua([[vim.cmd('normal! gg0z=')]]) + retry(nil, 1000, function() + neq('helo', api.nvim_buf_get_lines(0, 0, -1, false)[1]) end) - - neq('helo', api.nvim_buf_get_lines(0, 0, -1, false)[1]) - local kind = exec_lua([[return _G._captured and _G._captured.opts.kind]]) - eq('spell', kind) + eq('spell', exec_lua([[return _G._captured and _G._captured.opts.kind]])) end) - it(':tselect dispatches selection from a deferred callback', function() - write_file('XselTagA.c', 'int foo;\n') - write_file('XselTagB.c', 'int foo = 1;\n') - finally(function() - os.remove('XselTagA.c') - os.remove('XselTagB.c') - os.remove('XselTags') - end) - write_file( - 'XselTags', - '!_TAG_FILE_FORMAT\t2\t/extended format/\n' - .. 'foo\tXselTagA.c\t/^int foo;$/;"\tv\n' - .. 'foo\tXselTagB.c\t/^int foo = 1;$/;"\tv\n' - ) - api.nvim_set_option_value('tags', 'XselTags', {}) - - setup_async_picker(2) - exec_lua(function() - vim.cmd('tselect foo') - end) - - eq('XselTagB.c', api.nvim_eval('expand("%:t")')) - local kind = exec_lua([[return _G._captured and _G._captured.opts.kind]]) - eq('tag', kind) - end) - - --- Mock fzf-lua-style picker: opens a floating window with a *terminal* - --- buffer running a small shell command. When the command exits we treat - --- the user as having "picked" `pick`. This more closely exercises the - --- code paths that block real terminal-based pickers in ex-command - --- contexts (RedrawingDisabled, mode dispatch, terminal_loop, …). - local function setup_term_picker(pick) - exec_lua(function() - _G._captured = nil - --- @diagnostic disable-next-line: duplicate-set-field - vim.ui.select = function(items, opts, on_choice) - _G._captured = { items = items, opts = opts } - local buf = vim.api.nvim_create_buf(false, true) - local win = vim.api.nvim_open_win(buf, true, { - relative = 'editor', - row = 1, - col = 1, - width = 30, - height = math.min(#items, 5), - }) - -- Sleep briefly to mimic an interactive terminal session, then exit. - vim.fn.jobstart({ 'sh', '-c', 'sleep 0.05' }, { - term = true, - on_exit = function() - if vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_close(win, true) - end - if pick then - on_choice(items[pick], pick) - else - on_choice(nil, nil) - end - end, - }) - end - end, pick) - end - - it('z= dispatches selection from a terminal-based picker', function() - api.nvim_set_option_value('spell', true, {}) - api.nvim_set_option_value('spelllang', 'en_us', {}) - api.nvim_buf_set_lines(0, 0, -1, false, { 'helo' }) + it('+ async terminal-based picker', function() + prepare_test() setup_term_picker(1) - exec_lua(function() - vim.cmd('normal! gg0z=') + exec_lua([[vim.cmd('normal! gg0z=')]]) + retry(nil, 1000, function() + neq('helo', api.nvim_buf_get_lines(0, 0, -1, false)[1]) end) - - neq('helo', api.nvim_buf_get_lines(0, 0, -1, false)[1]) end) + end) - it(':tselect dispatches selection from a terminal-based picker', function() - write_file('XselTagA.c', 'int foo;\n') - write_file('XselTagB.c', 'int foo = 1;\n') - finally(function() - os.remove('XselTagA.c') - os.remove('XselTagB.c') - os.remove('XselTags') - end) - write_file( - 'XselTags', - '!_TAG_FILE_FORMAT\t2\t/extended format/\n' - .. 'foo\tXselTagA.c\t/^int foo;$/;"\tv\n' - .. 'foo\tXselTagB.c\t/^int foo = 1;$/;"\tv\n' - ) - api.nvim_set_option_value('tags', 'XselTags', {}) - - setup_term_picker(2) - exec_lua(function() - vim.cmd('tselect foo') - end) - - eq('XselTagB.c', api.nvim_eval('expand("%:t")')) - end) - - it(':browse oldfiles dispatches selection from a deferred callback', function() + describe('via ":browse oldfiles"', function() + it('+ async picker', function() finally(function() os.remove('XselOldA') os.remove('XselOldB') @@ -339,15 +279,11 @@ describe('vim.ui.select()', function() -- v:oldfiles is normally populated via shada; inject directly for the test. vim.v.oldfiles = { cwd_ .. '/XselOldA', cwd_ .. '/XselOldB' } vim.cmd('browse oldfiles') - -- :browse oldfiles is async — wait for on_choice to fire and edit the file. - vim.wait(1000, function() - return vim.fn.expand('%:t') == 'XselOldB' - end) end, cwd) - - eq('XselOldB', api.nvim_eval('expand("%:t")')) - local kind = exec_lua([[return _G._captured and _G._captured.opts.kind]]) - eq('oldfiles', kind) + retry(nil, 1000, function() + eq('XselOldB', api.nvim_eval('expand("%:t")')) + end) + eq('oldfiles', exec_lua([[return _G._captured and _G._captured.opts.kind]])) end) end) end)