diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index af47bbe908..47b6fa6d55 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -216,10 +216,11 @@ TUI UI -• |:tselect| delegates to |vim.ui.select()| instead of a bespoke internal - selection routine. -• |z=| (spell suggest) delegates to |vim.ui.select()| instead of a bespoke - internal selection routine. +• These builtin "picker" menus delegate to |vim.ui.select()|: + • :browse oldfiles + • |:recover| + • |:tselect| + • |z=| (spell suggest) VIMSCRIPT diff --git a/runtime/lua/vim/_core/ex_cmd.lua b/runtime/lua/vim/_core/ex_cmd.lua index 81232abf74..a1a7ddb07d 100644 --- a/runtime/lua/vim/_core/ex_cmd.lua +++ b/runtime/lua/vim/_core/ex_cmd.lua @@ -6,11 +6,25 @@ 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 = {} +--- Apply the `:filter[!] /pattern/` modifier to a single message. See also `message_filtered()`. +--- +--- @param filter vim.api.keyset.cmd_mods_filter ":filter" mod. +--- @param msg string Message to test. +--- @return boolean # True if `msg` should be skipped (not displayed). +function M.filter(filter, msg) + if not filter or filter.pattern == '' then + return false + end + local match = vim.regex(filter.pattern):match_str(msg) ~= nil + return match == filter.force +end + --- @param msg string local function echo_err(msg) api.nvim_echo({ { msg } }, true, { err = true }) @@ -266,4 +280,43 @@ function M.ex_uptime() api.nvim_echo({ { N_('Up %s'):format(uptime_display) } }, true, {}) end +--- `:oldfiles` and `:browse oldfiles`. Lists v:oldfiles (plain `:oldfiles`) or shows (async) +--- vim.ui.select() picker (`:browse oldfiles`) and edits the chosen file. +--- @param eap vim._core.ExCmdArgs +function M.ex_oldfiles(eap) + local files = vim.v.oldfiles + if not files or #files == 0 then + api.nvim_echo({ { N_('No old files') } }, false, {}) + return + end + + if eap.smods.browse then + vim.ui.select(files, { + prompt = N_('Select an oldfile:'), + kind = 'oldfiles', + }, function(_, idx) + if idx then + api.nvim_cmd({ + cmd = 'edit', + args = { vim.fn.expand(files[idx]) }, + magic = { file = false, bar = true }, -- May contain '%' (e.g. swapfiles), don't expand. + }, {}) + end + end) + return + end + + -- `:oldfiles`: list the entries. Honor `:filter /pat/[!]` per entry. + local lines = {} ---@type [string][] + for i, f in ipairs(files) do + if not M.filter(eap.smods.filter, f) then + lines[#lines + 1] = { ('%d: %s\n'):format(i, f) } + end + end + if #lines == 0 then + return + end + api.nvim_echo(lines, false, {}) +end + return M diff --git a/runtime/lua/vim/_core/spell.lua b/runtime/lua/vim/_core/spell.lua index d6bdc010a3..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,14 +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`). ---- Called from `spell_suggest()` (`z=`) to let the user pick from `items` via ---- |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? # 1-based index of the chosen suggestion, or nil if cancelled. -function M.suggest_select(items, bad) - return select_blocking(items, { +function M.select_suggest(items, bad) + vim.ui.select(items, { prompt = N_('Change "%s" to:'):format(bad), kind = 'spell', format_item = function(s) @@ -30,7 +30,14 @@ function M.suggest_select(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/swapfile.lua b/runtime/lua/vim/_core/swapfile.lua new file mode 100644 index 0000000000..7e3bee993f --- /dev/null +++ b/runtime/lua/vim/_core/swapfile.lua @@ -0,0 +1,86 @@ +local api = vim.api +local N_ = vim.fn.gettext + +local M = {} + +--- Renders a swap file as a multi-line block: +--- ``` +--- %home%foo%bar%README.md.swl +--- dated: Thu Apr 23 17:25:52 2026 +--- file name: ~foo/bar/README.md +--- modified: no +--- user name: justin host name: minime +--- process ID: 10521 (STILL RUNNING) +--- ``` +--- @param path string +--- @return string +local function format_swap(path) + local info = vim.fn.swapinfo(path) + local mtime = info.mtime and vim.fn.strftime('%a %b %d %H:%M:%S %Y', info.mtime) or '?' + local lines = { + vim.fs.basename(path), + (' dated: %s'):format(mtime), + } + if info.error then + lines[#lines + 1] = (' [%s]'):format(info.error) + else + lines[#lines + 1] = (' file name: %s'):format( + info.fname == '' and '[No Name]' or info.fname + ) + lines[#lines + 1] = (' modified: %s'):format(info.dirty == 1 and 'YES' or 'no') + if info.user ~= '' or info.host ~= '' then + local parts = {} ---@type string[] + if info.user ~= '' then + parts[#parts + 1] = ('user name: %s'):format(info.user) + end + if info.host ~= '' then + parts[#parts + 1] = ('host name: %s'):format(info.host) + end + lines[#lines + 1] = (' %s'):format(table.concat(parts, ' ')) + end + if info.pid > 0 then + lines[#lines + 1] = (' process ID: %d (STILL RUNNING)'):format(info.pid) + end + end + return table.concat(lines, '\n') +end + +--- Implements `:recover` (when there are multiple swap files): let the user pick via vim.ui.select(). +--- +--- async: returns immediately, then schedules `:recover {path}` on the chosen swapfile. +--- +--- @param items string[] List of swapfile paths. +function M.select_swap(items) + vim.ui.select(items, { + prompt = N_('Enter number of swap file to use (q or empty cancels):'), + kind = 'swap', + format_item = format_swap, + }, function(_, idx) + if not idx then + return + end + -- Queue ":recover! " as user input, so the recursive recovery runs via the normal + -- input-dispatch loop. Using vim.schedule + vim.cmd can hang bc of "Press ENTER". + vim.fn.feedkeys( + vim.keycode(('recover! %s'):format(vim.fn.fnameescape(items[idx]))), + 'in' + ) + end) +end + +--- Implements `nvim -r` (no arg): list every swapfile found in 'directory'. +--- +--- @param items string[] List of swapfile paths. +function M.list_swaps(items) + local lines = { { N_('Swap files found:') .. '\n' } } ---@type [string][] + if #items == 0 then + lines[#lines + 1] = { ' ' .. N_('-- none --') } + else + for i, path in ipairs(items) do + lines[#lines + 1] = { ('%d. %s\n'):format(i, format_swap(path)) } + end + end + api.nvim_echo(lines, false, {}) +end + +return M diff --git a/runtime/lua/vim/_core/tag.lua b/runtime/lua/vim/_core/tag.lua index 9d6c3cffbd..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 = {} @@ -11,34 +10,49 @@ local M = {} --- @field extra? string --- @field cur boolean True if this is the currently-active tagstack match. ---- Called from `do_tag()` (`:tselect`, ambiguous `:tag`, etc.) to let the user ---- pick from `matches` via |vim.ui.select()|. +--- Implements `do_tag()` (`:tselect`, ambiguous `:tag`, …) via vim.ui.select(). --- ---- @param items vim._core.tag.Match[] One per matching tag. ---- @return integer? # 1-based index of the chosen tag, or nil if cancelled. -function M.select(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/eval/funcs.c b/src/nvim/eval/funcs.c index 20147a48cb..4c8e26dfa8 100644 --- a/src/nvim/eval/funcs.c +++ b/src/nvim/eval/funcs.c @@ -7352,7 +7352,7 @@ static void f_substitute(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) static void f_swapfilelist(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) { tv_list_alloc_ret(rettv, kListLenUnknown); - recover_names(NULL, false, rettv->vval.v_list, 0, NULL); + recover_names(NULL, false, rettv->vval.v_list); } /// "swapinfo(swap_filename)" function diff --git a/src/nvim/ex_cmds.c b/src/nvim/ex_cmds.c index d0d45d399a..1d9c6aac02 100644 --- a/src/nvim/ex_cmds.c +++ b/src/nvim/ex_cmds.c @@ -57,6 +57,7 @@ #include "nvim/highlight_group.h" #include "nvim/indent.h" #include "nvim/input.h" +#include "nvim/lua/executor.h" #include "nvim/macros_defs.h" #include "nvim/main.h" #include "nvim/mark.h" @@ -5033,54 +5034,8 @@ char *skip_vimgrep_pat(char *p, char **s, int *flags) return p; } -/// List v:oldfiles in a nice way. +/// `:oldfiles` (sync) and `:browse oldfiles` (async). void ex_oldfiles(exarg_T *eap) { - list_T *l = get_vim_var_list(VV_OLDFILES); - int nr = 0; - - if (l == NULL) { - msg(_("No old files"), 0); - return; - } - - msg_start(); - msg_scroll = true; - TV_LIST_ITER(l, li, { - if (got_int) { - break; - } - nr++; - const char *fname = tv_get_string(TV_LIST_ITEM_TV(li)); - if (!message_filtered(fname)) { - msg_outnum(nr); - msg_puts(": "); - msg_outtrans(tv_get_string(TV_LIST_ITEM_TV(li)), 0, false); - msg_clr_eos(); - msg_putchar('\n'); - os_breakcheck(); - } - }); - - // Assume "got_int" was set to truncate the listing. - got_int = false; - - // File selection prompt on ":browse oldfiles" - if (cmdmod.cmod_flags & CMOD_BROWSE) { - quit_more = false; - nr = prompt_for_input(NULL, 0, false, NULL); - msg_starthere(); - if (nr > 0 && nr <= tv_list_len(l)) { - const char *const p = tv_list_find_str(l, nr - 1); - if (p == NULL) { - return; - } - char *const s = expand_env_save((char *)p); - eap->arg = s; - eap->cmdidx = CMD_edit; - cmdmod.cmod_flags &= ~CMOD_BROWSE; - do_exedit(eap, NULL); - xfree(s); - } - } + nlua_call_excmd("vim._core.ex_cmd", "ex_oldfiles", eap, &cmdmod, NULL); } 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 4a747848f3..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" @@ -167,11 +168,38 @@ static void nlua_push_cmdmod(lua_State *lstate, const cmdmod_T *cmod) lua_setfield(lstate, -2, "lockmarks"); lua_pushboolean(lstate, cmod->cmod_flags & CMOD_NOSWAPFILE); lua_setfield(lstate, -2, "noswapfile"); + + // ":filter[!] /pattern/" modifier (same shape as `nvim_parse_cmd().mods.filter`). + lua_newtable(lstate); + lua_pushstring(lstate, cmod->cmod_filter_pat ? cmod->cmod_filter_pat : ""); + lua_setfield(lstate, -2, "pattern"); + lua_pushboolean(lstate, cmod->cmod_filter_force); + lua_setfield(lstate, -2, "force"); + lua_setfield(lstate, -2, "filter"); } /// 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"); @@ -203,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) @@ -2363,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/main.c b/src/nvim/main.c index 4cfec6bd41..55cbdd89b0 100644 --- a/src/nvim/main.c +++ b/src/nvim/main.c @@ -511,10 +511,14 @@ int main(int argc, char **argv) // Decide about window layout for diff mode after reading vimrc. set_window_layout(¶ms); - // Recovery mode without a file name: List swap files. - // Uses the 'dir' option, therefore it must be after the initializations. + // "nvim -r" (recovery mode) without a file name: List swap files. if (recoverymode && fname == NULL) { - recover_names(NULL, true, NULL, 0, NULL); + typval_T items_tv; + tv_list_alloc_ret(&items_tv, 0); + recover_names(NULL, false, items_tv.vval.v_list); + typval_T lua_args[] = { items_tv, { .v_type = VAR_UNKNOWN } }; + nlua_call_vimfn("vim._core.swapfile", "list_swaps", lua_args, NULL); + tv_clear(&items_tv); os_exit(0); } diff --git a/src/nvim/memline.c b/src/nvim/memline.c index 4f929977d3..3d80184a1e 100644 --- a/src/nvim/memline.c +++ b/src/nvim/memline.c @@ -65,6 +65,7 @@ #include "nvim/globals.h" #include "nvim/highlight_defs.h" #include "nvim/input.h" +#include "nvim/lua/executor.h" #include "nvim/macros_defs.h" #include "nvim/main.h" #include "nvim/map_defs.h" @@ -789,28 +790,27 @@ void ml_recover(bool checkext) } else { directly = false; - // count the number of matching swapfiles - len = recover_names(fname, false, NULL, 0, NULL); - if (len == 0) { // no swapfiles found + // Enumerate matching swapfiles into items_tv. + typval_T items_tv; + tv_list_alloc_ret(&items_tv, 0); + recover_names(fname, true, items_tv.vval.v_list); + int n_swaps = tv_list_len(items_tv.vval.v_list); + + if (n_swaps == 0) { + tv_clear(&items_tv); semsg(_("E305: No swap file found for %s"), fname); goto theend; } - int i; - if (len == 1) { // one swapfile found, use it - i = 1; - } else { // several swapfiles found, choose - // list the names of the swapfiles - recover_names(fname, true, NULL, 0, NULL); - if (!ui_has(kUIMessages)) { - msg_putchar('\n'); - } - i = prompt_for_input(_("Enter number of swap file to use (0 to quit): "), 0, false, NULL); - if (i < 1 || i > len) { - goto theend; - } + if (n_swaps > 1) { + // Several swapfiles found: prompt (async) via vim.ui.select(). + typval_T lua_args[] = { items_tv, { .v_type = VAR_UNKNOWN } }; + nlua_call_vimfn("vim._core.swapfile", "select_swap", lua_args, NULL); + tv_clear(&items_tv); + goto theend; } - // get the swapfile name that will be used - recover_names(fname, false, NULL, i, &fname_used); + // One swapfile: use it directly. + fname_used = xstrdup(tv_list_first(items_tv.vval.v_list)->li_tv.vval.v_string); + tv_clear(&items_tv); } if (fname_used == NULL) { goto theend; // user chose invalid number. @@ -1274,28 +1274,22 @@ theend: } } -/// Find the names of swapfiles in current directory and the directory given -/// with the 'directory' option. +/// Enumerate swapfiles for `fname` (or for the global swap dir if `fname` is NULL), +/// appending each found path to `ret_list`. /// -/// Used to: -/// - list the swapfiles for "nvim -r" -/// - count the number of swapfiles when recovering -/// - list the swapfiles when recovering -/// - list the swapfiles for swapfilelist() -/// - find the name of the n'th swapfile when recovering +/// Used by `:recover` (`ml_recover()`), `nvim -r`, and `swapfilelist()`. /// -/// @param fname base for swapfile name -/// @param do_list when true, list the swapfile names -/// @param ret_list when not NULL add file names to it -/// @param nr when non-zero, return nr'th swapfile name -/// @param fname_out result when "nr" > 0 -int recover_names(char *fname, bool do_list, list_T *ret_list, int nr, char **fname_out) +/// @param fname base for swapfile name, or NULL to list every swapfile in 'directory'. +/// @param skip_curbuf exclude the current buffer's own active swapfile (used by `:recover`, +/// not by `swapfilelist()`). +/// @param ret_list receives the paths. +void recover_names(char *fname, bool skip_curbuf, list_T *ret_list) + FUNC_ATTR_NONNULL_ARG(3) { int num_names; char *(names[6]); char *tail; char *p; - int file_count = 0; char **files; char *fname_res = NULL; #ifdef HAVE_READLINK @@ -1312,14 +1306,6 @@ int recover_names(char *fname, bool do_list, list_T *ret_list, int nr, char **fn #endif } - msg_ext_skip_flush = true; - if (do_list) { - // use msg() to start the scrolling properly - msg_ext_set_kind("list_cmd"); - msg(_("Swap files found:"), 0); - msg_putchar('\n'); - } - // Do the loop for every directory in 'directory'. // First allocate some memory to put the directory name in. String dir_name; @@ -1375,7 +1361,7 @@ int recover_names(char *fname, bool do_list, list_T *ret_list, int nr, char **fn // When no swapfile found, wildcard expansion might have failed (e.g. // not able to execute the shell). // Try finding a swapfile by simply adding ".swp" to the file name. - if (*dirp == NUL && file_count + num_files == 0 && fname != NULL) { + if (*dirp == NUL && tv_list_len(ret_list) + num_files == 0 && fname != NULL) { char *swapname = modname(fname_res, ".swp", true); if (swapname != NULL) { if (os_path_exists(swapname)) { @@ -1389,10 +1375,9 @@ int recover_names(char *fname, bool do_list, list_T *ret_list, int nr, char **fn } // Remove swapfile name of the current buffer, it must be ignored. - // But keep it for swapfilelist(). - if (curbuf->b_ml.ml_mfp != NULL - && (p = curbuf->b_ml.ml_mfp->mf_fname) != NULL - && ret_list == NULL) { + if (skip_curbuf + && curbuf->b_ml.ml_mfp != NULL + && (p = curbuf->b_ml.ml_mfp->mf_fname) != NULL) { for (int i = 0; i < num_files; i++) { // Do not expand wildcards, on Windows would try to expand // "%tmp%" in "%tmp%file" @@ -1411,50 +1396,9 @@ int recover_names(char *fname, bool do_list, list_T *ret_list, int nr, char **fn } } } - if (nr > 0) { - file_count += num_files; - if (nr <= file_count) { - *fname_out = xstrdup(files[nr - 1 + num_files - file_count]); - dirp = ""; // stop searching - } - } else if (do_list) { - if (dir_name.data[0] == '.' && dir_name.data[1] == NUL) { - if (fname == NULL) { - msg_puts(_(" In current directory:\n")); - } else { - msg_puts(_(" Using specified name:\n")); - } - } else { - msg_puts(_(" In directory ")); - msg_home_replace(dir_name.data); - msg_puts(":\n"); - } - - if (num_files) { - for (int i = 0; i < num_files; i++) { - // print the swapfile name - msg_outnum(++file_count); - msg_puts(". "); - msg_puts(path_tail(files[i])); - msg_putchar('\n'); - StringBuilder msg = KV_INITIAL_VALUE; - kv_resize(msg, IOSIZE); - swapfile_info(files[i], &msg); - bool need_clear = false; - msg_multiline(cbuf_as_string(msg.items, msg.size), 0, false, false, &need_clear); - kv_destroy(msg); - } - } else { - msg_puts(_(" -- none --\n")); - } - ui_flush(); - } else if (ret_list != NULL) { - for (int i = 0; i < num_files; i++) { - String name = concat_fnames(dir_name, cstr_as_string(files[i]), true); - tv_list_append_allocated_string(ret_list, name.data); - } - } else { - file_count += num_files; + for (int i = 0; i < num_files; i++) { + // `files[i]` is already a full path (from `expand_wildcards`). + tv_list_append_allocated_string(ret_list, xstrdup(files[i])); } for (int i = 0; i < num_names; i++) { @@ -1464,9 +1408,7 @@ int recover_names(char *fname, bool do_list, list_T *ret_list, int nr, char **fn FreeWild(num_files, files); } } - msg_ext_skip_flush = false; xfree(dir_name.data); - return file_count; } /// Append the full path to name with path separators made into percent 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 4f1bae9f9b..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", "suggest_select", 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 8d7caac416..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", 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/core/main_spec.lua b/test/functional/core/main_spec.lua index ee301f0675..283bf4c2b5 100644 --- a/test/functional/core/main_spec.lua +++ b/test/functional/core/main_spec.lua @@ -232,6 +232,7 @@ describe('vim._core', function() 'vim._core.shared', 'vim._core.spell', 'vim._core.stringbuffer', + 'vim._core.swapfile', 'vim._core.system', 'vim._core.table', 'vim._core.tag', diff --git a/test/functional/ex_cmds/oldfiles_spec.lua b/test/functional/ex_cmds/oldfiles_spec.lua index e9c5cc180b..63ed19fc77 100644 --- a/test/functional/ex_cmds/oldfiles_spec.lua +++ b/test/functional/ex_cmds/oldfiles_spec.lua @@ -85,6 +85,15 @@ describe(':oldfiles', function() oldfiles = get_oldfiles('filter! file_ oldfiles') eq({ another }, oldfiles) + + -- The original v:oldfiles index is preserved in the output (matches `message_filtered()` behavior). + local v_oldfiles = api.nvim_get_vvar('oldfiles') + local raw = eval([[split(execute('filter file_ oldfiles'), "\n")]]) + for _, line in ipairs(raw) do + local idx, path = line:match('^(%d+):%s+(.+)$') + ok(idx ~= nil, 'numbered', line) + eq(path, v_oldfiles[tonumber(idx)]) + end end) end) diff --git a/test/functional/ex_cmds/swapfile_preserve_recover_spec.lua b/test/functional/ex_cmds/swapfile_preserve_recover_spec.lua index c3dc8fff7c..0e827be33f 100644 --- a/test/functional/ex_cmds/swapfile_preserve_recover_spec.lua +++ b/test/functional/ex_cmds/swapfile_preserve_recover_spec.lua @@ -160,24 +160,34 @@ describe("preserve and (R)ecover with custom 'directory'", function() { content = { { '' } }, pos = 0, - prompt = 'Enter number of swap file to use (0 to quit): ', + -- Default vim.ui.select prompt. + prompt = 'Type number and or click with the mouse (q or empty cancels): ', }, }, condition = function() msg = msg or screen.messages[1] - eq(true, msg.content[1][2]:match('Swap.*none --') ~= nil) - eq('list_cmd', msg.kind) + -- Concatenate all chunks (each chunk is { 'text' } or { hl_id, 'text', 'group' }). + local text = '' + for _, chunk in ipairs(msg.content) do + text = text .. (#chunk >= 2 and chunk[2] or chunk[1]) + end + -- New ui.select-driven prompt; rich info from format_item. + eq(true, text:match('Enter number of swap file to use') ~= nil) + eq(true, text:match('%.swo') ~= nil) + eq(true, text:match('%.swp') ~= nil) + eq(true, text:match('host name:') ~= nil) + eq('confirm', msg.kind) screen.messages = {} end, }) else screen:expect({ any = { - '\nSwap files found:', - '\n In directory ', - vim.pesc('\n1. '), - vim.pesc('\n2. '), - vim.pesc('\nEnter number of swap file to use (0 to quit): ^'), + vim.pesc('Enter number of swap file to use (q or empty cancels):'), + '\n1:.*%.swo', + '\n2:.*%.swp', + 'host name:', + vim.pesc('Type number and or click with the mouse (q or empty cancels): ^'), }, none = vim.pesc('{18:^@}'), }) diff --git a/test/functional/lua/ui_select_spec.lua b/test/functional/lua/ui_select_spec.lua index e073bf95c4..e41be74a1f 100644 --- a/test/functional/lua/ui_select_spec.lua +++ b/test/functional/lua/ui_select_spec.lua @@ -1,15 +1,86 @@ -- 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 local api = n.api local eq = t.eq +local neq = t.neq 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. +--- +--- 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() + _G._captured = nil + --- @diagnostic disable-next-line: duplicate-set-field + vim.ui.select = function(items, opts, on_choice) + _G._captured = { items = items, opts = opts } + -- Open a floating window like a real picker would. + local buf = vim.api.nvim_create_buf(false, true) + local win = vim.api.nvim_open_win(buf, false, { + relative = 'editor', + row = 1, + col = 1, + width = 30, + height = math.min(#items, 5), + }) + _G._captured.win = win + -- Defer the choice so the wait actually has to pump events. + vim.defer_fn(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, 30) + end + 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 [[ @@ -45,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') @@ -60,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() @@ -114,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') @@ -164,5 +241,49 @@ describe('vim.ui.select()', function() eq('helo', api.nvim_buf_get_lines(0, 0, -1, false)[1]) end) + + it('+ async picker', function() + prepare_test() + + setup_async_picker(1) + exec_lua([[vim.cmd('normal! gg0z=')]]) + retry(nil, 1000, function() + neq('helo', api.nvim_buf_get_lines(0, 0, -1, false)[1]) + end) + eq('spell', exec_lua([[return _G._captured and _G._captured.opts.kind]])) + end) + + it('+ async terminal-based picker', function() + prepare_test() + + setup_term_picker(1) + exec_lua([[vim.cmd('normal! gg0z=')]]) + retry(nil, 1000, function() + neq('helo', api.nvim_buf_get_lines(0, 0, -1, false)[1]) + end) + end) + end) + + describe('via ":browse oldfiles"', function() + it('+ async picker', function() + finally(function() + os.remove('XselOldA') + os.remove('XselOldB') + end) + write_file('XselOldA', 'a\n') + write_file('XselOldB', 'b\n') + local cwd = exec_lua([[return vim.uv.cwd()]]) + + setup_async_picker(2) + exec_lua(function(cwd_) + -- v:oldfiles is normally populated via shada; inject directly for the test. + vim.v.oldfiles = { cwd_ .. '/XselOldA', cwd_ .. '/XselOldB' } + vim.cmd('browse oldfiles') + end, cwd) + 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)