Complete aliases that expand to a shell escape
`alias ll '!ls -la'` makes everything after `ll` a shell argument just as surely as typing the `!` does, but Tab there still went to the builtin command tree and found nothing. The dispatch now asks what a line will turn into rather than how it starts: a '!' escape, or a name that is not a builtin and resolves to an alias whose body starts with '!'. Only the arguments complete — the command word is fixed by the alias body, so `ll vi` offers the file, never the editor. An alias to a builtin stays with the builtin tree. Only the alias itself is inspected, not what its expansion might expand to in turn: an alias chain can rewrite its own arguments, and guessing at that would offer candidates for a command line other than the one being built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,8 +54,8 @@ project, its git branch and a `*` dirty marker:
|
|||||||
Features: command history (`~/.mgsh_history`), Tab completion (commands, local
|
Features: command history (`~/.mgsh_history`), Tab completion (commands, local
|
||||||
projects for `cd`/`open`, server repos for `clone`/`show`, branches/tags for
|
projects for `cd`/`open`, server repos for `clone`/`show`, branches/tags for
|
||||||
`checkout`/`tag`, mirror targets for `pushremote`/`release`, filesystem paths for
|
`checkout`/`tag`, mirror targets for `pushremote`/`release`, filesystem paths for
|
||||||
`dist`, and shell-style completion after `!`), and colored `list`/`log`/error
|
`dist`, and shell-style completion after `!` and for aliases that expand to
|
||||||
output.
|
one), and colored `list`/`log`/error output.
|
||||||
|
|
||||||
The server repository list is fetched once per session on the first Tab that
|
The server repository list is fetched once per session on the first Tab that
|
||||||
needs it; `rescan` refreshes it (and reloads the configuration).
|
needs it; `rescan` refreshes it (and reloads the configuration).
|
||||||
@@ -84,6 +84,18 @@ until the prefix asks for one.
|
|||||||
< src/myproject > !gre<Tab> -> grep gresource
|
< src/myproject > !gre<Tab> -> 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<Tab> -> 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
|
Word splitting for completion is by whitespace only; quotes and backslash
|
||||||
escapes are left to the shell that runs the line.
|
escapes are left to the shell that runs the line.
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -9,9 +9,10 @@ import (
|
|||||||
"github.com/chzyer/readline"
|
"github.com/chzyer/readline"
|
||||||
)
|
)
|
||||||
|
|
||||||
// completer wires up Tab completion. A line starting with '!' is completed the
|
// completer wires up Tab completion. A line headed for a shell — a '!' escape,
|
||||||
// way a shell would — executables for the command, paths for its arguments —
|
// or an alias that expands to one — is completed the way a shell would:
|
||||||
// and everything else against the builtin command tree.
|
// executables for the command, paths for its arguments. Everything else goes to
|
||||||
|
// the builtin command tree.
|
||||||
func completer() readline.AutoCompleter {
|
func completer() readline.AutoCompleter {
|
||||||
return &mgshCompleter{builtin: builtinCompleter()}
|
return &mgshCompleter{builtin: builtinCompleter()}
|
||||||
}
|
}
|
||||||
@@ -23,8 +24,7 @@ func (c *mgshCompleter) Do(line []rune, pos int) ([][]rune, int) {
|
|||||||
if pos > len(line) {
|
if pos > len(line) {
|
||||||
pos = len(line)
|
pos = len(line)
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(strings.TrimLeft(string(line[:pos]), " \t"), "!") {
|
if cands, prefix, ok := completeShellLine(string(line[:pos])); ok {
|
||||||
cands, prefix := shellCandidates(string(line[:pos]))
|
|
||||||
return runeSuffixes(cands, prefix)
|
return runeSuffixes(cands, prefix)
|
||||||
}
|
}
|
||||||
return c.builtin.Do(line, pos)
|
return c.builtin.Do(line, pos)
|
||||||
|
|||||||
+45
-7
@@ -24,17 +24,55 @@ import (
|
|||||||
// installed on the machine running them.
|
// installed on the machine running them.
|
||||||
var shellCommandList = pathExecutables
|
var shellCommandList = pathExecutables
|
||||||
|
|
||||||
// shellCandidates returns the completion candidates for the text to the left of
|
// completeShellLine returns the candidates and the prefix they replace for a
|
||||||
// the cursor, together with the prefix they replace. text is expected to start
|
// line that is headed for a shell. ok is false for any other line, which is
|
||||||
// (after leading blanks) with the '!' that marks a shell escape.
|
// then left to the builtin command tree.
|
||||||
func shellCandidates(text string) (cands []string, prefix string) {
|
func completeShellLine(typed string) (cands []string, prefix string, ok bool) {
|
||||||
body, ok := strings.CutPrefix(strings.TrimLeft(text, " \t"), "!")
|
body, allowCommand, ok := shellLine(typed)
|
||||||
if !ok {
|
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:]
|
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
|
// a command word without a separator names something on PATH; with one it
|
||||||
// is a path like ./script, exactly as a shell reads it
|
// is a path like ./script, exactly as a shell reads it
|
||||||
|
|||||||
+77
-12
@@ -7,6 +7,12 @@ import (
|
|||||||
"testing"
|
"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.
|
// fakeCommands installs a fixed set of PATH executables for the test.
|
||||||
func fakeCommands(t *testing.T, names ...string) {
|
func fakeCommands(t *testing.T, names ...string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -39,7 +45,7 @@ func TestShellCandidatesCommandWord(t *testing.T) {
|
|||||||
fakeCommands(t, "vi", "vim", "view", "grep", "git")
|
fakeCommands(t, "vi", "vim", "view", "grep", "git")
|
||||||
shellTree(t)
|
shellTree(t)
|
||||||
|
|
||||||
cands, prefix := shellCandidates("!vi")
|
cands, prefix := complete("!vi")
|
||||||
if prefix != "vi" {
|
if prefix != "vi" {
|
||||||
t.Errorf("prefix = %q, want vi", prefix)
|
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
|
// 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))
|
t.Errorf("bare '!' offered %d candidates", len(cands))
|
||||||
}
|
}
|
||||||
// leading blanks are allowed, as runCommand allows them
|
// 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)
|
t.Errorf("indented escape = %v, want git", cands)
|
||||||
}
|
}
|
||||||
// a command word with a separator is a path, not a PATH lookup
|
// 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" {
|
strings.Join(cands, ",") != "main.go,main_test.go" {
|
||||||
t.Errorf("./ma = %v (prefix %q), want the local files", cands, prefix)
|
t.Errorf("./ma = %v (prefix %q), want the local files", cands, prefix)
|
||||||
}
|
}
|
||||||
@@ -67,13 +73,13 @@ func TestShellCandidatesArguments(t *testing.T) {
|
|||||||
shellTree(t)
|
shellTree(t)
|
||||||
|
|
||||||
// paths resolve against the project directory, where `!` commands run
|
// 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" {
|
if prefix != "ma" || strings.Join(cands, ",") != "main.go,main_test.go" {
|
||||||
t.Errorf("candidates = %v (prefix %q)", cands, prefix)
|
t.Errorf("candidates = %v (prefix %q)", cands, prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
// an empty argument lists the directory — dot entries stay out of the way
|
// an empty argument lists the directory — dot entries stay out of the way
|
||||||
cands, prefix = shellCandidates("!vi ")
|
cands, prefix = complete("!vi ")
|
||||||
if prefix != "" {
|
if prefix != "" {
|
||||||
t.Errorf("prefix = %q, want empty", prefix)
|
t.Errorf("prefix = %q, want empty", prefix)
|
||||||
}
|
}
|
||||||
@@ -82,25 +88,25 @@ func TestShellCandidatesArguments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ... until the prefix asks for them
|
// ... 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)
|
t.Errorf("dot prefix = %v, want the hidden entries", cands)
|
||||||
}
|
}
|
||||||
|
|
||||||
// a directory completes with its slash, so the next Tab walks into it
|
// 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/" {
|
if prefix != "sr" || strings.Join(cands, ",") != "src/" {
|
||||||
t.Errorf("directory candidate = %v (prefix %q)", cands, prefix)
|
t.Errorf("directory candidate = %v (prefix %q)", cands, prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
// inside a directory only the basename is completed, which is what keeps
|
// inside a directory only the basename is completed, which is what keeps
|
||||||
// the candidate list readable
|
// the candidate list readable
|
||||||
cands, prefix = shellCandidates("!vi src/ut")
|
cands, prefix = complete("!vi src/ut")
|
||||||
if prefix != "ut" || strings.Join(cands, ",") != "util.go" {
|
if prefix != "ut" || strings.Join(cands, ",") != "util.go" {
|
||||||
t.Errorf("nested candidate = %v (prefix %q), want util.go / ut", cands, prefix)
|
t.Errorf("nested candidate = %v (prefix %q), want util.go / ut", cands, prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
// later arguments complete the same way as the first
|
// 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)
|
t.Errorf("second argument = %v", cands)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,13 +120,13 @@ func TestShellCandidatesAbsoluteAndHome(t *testing.T) {
|
|||||||
shellTree(t)
|
shellTree(t)
|
||||||
fakeCommands(t, "vi")
|
fakeCommands(t, "vi")
|
||||||
|
|
||||||
if cands, prefix := shellCandidates("!vi ~/no"); prefix != "no" ||
|
if cands, prefix := complete("!vi ~/no"); prefix != "no" ||
|
||||||
strings.Join(cands, ",") != "notes.txt" {
|
strings.Join(cands, ",") != "notes.txt" {
|
||||||
t.Errorf("~/ completion = %v (prefix %q)", cands, prefix)
|
t.Errorf("~/ completion = %v (prefix %q)", cands, prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
abs := filepath.Join(home, "no")
|
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" {
|
strings.Join(cands, ",") != "notes.txt" {
|
||||||
t.Errorf("absolute completion = %v (prefix %q)", cands, prefix)
|
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)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.0.44
|
4.0.46
|
||||||
|
|||||||
Reference in New Issue
Block a user