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:
2026-07-26 18:28:35 +02:00
co-authored by Claude Opus 5
parent 2acca170e6
commit 0d0d28560e
5 changed files with 142 additions and 27 deletions
+14 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
4.0.44 4.0.46