2 Commits
Author SHA1 Message Date
mikeandClaude Opus 5 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
mikeandClaude Opus 5 2acca170e6 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 <noreply@anthropic.com>
2026-07-26 18:18:01 +02:00
5 changed files with 514 additions and 7 deletions
+31 -2
View File
@@ -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 for aliases that expand to
one), 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,34 @@ 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<Tab> -> !vi main
< src/myproject > !vi <Tab> -> Makefile main.go main_test.go src/
< src/myproject > !gre<Tab> -> grep gresource
```
An alias that expands to a shell escape completes the same way, because its
arguments end up as shell arguments:
```
alias ll '!ls -la'
< src/myproject > ll ma<Tab> -> ll main
```
Only the alias's arguments complete, never its first word — the command is
fixed by the alias body. An alias to a builtin (`alias co 'checkout $1'`) is not
a shell line and is left alone.
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:
+41 -4
View File
@@ -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 headed for a shell — a '!' escape,
// or an alias that expands to one — is completed the way a shell would:
// executables for the command, paths for its arguments. Everything else goes to
// 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 cands, prefix, ok := completeShellLine(string(line[:pos])); ok {
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)),
+181
View File
@@ -0,0 +1,181 @@
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
})
+260
View File
@@ -0,0 +1,260 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// complete runs the real dispatch a Tab press goes through.
func complete(typed string) ([]string, string) {
cands, prefix, _ := completeShellLine(typed)
return cands, prefix
}
// 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 := complete("!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, _ := complete("!"); len(cands) != 0 {
t.Errorf("bare '!' offered %d candidates", len(cands))
}
// leading blanks are allowed, as runCommand allows them
if cands, _ := complete(" !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 := complete("!./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 := complete("!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 = complete("!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, _ := complete("!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 = complete("!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 = complete("!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, _ := complete("!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 := complete("!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 := complete("!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)
}
}
// withAliases installs a fixed alias set for the test.
func withAliases(t *testing.T, m map[string]string) {
t.Helper()
old := aliases
aliases = m
t.Cleanup(func() { aliases = old })
}
// TestShellCandidatesThroughAlias: an alias that expands to a '!' escape turns
// everything after its name into shell arguments, so it completes as such.
func TestShellCandidatesThroughAlias(t *testing.T) {
shellTree(t)
fakeCommands(t, "vi", "ls")
withAliases(t, map[string]string{
"ll": "!ls -la",
"e": "!vi $1",
"co": "checkout $1", // a builtin, not a shell command
"open": "!xdg-open $1", // shadows a builtin: must not count
})
// arguments of a shell alias complete against the filesystem
if cands, prefix := complete("ll ma"); prefix != "ma" ||
strings.Join(cands, ",") != "main.go,main_test.go" {
t.Errorf("alias argument = %v (prefix %q)", cands, prefix)
}
// including the empty one, which lists the directory
if cands, _ := complete("e "); strings.Join(cands, ",") != "Makefile,main.go,main_test.go,src/" {
t.Errorf("empty alias argument = %v", cands)
}
// and paths inside it
if cands, prefix := complete("ll src/ut"); prefix != "ut" ||
strings.Join(cands, ",") != "util.go" {
t.Errorf("nested alias argument = %v (prefix %q)", cands, prefix)
}
// the command word of an alias is fixed by its body, so PATH is never
// offered — `ll vi` means the file "vi", not the editor
if cands, _ := complete("ll vi"); len(cands) != 0 {
t.Errorf("alias argument matched PATH: %v", cands)
}
// an alias to a builtin is not a shell line at all
if _, _, ok := shellLine("co ma"); ok {
t.Error("an alias expanding to a builtin was treated as a shell line")
}
// nor is a name that a builtin owns, since runCommand never expands those
if _, _, ok := shellLine("open ma"); ok {
t.Error("a builtin name was resolved through an alias")
}
// nor an undefined name
if _, _, ok := shellLine("nosuch ma"); ok {
t.Error("an undefined alias was treated as a shell line")
}
// while the alias name itself is still being typed there is nothing to know
if _, _, ok := shellLine("ll"); ok {
t.Error("an incomplete alias name was resolved")
}
}
+1 -1
View File
@@ -1 +1 @@
4.0.42
4.0.46