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