diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 02ed7fcb7..38ab31a79 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -33,6 +33,7 @@ Scripting improvements Interactive improvements ------------------------ +- Tab (or any ``complete`` key binding) now prefer to expand wildcards instead of invoking completions, if there is a wildcard in the path component under the cursor (:issue:`954`). - The default command-not-found handler now reports a special error if there is a non-executable file (:issue:`8804`) - ``less`` and other interactive commands would occasionally be stopped when run in a pipeline with fish functions; this has been fixed (:issue:`8699`). - Case-changing autosuggestions generated mid-token now correctly append only the suffix, instead of duplicating the token (:issue:`8820`). diff --git a/src/reader.cpp b/src/reader.cpp index 7056b28fe..0481c5201 100644 --- a/src/reader.cpp +++ b/src/reader.cpp @@ -76,6 +76,7 @@ #include "signal.h" #include "termsize.h" #include "tokenizer.h" +#include "wildcard.h" #include "wutil.h" // IWYU pragma: keep // Name of the variable that tells how long it took, in milliseconds, for the previous @@ -110,6 +111,10 @@ /// more input without repainting. static constexpr size_t READAHEAD_MAX = 256; +/// When tab-completing with a wildcard, we expand the wildcard up to this many results. +/// If expansion would exceed this many results, beep and do nothing. +static const size_t TAB_COMPLETE_WILDCARD_MAX_EXPANSION = 256; + /// A mode for calling the reader_kill function. In this mode, the new string is appended to the /// current contents of the kill buffer. #define KILL_APPEND 0 @@ -731,6 +736,7 @@ class reader_data_t : public std::enable_shared_from_this { /// Access the parser. parser_t &parser() { return *parser_ref; } + const parser_t &parser() const { return *parser_ref; } reader_data_t(std::shared_ptr parser, std::shared_ptr hist, reader_config_t &&conf) @@ -770,6 +776,12 @@ class reader_data_t : public std::enable_shared_from_this { /// Compute completions and update the pager and/or commandline as needed. void compute_and_apply_completions(readline_cmd_t c, readline_loop_state_t &rls); + /// Given that the user is tab-completing a token \p wc whose cursor is at \p pos in the token, + /// try expanding it as a wildcard, populating \p result with the expanded string. + /// \return true to suppress completions (e.g. because we expanded the wildcard, or the user + /// cancelled), false to allow normal completions. + bool try_expand_wildcard(wcstring wc, size_t pos, wcstring *result); + void move_word(editable_line_t *el, bool move_right, bool erase, enum move_word_style_t style, bool newv); @@ -2776,6 +2788,69 @@ void reader_data_t::apply_commandline_state_changes() { } } +bool reader_data_t::try_expand_wildcard(wcstring wc, size_t position, wcstring *result) { + // Hacky from #8593: only expand if there are wildcards in the "current path component." + // Find the "current path component" by looking for an unescaped slash before and after + // our position. + // This is quite naive; for example it mishandles brackets. + auto is_path_sep = [&](size_t where) { + return wc.at(where) == L'/' && count_preceding_backslashes(wc, where) % 2 == 0; + }; + size_t comp_start = position; + while (comp_start > 0 && !is_path_sep(comp_start - 1)) { + comp_start--; + } + size_t comp_end = position; + while (comp_end < wc.size() && !is_path_sep(comp_end)) { + comp_end++; + } + if (!wildcard_has(wc.c_str() + comp_start, comp_end - comp_start)) { + return false; + } + + result->clear(); + + // Have a low limit on the number of matches, otherwise we will overwhelm the command line. + operation_context_t ctx{nullptr, vars(), parser().cancel_checker(), + TAB_COMPLETE_WILDCARD_MAX_EXPANSION}; + // We do wildcards only. + expand_flags_t flags{expand_flag::skip_cmdsubst, expand_flag::skip_variables, + expand_flag::preserve_home_tildes}; + completion_list_t expanded; + expand_result_t ret = expand_string(std::move(wc), &expanded, flags, ctx); + switch (ret.result) { + case expand_result_t::error: + // This may come about if we exceeded the max number of matches. + // Return "success" to suppress normal completions. + flash(); + return true; + case expand_result_t::wildcard_no_match: + // Allow normal completions. + return false; + case expand_result_t::cancel: + // e.g. the user hit control-C. Suppress normal completions. + return true; + case expand_result_t::ok: + break; + } + // Insert all matches (escaped) and a trailing space. + wcstring joined; + for (const auto &match : expanded) { + if (match.flags & COMPLETE_DONT_ESCAPE) { + joined.append(match.completion); + } else { + complete_flags_t tildeflag = + (match.flags & COMPLETE_DONT_ESCAPE_TILDES) ? ESCAPE_NO_TILDE : 0; + joined.append( + escape_string(match.completion, ESCAPE_ALL | ESCAPE_NO_QUOTED | tildeflag)); + } + joined.push_back(L' '); + } + + *result = std::move(joined); + return true; +} + void reader_data_t::compute_and_apply_completions(readline_cmd_t c, readline_loop_state_t &rls) { assert((c == readline_cmd_t::complete || c == readline_cmd_t::complete_and_search) && "Invalid command"); @@ -2795,17 +2870,31 @@ void reader_data_t::compute_and_apply_completions(readline_cmd_t c, readline_loo // completions - stuff happening outside of it is not interesting. const wchar_t *cmdsub_begin, *cmdsub_end; parse_util_cmdsubst_extent(buff, el->position(), &cmdsub_begin, &cmdsub_end); + size_t position_in_cmdsub = el->position() - (cmdsub_begin - buff); // Figure out the extent of the token within the command substitution. Note we // pass cmdsub_begin here, not buff. const wchar_t *token_begin, *token_end; - parse_util_token_extent(cmdsub_begin, el->position() - (cmdsub_begin - buff), &token_begin, - &token_end, nullptr, nullptr); + parse_util_token_extent(cmdsub_begin, position_in_cmdsub, &token_begin, &token_end, nullptr, + nullptr); + size_t position_in_token = position_in_cmdsub - (token_begin - cmdsub_begin); // Hack: the token may extend past the end of the command substitution, e.g. in // (echo foo) the last token is 'foo)'. Don't let that happen. if (token_end > cmdsub_end) token_end = cmdsub_end; + // Check if we have a wildcard within this string; if so we first attempt to expand the + // wildcard; if that succeeds we don't then apply user completions (#8593). + wcstring wc_expanded; + if (try_expand_wildcard(wcstring(token_begin, token_end), position_in_token, &wc_expanded)) { + rls.comp.clear(); + rls.complete_did_insert = false; + size_t tok_off = static_cast(token_begin - buff); + size_t tok_len = static_cast(token_end - token_begin); + el->push_edit(edit_t{tok_off, tok_len, std::move(wc_expanded)}); + return; + } + // Construct a copy of the string from the beginning of the command substitution // up to the end of the token we're completing. const wcstring buffcpy = wcstring(cmdsub_begin, token_end); @@ -3544,8 +3633,8 @@ void reader_data_t::handle_readline_command(readline_cmd_t c, readline_loop_stat break; } - auto move_style = - (c != rl::backward_bigword) ? move_word_style_punctuation : move_word_style_whitespace; + auto move_style = (c != rl::backward_bigword) ? move_word_style_punctuation + : move_word_style_whitespace; move_word(active_edit_line(), MOVE_DIR_LEFT, false /* do not erase */, move_style, false); break; @@ -3562,8 +3651,8 @@ void reader_data_t::handle_readline_command(readline_cmd_t c, readline_loop_stat break; } - auto move_style = - (c != rl::forward_bigword) ? move_word_style_punctuation : move_word_style_whitespace; + auto move_style = (c != rl::forward_bigword) ? move_word_style_punctuation + : move_word_style_whitespace; editable_line_t *el = active_edit_line(); if (el->position() < el->size()) { move_word(el, MOVE_DIR_RIGHT, false /* do not erase */, move_style, false); diff --git a/tests/pexpects/wildcard_tab.py b/tests/pexpects/wildcard_tab.py new file mode 100644 index 000000000..f52f50c2c --- /dev/null +++ b/tests/pexpects/wildcard_tab.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +import signal +from pexpect_helper import SpawnedProc + +sp = SpawnedProc() +send, sendline, sleep, expect_prompt, expect_re, expect_str = ( + sp.send, + sp.sendline, + sp.sleep, + sp.expect_prompt, + sp.expect_re, + sp.expect_str, +) + +expect_prompt() + +# Exclam to clear the commandline. +sendline(r"bind ! 'commandline \'\''") +expect_prompt() + +# A do-nothing function to ensure we don't inherit weird completions. +sendline(r"function foo; end") +expect_prompt() + +sendline(r"cd (mktemp -d)") +expect_prompt() + +# Helper function that sets the commandline to a glob, +# optionally moves the cursor back, tab completes, and then clears the commandline. +def tab_expand_glob(input, expected, move_cursor_back=0): + send(input) + if move_cursor_back > 0: + send("\x1b[D" * move_cursor_back) + expect_str(input) + sleep(0.1) + send("\t") + expect_str(expected) + send(r"!") # clears the commandline + + +# Don't report tab_expand_glob as the callsite since it is a helper. +tab_expand_glob.callsite_skip = True + +sendline(r"touch aaa1 aaa2 aaa3") +expect_prompt() + +tab_expand_glob(r"cat *", r"cat aaa1 aaa2 aaa3") +tab_expand_glob(r"cat *2", r"cat aaa2") + +# Globs that fail to expand are left alone. +tab_expand_glob(r"cat qqq*", r"cat qqq*") + +# Special characters in expanded globs are properly escaped. +sendline(r"touch bb\*bbQ cc\;ccQ") +expect_prompt() +tab_expand_glob(r"cat *Q", r"cat bb\*bbQ cc\;ccQ") + +# Cases from #8593. +sendline(r"rm -Rf *; touch README.rst") +expect_prompt() +tab_expand_glob(r"cat R*", r"cat README.rst") + +# Glob fails, so offer completion. +tab_expand_glob(r"cat *.r", r"cat *.rst") +tab_expand_glob(r"cat *.rst", r"cat README.rst") + +sendline(r"mkdir benchmarks && mkdir benchmarks/somedir && touch benchmarks/somefile") +expect_prompt() + +tab_expand_glob(r"echo benchmarks/*", r"echo benchmarks/somedir benchmarks/somefile") + +# Trailing slash suppresses files. +# Note we move the cursor backwards one, to right after the glob. +tab_expand_glob(r"echo benchmarks/*/", r"echo benchmarks/somedir/", 1) + +# Glob fails so it tries completions which also fails. +# This happens whether the cursor is at the end, or just after the glob. +tab_expand_glob(r"echo ben*/nomatch", r"echo ben*/nomatch") +tab_expand_glob(r"echo ben*/nomatch", r"echo ben*/nomatch", len("/nomatch")) + +# Glob fails so it tries completions which succeeds. +tab_expand_glob(r"echo ben*/somed", r"echo ben*/somedir") +tab_expand_glob(r"echo ben*/somed", r"echo ben*/somedir", len("/somed")) + +# No glob in "current path component," offer completions. +tab_expand_glob(r"echo {benchmarks/*/,benchm}a", r"echo {benchmarks/*/,benchm}arks/") + +# Test undo and redo. +# "<" and ">" to undo and redo respectively. +sendline(r"bind \< undo; bind \> redo") +expect_prompt() + +send(r"echo benchmarks/*") +sleep(0.1) +send("\t") +expect_str(r"echo benchmarks/somedir benchmarks/somefile") + +# Undo un-expands the command. +send(r"<") +expect_str(r"echo benchmarks/*") + +# Redo re-expands it. +send(r">") +expect_str(r"echo benchmarks/somedir benchmarks/somefile")