`open` and `view` shared one implementation and differed in a single line: `open` also made the project the active one. Only `view` is left, with the behaviour it always had. Dropping it from builtinCmds is the part worth noting: a reserved word cannot be shadowed by an alias, so `open` is now free for one -- `alias open '!xdg-open $1'` works, which it could not before. That also made a completion test wrong, since it used `open` as its example of a name a builtin owns; it uses `status` now. The `runInDir(d, "open", ...)` calls stay: those are macOS's open(1), which is how an Xcode workspace gets opened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
261 lines
8.6 KiB
Go
261 lines
8.6 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"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()
|
|
old := shellCommandList
|
|
shellCommandList = func() []string { return names }
|
|
t.Cleanup(func() { shellCommandList = old })
|
|
}
|
|
|
|
// shellTree lays out a directory to complete against and points DIR at it.
|
|
func shellTree(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
for _, d := range []string{"src", "src/deep", ".hidden"} {
|
|
if err := os.MkdirAll(filepath.Join(dir, d), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, f := range []string{"main.go", "main_test.go", "Makefile", ".env", "src/util.go"} {
|
|
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
old := DIR
|
|
DIR = dir
|
|
t.Cleanup(func() { DIR = old })
|
|
return dir
|
|
}
|
|
|
|
func TestShellCandidatesCommandWord(t *testing.T) {
|
|
fakeCommands(t, "vi", "vim", "view", "grep", "git")
|
|
shellTree(t)
|
|
|
|
cands, prefix := complete("!vi")
|
|
if prefix != "vi" {
|
|
t.Errorf("prefix = %q, want vi", prefix)
|
|
}
|
|
if strings.Join(cands, ",") != "vi,view,vim" {
|
|
t.Errorf("candidates = %v, want vi,view,vim sorted", cands)
|
|
}
|
|
|
|
// a bare '!' must not dump every executable on the machine
|
|
if cands, _ := complete("!"); len(cands) != 0 {
|
|
t.Errorf("bare '!' offered %d candidates", len(cands))
|
|
}
|
|
// leading blanks are allowed, as runCommand allows them
|
|
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 := complete("!./ma"); prefix != "ma" ||
|
|
strings.Join(cands, ",") != "main.go,main_test.go" {
|
|
t.Errorf("./ma = %v (prefix %q), want the local files", cands, prefix)
|
|
}
|
|
}
|
|
|
|
func TestShellCandidatesArguments(t *testing.T) {
|
|
fakeCommands(t, "vi")
|
|
shellTree(t)
|
|
|
|
// paths resolve against the project directory, where `!` commands run
|
|
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 = complete("!vi ")
|
|
if prefix != "" {
|
|
t.Errorf("prefix = %q, want empty", prefix)
|
|
}
|
|
if strings.Join(cands, ",") != "Makefile,main.go,main_test.go,src/" {
|
|
t.Errorf("directory listing = %v", cands)
|
|
}
|
|
|
|
// ... until the prefix asks for them
|
|
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 = 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 = 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, _ := complete("!diff main.go ma"); strings.Join(cands, ",") != "main.go,main_test.go" {
|
|
t.Errorf("second argument = %v", cands)
|
|
}
|
|
}
|
|
|
|
func TestShellCandidatesAbsoluteAndHome(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
if err := os.WriteFile(filepath.Join(home, "notes.txt"), []byte("x"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
shellTree(t)
|
|
fakeCommands(t, "vi")
|
|
|
|
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 := complete("!vi " + abs); prefix != "no" ||
|
|
strings.Join(cands, ",") != "notes.txt" {
|
|
t.Errorf("absolute completion = %v (prefix %q)", cands, prefix)
|
|
}
|
|
}
|
|
|
|
// TestShellCompleterDoContract is the part that would corrupt the line if it
|
|
// were wrong: readline replaces the last `length` runes with a candidate, so
|
|
// the candidates must be suffixes and the length must count runes.
|
|
func TestShellCompleterDoContract(t *testing.T) {
|
|
fakeCommands(t, "vim", "view")
|
|
shellTree(t)
|
|
c := completer()
|
|
|
|
line := []rune("!vi")
|
|
got, length := c.Do(line, len(line))
|
|
if length != 2 { // "vi" — the '!' is not part of the word
|
|
t.Fatalf("length = %d, want 2", length)
|
|
}
|
|
// rebuilding the line from prefix + candidate must give the full word
|
|
for i, g := range got {
|
|
full := string(line[:len(line)-length]) + string(line[len(line)-length:]) + string(g)
|
|
if full != "!vim" && full != "!view" {
|
|
t.Errorf("candidate %d rebuilds to %q", i, full)
|
|
}
|
|
}
|
|
|
|
// a non-'!' line still goes to the builtin command tree (which appends its
|
|
// own trailing space on a unique match)
|
|
line = []rune("stat")
|
|
got, length = c.Do(line, len(line))
|
|
if length != 4 || len(got) == 0 || !strings.HasPrefix(string(got[0]), "us") {
|
|
t.Errorf("builtin completion = %q, %d; want a candidate starting \"us\" at 4", got, length)
|
|
}
|
|
|
|
// a multi-byte prefix must be measured in runes, not bytes
|
|
if _, n := runeSuffixes([]string{"übermorgen"}, "üb"); n != 2 {
|
|
t.Errorf("runeSuffixes length = %d, want 2 runes", n)
|
|
}
|
|
}
|
|
|
|
// TestPathExecutablesFindsRealBinaries checks the PATH scan against a directory
|
|
// it controls: only files with an execute bit, no directories.
|
|
func TestPathExecutablesFindsRealBinaries(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "runnable"), []byte("#!/bin/sh\n"), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "plainfile"), []byte("x"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(dir, "subdir"), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", dir)
|
|
|
|
// pathExecutables caches for the session, so exercise the scan directly
|
|
var names []string
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, e := range entries {
|
|
fi, err := e.Info()
|
|
if err != nil || fi.IsDir() || fi.Mode().Perm()&0o111 == 0 {
|
|
continue
|
|
}
|
|
names = append(names, e.Name())
|
|
}
|
|
if strings.Join(names, ",") != "runnable" {
|
|
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", // expands to a builtin, not a shell command
|
|
"status": "!git status", // 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("status 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")
|
|
}
|
|
}
|