From 2acca170e634cbaf3436300198c553a9fe3670bf Mon Sep 17 00:00:00 2001 From: Michael Wesemann Date: Sun, 26 Jul 2026 18:18:01 +0200 Subject: [PATCH] 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 --- README.md | 21 ++++- completion.go | 45 +++++++++- shellcomplete.go | 143 +++++++++++++++++++++++++++++++ shellcomplete_test.go | 195 ++++++++++++++++++++++++++++++++++++++++++ version.txt | 2 +- 5 files changed, 399 insertions(+), 7 deletions(-) create mode 100644 shellcomplete.go create mode 100644 shellcomplete_test.go diff --git a/README.md b/README.md index e83e0c8..e32b145 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,9 @@ project, its git branch and a `*` dirty marker: Features: command history (`~/.mgsh_history`), Tab completion (commands, local projects for `cd`/`open`, server repos for `clone`/`show`, branches/tags for -`checkout`/`tag`, mirror targets for `pushremote`, filesystem paths for `dist`), -and colored `list`/`log`/error output. +`checkout`/`tag`, mirror targets for `pushremote`/`release`, filesystem paths for +`dist`, and shell-style completion after `!`), and colored `list`/`log`/error +output. The server repository list is fetched once per session on the first Tab that needs it; `rescan` refreshes it (and reloads the configuration). @@ -70,6 +71,22 @@ prefix it with `!`: < src/myproject > !ls -la ``` +It runs in the active project's directory. Tab completion works there the way it +does in a shell: the word after the `!` completes against the executables on +`PATH`, everything after it against the filesystem — relative to the project, +with `~/` and absolute paths understood, and directories completing with their +trailing slash so the next Tab walks into them. Dot entries stay out of the way +until the prefix asks for one. + +``` +< src/myproject > !vi ma -> !vi main +< src/myproject > !vi -> Makefile main.go main_test.go src/ +< src/myproject > !gre -> grep gresource +``` + +Word splitting for completion is by whitespace only; quotes and backslash +escapes are left to the shell that runs the line. + ### Commands Run `help` for the full list. Highlights: diff --git a/completion.go b/completion.go index 231c898..6e854da 100644 --- a/completion.go +++ b/completion.go @@ -4,15 +4,52 @@ import ( "os" "path/filepath" "strings" + "unicode/utf8" "github.com/chzyer/readline" ) -// completer wires up Tab completion. Command names complete at the start of the -// line; cd/open/view complete local project names; clone/show complete +// completer wires up Tab completion. A line starting with '!' is completed the +// way a shell would — executables for the command, paths for its arguments — +// and everything else against the builtin command tree. +func completer() readline.AutoCompleter { + return &mgshCompleter{builtin: builtinCompleter()} +} + +// mgshCompleter dispatches between the two completion worlds. +type mgshCompleter struct{ builtin *readline.PrefixCompleter } + +func (c *mgshCompleter) Do(line []rune, pos int) ([][]rune, int) { + if pos > len(line) { + pos = len(line) + } + if strings.HasPrefix(strings.TrimLeft(string(line[:pos]), " \t"), "!") { + cands, prefix := shellCandidates(string(line[:pos])) + return runeSuffixes(cands, prefix) + } + return c.builtin.Do(line, pos) +} + +// runeSuffixes converts full candidate words into what readline wants: the part +// still missing after the prefix already typed, plus that prefix's length. +func runeSuffixes(cands []string, prefix string) ([][]rune, int) { + n := utf8.RuneCountInString(prefix) + out := make([][]rune, 0, len(cands)) + for _, c := range cands { + r := []rune(c) + if len(r) >= n { + out = append(out, r[n:]) + } + } + return out, n +} + +// builtinCompleter is the command tree. Command names complete at the start of +// the line; cd/open/view complete local project names; clone/show complete // repository names cached from the git server; checkout/tag complete branch and -// tag names; dist completes filesystem paths. -func completer() *readline.PrefixCompleter { +// tag names; pushremote/release complete mirror targets; dist completes +// filesystem paths. +func builtinCompleter() *readline.PrefixCompleter { return readline.NewPrefixCompleter( readline.PcItem("cd", readline.PcItemDynamic(dynLocalProjects)), readline.PcItem("open", readline.PcItemDynamic(dynLocalProjects)), diff --git a/shellcomplete.go b/shellcomplete.go new file mode 100644 index 0000000..19fd282 --- /dev/null +++ b/shellcomplete.go @@ -0,0 +1,143 @@ +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 +}) diff --git a/shellcomplete_test.go b/shellcomplete_test.go new file mode 100644 index 0000000..9c87b31 --- /dev/null +++ b/shellcomplete_test.go @@ -0,0 +1,195 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// fakeCommands installs a fixed set of PATH executables for the test. +func fakeCommands(t *testing.T, names ...string) { + t.Helper() + old := shellCommandList + shellCommandList = func() []string { return names } + t.Cleanup(func() { shellCommandList = old }) +} + +// shellTree lays out a directory to complete against and points DIR at it. +func shellTree(t *testing.T) string { + t.Helper() + dir := t.TempDir() + for _, d := range []string{"src", "src/deep", ".hidden"} { + if err := os.MkdirAll(filepath.Join(dir, d), 0755); err != nil { + t.Fatal(err) + } + } + for _, f := range []string{"main.go", "main_test.go", "Makefile", ".env", "src/util.go"} { + if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + } + old := DIR + DIR = dir + t.Cleanup(func() { DIR = old }) + return dir +} + +func TestShellCandidatesCommandWord(t *testing.T) { + fakeCommands(t, "vi", "vim", "view", "grep", "git") + shellTree(t) + + cands, prefix := shellCandidates("!vi") + if prefix != "vi" { + t.Errorf("prefix = %q, want vi", prefix) + } + if strings.Join(cands, ",") != "vi,view,vim" { + t.Errorf("candidates = %v, want vi,view,vim sorted", cands) + } + + // a bare '!' must not dump every executable on the machine + if cands, _ := shellCandidates("!"); len(cands) != 0 { + t.Errorf("bare '!' offered %d candidates", len(cands)) + } + // leading blanks are allowed, as runCommand allows them + if cands, _ := shellCandidates(" !gi"); strings.Join(cands, ",") != "git" { + t.Errorf("indented escape = %v, want git", cands) + } + // a command word with a separator is a path, not a PATH lookup + if cands, prefix := shellCandidates("!./ma"); prefix != "ma" || + strings.Join(cands, ",") != "main.go,main_test.go" { + t.Errorf("./ma = %v (prefix %q), want the local files", cands, prefix) + } +} + +func TestShellCandidatesArguments(t *testing.T) { + fakeCommands(t, "vi") + shellTree(t) + + // paths resolve against the project directory, where `!` commands run + cands, prefix := shellCandidates("!vi ma") + if prefix != "ma" || strings.Join(cands, ",") != "main.go,main_test.go" { + t.Errorf("candidates = %v (prefix %q)", cands, prefix) + } + + // an empty argument lists the directory — dot entries stay out of the way + cands, prefix = shellCandidates("!vi ") + if prefix != "" { + t.Errorf("prefix = %q, want empty", prefix) + } + if strings.Join(cands, ",") != "Makefile,main.go,main_test.go,src/" { + t.Errorf("directory listing = %v", cands) + } + + // ... until the prefix asks for them + if cands, _ := shellCandidates("!vi ."); strings.Join(cands, ",") != ".env,.hidden/" { + t.Errorf("dot prefix = %v, want the hidden entries", cands) + } + + // a directory completes with its slash, so the next Tab walks into it + cands, prefix = shellCandidates("!vi sr") + if prefix != "sr" || strings.Join(cands, ",") != "src/" { + t.Errorf("directory candidate = %v (prefix %q)", cands, prefix) + } + + // inside a directory only the basename is completed, which is what keeps + // the candidate list readable + cands, prefix = shellCandidates("!vi src/ut") + if prefix != "ut" || strings.Join(cands, ",") != "util.go" { + t.Errorf("nested candidate = %v (prefix %q), want util.go / ut", cands, prefix) + } + + // later arguments complete the same way as the first + if cands, _ := shellCandidates("!diff main.go ma"); strings.Join(cands, ",") != "main.go,main_test.go" { + t.Errorf("second argument = %v", cands) + } +} + +func TestShellCandidatesAbsoluteAndHome(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.WriteFile(filepath.Join(home, "notes.txt"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + shellTree(t) + fakeCommands(t, "vi") + + if cands, prefix := shellCandidates("!vi ~/no"); prefix != "no" || + strings.Join(cands, ",") != "notes.txt" { + t.Errorf("~/ completion = %v (prefix %q)", cands, prefix) + } + + abs := filepath.Join(home, "no") + if cands, prefix := shellCandidates("!vi " + abs); prefix != "no" || + strings.Join(cands, ",") != "notes.txt" { + t.Errorf("absolute completion = %v (prefix %q)", cands, prefix) + } +} + +// TestShellCompleterDoContract is the part that would corrupt the line if it +// were wrong: readline replaces the last `length` runes with a candidate, so +// the candidates must be suffixes and the length must count runes. +func TestShellCompleterDoContract(t *testing.T) { + fakeCommands(t, "vim", "view") + shellTree(t) + c := completer() + + line := []rune("!vi") + got, length := c.Do(line, len(line)) + if length != 2 { // "vi" — the '!' is not part of the word + t.Fatalf("length = %d, want 2", length) + } + // rebuilding the line from prefix + candidate must give the full word + for i, g := range got { + full := string(line[:len(line)-length]) + string(line[len(line)-length:]) + string(g) + if full != "!vim" && full != "!view" { + t.Errorf("candidate %d rebuilds to %q", i, full) + } + } + + // a non-'!' line still goes to the builtin command tree (which appends its + // own trailing space on a unique match) + line = []rune("stat") + got, length = c.Do(line, len(line)) + if length != 4 || len(got) == 0 || !strings.HasPrefix(string(got[0]), "us") { + t.Errorf("builtin completion = %q, %d; want a candidate starting \"us\" at 4", got, length) + } + + // a multi-byte prefix must be measured in runes, not bytes + if _, n := runeSuffixes([]string{"übermorgen"}, "üb"); n != 2 { + t.Errorf("runeSuffixes length = %d, want 2 runes", n) + } +} + +// TestPathExecutablesFindsRealBinaries checks the PATH scan against a directory +// it controls: only files with an execute bit, no directories. +func TestPathExecutablesFindsRealBinaries(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "runnable"), []byte("#!/bin/sh\n"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "plainfile"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "subdir"), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + + // pathExecutables caches for the session, so exercise the scan directly + var names []string + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + fi, err := e.Info() + if err != nil || fi.IsDir() || fi.Mode().Perm()&0o111 == 0 { + continue + } + names = append(names, e.Name()) + } + if strings.Join(names, ",") != "runnable" { + t.Errorf("executable scan = %v, want only runnable", names) + } +} diff --git a/version.txt b/version.txt index 7a54ad2..e2cdb56 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.0.42 +4.0.44