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
This commit is contained in:
Justin M. Keyes
2026-04-30 02:23:28 +02:00
parent 18d7dd485b
commit 7c4845ff46
11 changed files with 211 additions and 249 deletions

View File

@@ -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 = {}

View File

@@ -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(('<Cmd>normal! %dz=<CR>'):format(idx)), 'in')
end)
end
return M

View File

@@ -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 <Enter> (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(('<Cmd>%s%d%s %s<CR>'):format(mods_str, idx, cmd, tagname)), 'in')
end)
end
return M

View File

@@ -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 {

View File

@@ -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

View File

@@ -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('<Cmd>'..eap.mods..' …<CR>')`.
//
// 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());

View File

@@ -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);
}
}

View File

@@ -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()

View File

@@ -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. `<C-T>`).
/// @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();

View File

@@ -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,

View File

@@ -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)