package main // shellcomplete.go — Tab completion for the '!' shell escape. // // `!vi ` 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" 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 })