A line starting with '!' was not in the completer tree at all, so Tab did nothing there -- just where the paths are longest. It now completes the way a shell does: the command word against the executables on PATH, the arguments against the filesystem, resolved relative to the active project because that is where forwardShell runs the line. Directories complete with their trailing slash, `~/` and absolute paths work, and dot entries stay hidden until the prefix asks for one. readline's completer is a tree of fixed words, which cannot express "a prefix that is not a word", so this is a small AutoCompleter that dispatches on the '!' and hands everything else to the existing tree. Its contract is easy to get subtly wrong -- candidates are the suffixes still missing, and the length is counted in runes, not bytes -- so the conversion has its own test, as does completing only the basename inside a directory, which is what keeps the candidate list readable. Only the basename is offered inside a directory, PATH is scanned once per session, and a bare '!' offers nothing rather than every executable on the machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
196 lines
6.3 KiB
Go
196 lines
6.3 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// 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 := shellCandidates("!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, _ := shellCandidates("!"); 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" {
|
|
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" ||
|
|
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 := shellCandidates("!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 ")
|
|
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, _ := shellCandidates("!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")
|
|
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")
|
|
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" {
|
|
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 := shellCandidates("!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" ||
|
|
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)
|
|
}
|
|
}
|