Files
mgsh/shellcomplete.go
Michael Wesemann 0d0d28560e Complete aliases that expand to a shell escape
`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>
2026-07-26 18:28:35 +02:00

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
})