Skip to content

Commit

Permalink
Expand wildcards on tab
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
ridiculousfish committed Apr 10, 2022
1 parent 1023d32 commit 143757e
Show file tree
Hide file tree
Showing 3 changed files with 200 additions and 6 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
101 changes: 95 additions & 6 deletions src/reader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
104 changes: 104 additions & 0 deletions tests/pexpects/wildcard_tab.py
Original file line number Diff line number Diff line change
@@ -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")

1 comment on commit 143757e

@mqudsi
Copy link
Contributor

@mqudsi mqudsi commented on 143757e May 10, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only kicks in if the wildcard is in the "path component
containing the cursor."

I was concerned by this because it implied existing behavior would break, but it turns out that everything is great and that the above precondition should read "and there are no completions available"

e.g.

$ touch hello1.txt hello2.txt hello1.log hello2.log
$ mv hello*.<TAB>

This does not expand the wildcard and instead continues to give the desired result (suggesting suffixes that match the pattern, in this case, hello*.log and hello*.txt).

Please sign in to comment.