Expand wildcards on tab

Prior to this change, if you tab-completed a token with a wildcard (glob), we
would invoke ordinary completions. Instead, expand the wildcard, replacing
the wildcard with the result of expansions. If the wildcard fails to expand,
flash the command line to signal an error and do not modify it.

Example:

    > touch file(seq 4)
    > echo file*<tab>

becomes:

    > echo file1 file2 file3 file4

whereas before the tab would have just added a space.

Some things to note:

1. If the expansion would produce more than 256 items, we flash the command
line and do nothing, since it would make the commandline overfull.

2. The wildcard token can be brought back through Undo (ctrl-Z).

3. This only kicks in if the wildcard is in the "path component
   containing the cursor." If the wildcard is in a previous component,
   we continue using completions as normal.

Fixes #954.
This commit is contained in:
ridiculousfish 2021-11-27 14:38:31 -08:00
parent 1023d322e5
commit 143757e8c6
3 changed files with 200 additions and 6 deletions

View File

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

View File

@ -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<reader_data_t> {
/// Access the parser.
parser_t &parser() { return *parser_ref; }
const parser_t &parser() const { return *parser_ref; }
reader_data_t(std::shared_ptr<parser_t> parser, std::shared_ptr<history_t> hist,
reader_config_t &&conf)
@ -770,6 +776,12 @@ class reader_data_t : public std::enable_shared_from_this<reader_data_t> {
/// 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<size_t>(token_begin - buff);
size_t tok_len = static_cast<size_t>(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);

View File

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