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:
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
// shellcomplete.go — Tab completion for the '!' shell escape.
|
||||
//
|
||||
// `!vi <Tab>` should behave like it does in a shell: the first word completes
|
||||
// against the executables on PATH, everything after it against the filesystem.
|
||||
// Paths resolve relative to the active project directory, because that is where
|
||||
// forwardShell runs the command.
|
||||
//
|
||||
// Word splitting here is whitespace only. Quoting and backslash escapes are the
|
||||
// shell's business at execution time; getting them right for completion too
|
||||
// would buy little for a one-off escape hatch.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// shellCommandList supplies the executable names for the command position. A
|
||||
// variable so tests can hand over a fixed set instead of whatever happens to be
|
||||
// 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"), "!")
|
||||
if !ok {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
word := body[strings.LastIndexAny(body, " \t")+1:]
|
||||
inCommand := 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
|
||||
if inCommand && !strings.ContainsRune(word, '/') {
|
||||
if word == "" {
|
||||
return nil, "" // every executable on the machine helps nobody
|
||||
}
|
||||
return matchPrefix(shellCommandList(), word), word
|
||||
}
|
||||
|
||||
dir, base := splitPathToken(word)
|
||||
return matchPrefix(pathEntries(dir), base), base
|
||||
}
|
||||
|
||||
// splitPathToken splits a path token into the directory part, kept exactly as
|
||||
// typed, and the basename being completed. Completing only the basename is what
|
||||
// keeps the candidate list readable: "src/ma<Tab>" offers "main.go", not the
|
||||
// whole path again.
|
||||
func splitPathToken(word string) (dir, base string) {
|
||||
if i := strings.LastIndexByte(word, '/'); i >= 0 {
|
||||
return word[:i+1], word[i+1:]
|
||||
}
|
||||
return "", word
|
||||
}
|
||||
|
||||
// pathEntries lists what a directory token points at. Directories come back
|
||||
// with a trailing slash, so completing one leads straight into it.
|
||||
func pathEntries(dir string) []string {
|
||||
root := DIR
|
||||
switch {
|
||||
case strings.HasPrefix(dir, "~/"):
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
root, dir = home, dir[2:]
|
||||
case strings.HasPrefix(dir, "/"):
|
||||
root = ""
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(filepath.Join(root, dir))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
name += "/"
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchPrefix keeps the candidates starting with prefix, sorted and without
|
||||
// duplicates. A hidden entry only shows up once the prefix asks for it, as in a
|
||||
// shell.
|
||||
func matchPrefix(cands []string, prefix string) []string {
|
||||
wantHidden := strings.HasPrefix(prefix, ".")
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, c := range cands {
|
||||
if !strings.HasPrefix(c, prefix) || seen[c] {
|
||||
continue
|
||||
}
|
||||
if !wantHidden && strings.HasPrefix(c, ".") {
|
||||
continue
|
||||
}
|
||||
seen[c] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// pathExecutables lists the executable names on PATH. The scan happens once per
|
||||
// session: PATH cannot change from inside mgsh, and a few thousand directory
|
||||
// entries are not worth walking on every Tab.
|
||||
var pathExecutables = sync.OnceValue(func() []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if seen[name] {
|
||||
continue // the first one on PATH is the one that would run
|
||||
}
|
||||
fi, err := e.Info()
|
||||
if err != nil || fi.IsDir() || fi.Mode().Perm()&0o111 == 0 {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
})
|
||||
Reference in New Issue
Block a user