0d0d28560e
`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>
182 lines
5.7 KiB
Go
182 lines
5.7 KiB
Go
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
|
|
|
|
// completeShellLine returns the candidates and the prefix they replace for a
|
|
// line that is headed for a shell. ok is false for any other line, which is
|
|
// then left to the builtin command tree.
|
|
func completeShellLine(typed string) (cands []string, prefix string, ok bool) {
|
|
body, allowCommand, ok := shellLine(typed)
|
|
if !ok {
|
|
return nil, "", false
|
|
}
|
|
cands, prefix = shellCandidates(body, allowCommand)
|
|
return cands, prefix, true
|
|
}
|
|
|
|
// shellLine works out which part of a typed line will reach a shell, and
|
|
// whether its command word is still open for completion. Two things get there:
|
|
// a '!' escape, and an alias that expands to one — `alias ll '!ls -la'` makes
|
|
// everything after `ll` a shell argument just as surely.
|
|
//
|
|
// Only the alias itself is inspected, not what its expansion might expand to
|
|
// again: an alias chain can rewrite its arguments, and guessing at that would
|
|
// offer candidates for a command line that is not the one being built.
|
|
func shellLine(typed string) (body string, allowCommand, ok bool) {
|
|
trimmed := strings.TrimLeft(typed, " \t")
|
|
if rest, found := strings.CutPrefix(trimmed, "!"); found {
|
|
return rest, true, true
|
|
}
|
|
|
|
// the alias name has to be complete — while it is still being typed there
|
|
// is no way to know what it will turn out to be
|
|
sep := strings.IndexAny(trimmed, " \t")
|
|
if sep < 0 {
|
|
return "", false, false
|
|
}
|
|
name := trimmed[:sep]
|
|
if isBuiltin(name) { // a builtin can never be shadowed by an alias
|
|
return "", false, false
|
|
}
|
|
expansion, defined := aliases[name]
|
|
if !defined || !strings.HasPrefix(strings.TrimSpace(expansion), "!") {
|
|
return "", false, false
|
|
}
|
|
// the command comes from the alias body, so only arguments are left to complete
|
|
return trimmed[sep:], false, true
|
|
}
|
|
|
|
// shellCandidates completes the last word of a shell command line. allowCommand
|
|
// says whether its first word may still be completed against PATH.
|
|
func shellCandidates(body string, allowCommand bool) (cands []string, prefix string) {
|
|
word := body[strings.LastIndexAny(body, " \t")+1:]
|
|
inCommand := allowCommand && 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
|
|
})
|