Complete ! shell escapes like a shell

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>
This commit is contained in:
2026-07-26 18:18:01 +02:00
co-authored by Claude Opus 5
parent 2a622046f2
commit 2acca170e6
5 changed files with 399 additions and 7 deletions
+195
View File
@@ -0,0 +1,195 @@
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)
}
}