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 660a37fa6a
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
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<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
escapes are left to the shell that runs the line.
+5 -5
View File
@@ -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)
+45 -7
View File
@@ -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
+77 -12
View File
@@ -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")
}
}
+1 -1
View File
@@ -1 +1 @@
4.0.44
4.0.46