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:
+77
-12
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user