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