diff --git a/README.md b/README.md index e32b145..9856eb6 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,8 @@ project, its git branch and a `*` dirty marker: Features: command history (`~/.mgsh_history`), Tab completion (commands, local projects for `cd`/`open`, server repos for `clone`/`show`, branches/tags for `checkout`/`tag`, mirror targets for `pushremote`/`release`, filesystem paths for -`dist`, and shell-style completion after `!`), and colored `list`/`log`/error -output. +`dist`, and shell-style completion after `!` and for aliases that expand to +one), and colored `list`/`log`/error output. The server repository list is fetched once per session on the first Tab that needs it; `rescan` refreshes it (and reloads the configuration). @@ -84,6 +84,18 @@ until the prefix asks for one. < src/myproject > !gre -> grep gresource ``` +An alias that expands to a shell escape completes the same way, because its +arguments end up as shell arguments: + +``` +alias ll '!ls -la' +< src/myproject > ll ma -> ll main +``` + +Only the alias's arguments complete, never its first word — the command is +fixed by the alias body. An alias to a builtin (`alias co 'checkout $1'`) is not +a shell line and is left alone. + Word splitting for completion is by whitespace only; quotes and backslash escapes are left to the shell that runs the line. diff --git a/completion.go b/completion.go index 6e854da..55397ac 100644 --- a/completion.go +++ b/completion.go @@ -9,9 +9,10 @@ import ( "github.com/chzyer/readline" ) -// completer wires up Tab completion. A line starting with '!' is completed the -// way a shell would — executables for the command, paths for its arguments — -// and everything else against the builtin command tree. +// completer wires up Tab completion. A line headed for a shell — a '!' escape, +// or an alias that expands to one — is completed the way a shell would: +// executables for the command, paths for its arguments. Everything else goes to +// the builtin command tree. func completer() readline.AutoCompleter { return &mgshCompleter{builtin: builtinCompleter()} } @@ -23,8 +24,7 @@ func (c *mgshCompleter) Do(line []rune, pos int) ([][]rune, int) { if pos > len(line) { pos = len(line) } - if strings.HasPrefix(strings.TrimLeft(string(line[:pos]), " \t"), "!") { - cands, prefix := shellCandidates(string(line[:pos])) + if cands, prefix, ok := completeShellLine(string(line[:pos])); ok { return runeSuffixes(cands, prefix) } return c.builtin.Do(line, pos) diff --git a/shellcomplete.go b/shellcomplete.go index 19fd282..35fd6b5 100644 --- a/shellcomplete.go +++ b/shellcomplete.go @@ -24,17 +24,55 @@ import ( // installed on the machine running them. var shellCommandList = pathExecutables -// shellCandidates returns the completion candidates for the text to the left of -// the cursor, together with the prefix they replace. text is expected to start -// (after leading blanks) with the '!' that marks a shell escape. -func shellCandidates(text string) (cands []string, prefix string) { - body, ok := strings.CutPrefix(strings.TrimLeft(text, " \t"), "!") +// completeShellLine returns the candidates and the prefix they replace for a +// line that is headed for a shell. ok is false for any other line, which is +// then left to the builtin command tree. +func completeShellLine(typed string) (cands []string, prefix string, ok bool) { + body, allowCommand, ok := shellLine(typed) if !ok { - return nil, "" + return nil, "", false + } + cands, prefix = shellCandidates(body, allowCommand) + return cands, prefix, true +} + +// shellLine works out which part of a typed line will reach a shell, and +// whether its command word is still open for completion. Two things get there: +// a '!' escape, and an alias that expands to one — `alias ll '!ls -la'` makes +// everything after `ll` a shell argument just as surely. +// +// Only the alias itself is inspected, not what its expansion might expand to +// again: an alias chain can rewrite its arguments, and guessing at that would +// offer candidates for a command line that is not the one being built. +func shellLine(typed string) (body string, allowCommand, ok bool) { + trimmed := strings.TrimLeft(typed, " \t") + if rest, found := strings.CutPrefix(trimmed, "!"); found { + return rest, true, true } + // the alias name has to be complete — while it is still being typed there + // is no way to know what it will turn out to be + sep := strings.IndexAny(trimmed, " \t") + if sep < 0 { + return "", false, false + } + name := trimmed[:sep] + if isBuiltin(name) { // a builtin can never be shadowed by an alias + return "", false, false + } + expansion, defined := aliases[name] + if !defined || !strings.HasPrefix(strings.TrimSpace(expansion), "!") { + return "", false, false + } + // the command comes from the alias body, so only arguments are left to complete + return trimmed[sep:], false, true +} + +// shellCandidates completes the last word of a shell command line. allowCommand +// says whether its first word may still be completed against PATH. +func shellCandidates(body string, allowCommand bool) (cands []string, prefix string) { word := body[strings.LastIndexAny(body, " \t")+1:] - inCommand := strings.TrimLeft(body[:len(body)-len(word)], " \t") == "" + inCommand := allowCommand && strings.TrimLeft(body[:len(body)-len(word)], " \t") == "" // a command word without a separator names something on PATH; with one it // is a path like ./script, exactly as a shell reads it diff --git a/shellcomplete_test.go b/shellcomplete_test.go index 9c87b31..b71eced 100644 --- a/shellcomplete_test.go +++ b/shellcomplete_test.go @@ -7,6 +7,12 @@ import ( "testing" ) +// complete runs the real dispatch a Tab press goes through. +func complete(typed string) ([]string, string) { + cands, prefix, _ := completeShellLine(typed) + return cands, prefix +} + // fakeCommands installs a fixed set of PATH executables for the test. func fakeCommands(t *testing.T, names ...string) { t.Helper() @@ -39,7 +45,7 @@ func TestShellCandidatesCommandWord(t *testing.T) { fakeCommands(t, "vi", "vim", "view", "grep", "git") shellTree(t) - cands, prefix := shellCandidates("!vi") + cands, prefix := complete("!vi") if prefix != "vi" { t.Errorf("prefix = %q, want vi", prefix) } @@ -48,15 +54,15 @@ func TestShellCandidatesCommandWord(t *testing.T) { } // a bare '!' must not dump every executable on the machine - if cands, _ := shellCandidates("!"); len(cands) != 0 { + if cands, _ := complete("!"); len(cands) != 0 { t.Errorf("bare '!' offered %d candidates", len(cands)) } // leading blanks are allowed, as runCommand allows them - if cands, _ := shellCandidates(" !gi"); strings.Join(cands, ",") != "git" { + if cands, _ := complete(" !gi"); strings.Join(cands, ",") != "git" { t.Errorf("indented escape = %v, want git", cands) } // a command word with a separator is a path, not a PATH lookup - if cands, prefix := shellCandidates("!./ma"); prefix != "ma" || + if cands, prefix := complete("!./ma"); prefix != "ma" || strings.Join(cands, ",") != "main.go,main_test.go" { t.Errorf("./ma = %v (prefix %q), want the local files", cands, prefix) } @@ -67,13 +73,13 @@ func TestShellCandidatesArguments(t *testing.T) { shellTree(t) // paths resolve against the project directory, where `!` commands run - cands, prefix := shellCandidates("!vi ma") + cands, prefix := complete("!vi ma") if prefix != "ma" || strings.Join(cands, ",") != "main.go,main_test.go" { t.Errorf("candidates = %v (prefix %q)", cands, prefix) } // an empty argument lists the directory — dot entries stay out of the way - cands, prefix = shellCandidates("!vi ") + cands, prefix = complete("!vi ") if prefix != "" { t.Errorf("prefix = %q, want empty", prefix) } @@ -82,25 +88,25 @@ func TestShellCandidatesArguments(t *testing.T) { } // ... until the prefix asks for them - if cands, _ := shellCandidates("!vi ."); strings.Join(cands, ",") != ".env,.hidden/" { + if cands, _ := complete("!vi ."); strings.Join(cands, ",") != ".env,.hidden/" { t.Errorf("dot prefix = %v, want the hidden entries", cands) } // a directory completes with its slash, so the next Tab walks into it - cands, prefix = shellCandidates("!vi sr") + cands, prefix = complete("!vi sr") if prefix != "sr" || strings.Join(cands, ",") != "src/" { t.Errorf("directory candidate = %v (prefix %q)", cands, prefix) } // inside a directory only the basename is completed, which is what keeps // the candidate list readable - cands, prefix = shellCandidates("!vi src/ut") + cands, prefix = complete("!vi src/ut") if prefix != "ut" || strings.Join(cands, ",") != "util.go" { t.Errorf("nested candidate = %v (prefix %q), want util.go / ut", cands, prefix) } // later arguments complete the same way as the first - if cands, _ := shellCandidates("!diff main.go ma"); strings.Join(cands, ",") != "main.go,main_test.go" { + if cands, _ := complete("!diff main.go ma"); strings.Join(cands, ",") != "main.go,main_test.go" { t.Errorf("second argument = %v", cands) } } @@ -114,13 +120,13 @@ func TestShellCandidatesAbsoluteAndHome(t *testing.T) { shellTree(t) fakeCommands(t, "vi") - if cands, prefix := shellCandidates("!vi ~/no"); prefix != "no" || + if cands, prefix := complete("!vi ~/no"); prefix != "no" || strings.Join(cands, ",") != "notes.txt" { t.Errorf("~/ completion = %v (prefix %q)", cands, prefix) } abs := filepath.Join(home, "no") - if cands, prefix := shellCandidates("!vi " + abs); prefix != "no" || + if cands, prefix := complete("!vi " + abs); prefix != "no" || strings.Join(cands, ",") != "notes.txt" { t.Errorf("absolute completion = %v (prefix %q)", cands, prefix) } @@ -193,3 +199,62 @@ func TestPathExecutablesFindsRealBinaries(t *testing.T) { t.Errorf("executable scan = %v, want only runnable", names) } } + +// withAliases installs a fixed alias set for the test. +func withAliases(t *testing.T, m map[string]string) { + t.Helper() + old := aliases + aliases = m + t.Cleanup(func() { aliases = old }) +} + +// TestShellCandidatesThroughAlias: an alias that expands to a '!' escape turns +// everything after its name into shell arguments, so it completes as such. +func TestShellCandidatesThroughAlias(t *testing.T) { + shellTree(t) + fakeCommands(t, "vi", "ls") + withAliases(t, map[string]string{ + "ll": "!ls -la", + "e": "!vi $1", + "co": "checkout $1", // a builtin, not a shell command + "open": "!xdg-open $1", // shadows a builtin: must not count + }) + + // arguments of a shell alias complete against the filesystem + if cands, prefix := complete("ll ma"); prefix != "ma" || + strings.Join(cands, ",") != "main.go,main_test.go" { + t.Errorf("alias argument = %v (prefix %q)", cands, prefix) + } + // including the empty one, which lists the directory + if cands, _ := complete("e "); strings.Join(cands, ",") != "Makefile,main.go,main_test.go,src/" { + t.Errorf("empty alias argument = %v", cands) + } + // and paths inside it + if cands, prefix := complete("ll src/ut"); prefix != "ut" || + strings.Join(cands, ",") != "util.go" { + t.Errorf("nested alias argument = %v (prefix %q)", cands, prefix) + } + + // the command word of an alias is fixed by its body, so PATH is never + // offered — `ll vi` means the file "vi", not the editor + if cands, _ := complete("ll vi"); len(cands) != 0 { + t.Errorf("alias argument matched PATH: %v", cands) + } + + // an alias to a builtin is not a shell line at all + if _, _, ok := shellLine("co ma"); ok { + t.Error("an alias expanding to a builtin was treated as a shell line") + } + // nor is a name that a builtin owns, since runCommand never expands those + if _, _, ok := shellLine("open ma"); ok { + t.Error("a builtin name was resolved through an alias") + } + // nor an undefined name + if _, _, ok := shellLine("nosuch ma"); ok { + t.Error("an undefined alias was treated as a shell line") + } + // while the alias name itself is still being typed there is nothing to know + if _, _, ok := shellLine("ll"); ok { + t.Error("an incomplete alias name was resolved") + } +} diff --git a/version.txt b/version.txt index e2cdb56..6c1af82 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.0.44 +4.0.46