From 187de303516100616457dc479637c181672de0b4 Mon Sep 17 00:00:00 2001 From: Florian Walther Date: Sat, 3 Oct 2026 23:27:55 +0200 Subject: [PATCH] add aliases - alias name=value defines aliases, alias lists or shows them in a form that can be read back, unalias removes them (unalias -a: all) - the first word of every command (at the start and after ;, &, &&, || and |) is replaced, the value is expanded again except for aliases that are already being expanded (alias ls='ls --color' works) - a value ending with a blank makes the next word a command name too - quoted or escaped words (\ll, 'll') skip the alias - aliases are only expanded in interactive shells and their startup files, like in bash - tab completion includes aliases Co-Authored-By: Claude Opus 5.5 --- README.md | 36 ++++++++++ alias.go | 191 ++++++++++++++++++++++++++++++++++++++++++++++++++ alias_test.go | 164 +++++++++++++++++++++++++++++++++++++++++++ main.go | 46 ++++++++---- 4 files changed, 422 insertions(+), 15 deletions(-) create mode 100644 alias.go create mode 100644 alias_test.go diff --git a/README.md b/README.md index 4dc1bd2..d3d0b06 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ Built-in commands: fg [%n] - Continue job n (default: current job) in the foreground bg [%n] - Continue stopped job n in the background wait [%n] - Wait for job n or all background jobs + alias [name[=value] ...] - Define or show aliases (alias ll='ls -l') + unalias name - Remove an alias (unalias -a: all aliases) help - Show this help Features: @@ -43,6 +45,8 @@ Features: 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file - Job control: command & runs in the background, Ctrl+Z stops the foreground job; jobs, fg, bg and wait manage the jobs + - Aliases replace the first word of a command in interactive shells + (define them in ~/.goshrc); \name or 'name' skips the alias - Non-interactive use: gosh -c 'command', gosh script, command | gosh; the exit status is the one of the last command - Startup files: login shells (-l or argv[0] starting with -) read @@ -96,6 +100,10 @@ Beispiel für `~/.goshrc`: export PATH=$HOME/bin:$HOME/go/bin:$PATH export EDITOR=vim +# Aliase +alias ll='ls -l' +alias ..='cd ..' + # Prompt-Farben (Anführungszeichen wegen des ;) export GOSH_COLORS='user=1;38;5;208:path=38;5;205' @@ -186,6 +194,34 @@ $ grep -c error < build.log - Der Exit-Status einer Pipeline ist der des letzten Befehls. Beendet sich ein Befehl am Ende (z. B. `head`), bekommt der schreibende Befehl davor `SIGPIPE`, `yes | head -1` endet also sofort. - Die Built-ins `pwd` und `help` lassen sich umleiten und in Pipes verwenden (`help | less`). `cd`, `set`, `export`, `exit` und `quit` verändern die Shell selbst und sind in einer Pipeline nicht erlaubt. +### Aliase + +Ein Alias ist ein Kurzname für einen Befehl, am besten in `~/.goshrc` definiert: + +```bash +alias ll='ls -l' +alias la='ls -la' +alias ..='cd ..' +alias gs='git status' +alias ls='ls --color=auto' # ein Alias darf den eigenen Namen enthalten +alias please='sudo ' # endet mit Leerzeichen: das nächste Wort wird auch ersetzt +``` + +| Befehl | Bedeutung | +|-------------------------|---------------------------------------------------| +| `alias name='wert'` | definiert einen Alias (mehrere in einem Aufruf möglich) | +| `alias` | listet alle Aliase (in einer Form, die sich wieder einlesen lässt) | +| `alias name` | zeigt einen Alias | +| `unalias name` | entfernt einen Alias, `unalias -a` alle | + +- Ersetzt wird nur das erste Wort eines Befehls, also am Zeilenanfang und nach `;`, `&&`, `||`, `|` und `&`: `ll | less` funktioniert, `echo ll` gibt `ll` aus. +- Der Wert darf selbst mehrere Befehle enthalten (`alias up='cd .. && ls'`) und wird wiederum auf Aliase geprüft. Ein Alias, der gerade ersetzt wird, wird darin nicht noch einmal ersetzt, deshalb funktioniert `alias ls='ls --color=auto'`. +- Endet der Wert mit einem Leerzeichen, wird auch das nächste Wort als Alias geprüft: Mit den Aliasen oben wird `please ll` zu `sudo ls --color=auto -l`. +- Mit `\ll` oder `'ll'` umgeht man den Alias und ruft das Programm direkt auf. +- Aliase gelten nur in interaktiven Shells (wie in bash), also auch in `~/.goshrc`, aber nicht in Skripten und bei `gosh -c`. Ein Alias wirkt erst ab der nächsten Zeile, nicht in der Zeile, in der er definiert wird. +- Erlaubt sind Namen aus Buchstaben, Ziffern und `_ - . + : , @ % ^`. +- Aliase werden auch mit Tab vervollständigt. + ### Job-Control In einer interaktiven Shell läuft jede Befehlszeile (bzw. jede Pipeline) als eigener *Job* in einer eigenen Prozessgruppe: diff --git a/alias.go b/alias.go new file mode 100644 index 0000000..eb82d80 --- /dev/null +++ b/alias.go @@ -0,0 +1,191 @@ +package main + +// Aliases: like in bash, an alias replaces the first word of a command and +// is only expanded in interactive shells (including the startup files), not +// in scripts or with gosh -c. + +import ( + "fmt" + "io" + "sort" + "strings" + "sync" +) + +var ( + aliasMu sync.Mutex + aliases = map[string]string{} +) + +// isAliasName reports whether s can be an alias name: letters, digits and a +// few punctuation characters, but nothing that is special to the shell. +func isAliasName(s string) bool { + if s == "" { + return false + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + case strings.ContainsRune("_-.+:,@%^", r): + default: + return false + } + } + return true +} + +// lookupAlias returns the value of an alias. +func lookupAlias(name string) (string, bool) { + aliasMu.Lock() + defer aliasMu.Unlock() + value, ok := aliases[name] + return value, ok +} + +// aliasNames returns the names of all aliases, sorted. +func aliasNames() []string { + aliasMu.Lock() + defer aliasMu.Unlock() + names := make([]string, 0, len(aliases)) + for name := range aliases { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// expandAliases replaces aliases at the start of every command in line (at +// the start and after ;, &, &&, || and |). The value of an alias is expanded +// again, except for aliases that are already being expanded, so that e.g. +// alias ls='ls --color' works. If the value ends with a blank, the next word +// is checked for an alias too. Quoted or escaped words (\ls, 'ls') are never +// replaced. +func expandAliases(line string) string { + return expandAliasesIn(line, map[string]bool{}) +} + +func expandAliasesIn(line string, active map[string]bool) string { + var ( + out strings.Builder + quote byte + escaped bool + commandPos = true // the next word is the name of a command + ) + + for i := 0; i < len(line); i++ { + ch := line[i] + if quote == 0 && !escaped && commandPos { + if ch == ' ' || ch == '\t' { + out.WriteByte(ch) + continue + } + commandPos = false + end := i + for end < len(line) && strings.IndexByte(" \t\n;&|<>", line[end]) < 0 { + end++ + } + name := line[i:end] + if value, ok := lookupAlias(name); ok && isAliasName(name) && !active[name] { + active[name] = true + out.WriteString(expandAliasesIn(value, active)) + delete(active, name) + // A trailing blank makes the next word a command name too + commandPos = strings.HasSuffix(value, " ") || strings.HasSuffix(value, "\t") + i = end - 1 + continue + } + } + + out.WriteByte(ch) + switch { + case escaped: + escaped = false + case quote == '\'': + if ch == '\'' { + quote = 0 + } + case ch == '\\': + escaped = true + case quote == '"': + if ch == '"' { + quote = 0 + } + case ch == '"' || ch == '\'': + quote = ch + case ch == '#' && (i == 0 || strings.IndexByte(" \t", line[i-1]) >= 0): + // Comment until the end of the line + out.WriteString(line[i+1:]) + return out.String() + case ch == ';' || ch == '|': + commandPos = true + case ch == '&': + // & and && separate commands, &>, >& and <& do not + if !(i+1 < len(line) && line[i+1] == '>') && !(i > 0 && strings.IndexByte("<>", line[i-1]) >= 0) { + commandPos = true + } + } + } + return out.String() +} + +// quoteAlias quotes a value for the output of alias, so that it can be read +// back: 'value' with ' written as '\”. +func quoteAlias(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" +} + +// builtinAlias defines aliases (alias name=value ...), prints them (alias +// name ...) or lists all of them (alias). +func builtinAlias(args []string, stdout io.Writer) error { + if len(args) == 1 { + for _, name := range aliasNames() { + value, _ := lookupAlias(name) + fmt.Fprintf(stdout, "alias %s=%s\n", name, sanitize(quoteAlias(value))) + } + return nil + } + + var err error + for _, arg := range args[1:] { + name, value, define := strings.Cut(arg, "=") + if !isAliasName(name) { + err = fmt.Errorf("alias: `%s': invalid alias name", sanitize(name)) + continue + } + if define { + aliasMu.Lock() + aliases[name] = value + aliasMu.Unlock() + continue + } + if value, ok := lookupAlias(name); ok { + fmt.Fprintf(stdout, "alias %s=%s\n", name, sanitize(quoteAlias(value))) + } else { + err = fmt.Errorf("alias: %s: not found", sanitize(name)) + } + } + return err +} + +// builtinUnalias removes aliases (unalias name ...) or all of them +// (unalias -a). +func builtinUnalias(args []string) error { + if len(args) < 2 { + return fmt.Errorf("usage: unalias [-a] name ...") + } + aliasMu.Lock() + defer aliasMu.Unlock() + if args[1] == "-a" { + aliases = map[string]string{} + return nil + } + var err error + for _, name := range args[1:] { + if _, ok := aliases[name]; !ok { + err = fmt.Errorf("unalias: %s: not found", sanitize(name)) + continue + } + delete(aliases, name) + } + return err +} diff --git a/alias_test.go b/alias_test.go new file mode 100644 index 0000000..b9e374c --- /dev/null +++ b/alias_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// withAliases sets the aliases for a test and restores them afterwards. +func withAliases(t *testing.T, defs map[string]string) { + t.Helper() + aliasMu.Lock() + saved := aliases + aliases = map[string]string{} + for name, value := range defs { + aliases[name] = value + } + aliasMu.Unlock() + t.Cleanup(func() { + aliasMu.Lock() + aliases = saved + aliasMu.Unlock() + }) +} + +func TestExpandAliases(t *testing.T) { + withAliases(t, map[string]string{ + "ll": "ls -l", + "ls": "ls --color", + "..": "cd ..", + "e": "echo ", + "w": "world", + "a": "b", + "b": "a", + "multi": "cd /tmp && ls", + }) + + tests := []struct { + input, want string + }{ + {"ll", "ls --color -l"}, + {" ll -a", " ls --color -l -a"}, + {"ls", "ls --color"}, + {"..", "cd .."}, + // Only the command name is replaced + {"echo ll", "echo ll"}, + {"ll ll", "ls --color -l ll"}, + // ... also after ;, &&, ||, | and & + {"ll; ll && ll || ll | ll & ll", "ls --color -l; ls --color -l && ls --color -l || ls --color -l | ls --color -l & ls --color -l"}, + // but not after redirections with & + {"ll 2>&1 ll", "ls --color -l 2>&1 ll"}, + {"ll &> ll", "ls --color -l &> ll"}, + // Quoted or escaped words are not replaced + {`\ll`, `\ll`}, + {`'ll'`, `'ll'`}, + {`"ll"`, `"ll"`}, + {`echo "a; ll"`, `echo "a; ll"`}, + {`echo 'a | ll'`, `echo 'a | ll'`}, + // A trailing blank makes the next word a command name too + {"e w", "echo world"}, + {"e e w", "echo echo world"}, + // Recursion stops at aliases that are already being expanded + {"a", "a"}, + {"b", "b"}, + // The value can contain several commands + {"multi", "cd /tmp && ls --color"}, + // Comments are left alone + {"echo x # ll", "echo x # ll"}, + } + for _, tc := range tests { + if got := expandAliases(tc.input); got != tc.want { + t.Errorf("expandAliases(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestBuiltinAlias(t *testing.T) { + withAliases(t, nil) + + run := func(args ...string) (string, error) { + var out bytes.Buffer + err := builtinAlias(append([]string{"alias"}, args...), &out) + return out.String(), err + } + + if _, err := run("ll=ls -l", "q=echo it's", "..=cd .."); err != nil { + t.Fatal(err) + } + out, _ := run() + want := "alias ..='cd ..'\nalias ll='ls -l'\nalias q='echo it'\\''s'\n" + if out != want { + t.Errorf("alias = %q, want %q", out, want) + } + if out, _ := run("ll"); out != "alias ll='ls -l'\n" { + t.Errorf("alias ll = %q", out) + } + if _, err := run("nope"); err == nil || err.Error() != "alias: nope: not found" { + t.Errorf("alias nope: %v", err) + } + for _, name := range []string{"a/b", "a=b", "$x", "a b", ""} { + if isAliasName(name) { + t.Errorf("isAliasName(%q) = true", name) + } + } + if _, err := run("a/b=x"); err == nil || !strings.Contains(err.Error(), "invalid alias name") { + t.Errorf("alias a/b=x: %v", err) + } + + if err := builtinUnalias([]string{"unalias", "ll", "nope"}); err == nil || err.Error() != "unalias: nope: not found" { + t.Errorf("unalias nope: %v", err) + } + if _, ok := lookupAlias("ll"); ok { + t.Error("ll still defined after unalias") + } + builtinUnalias([]string{"unalias", "-a"}) + if names := aliasNames(); len(names) != 0 { + t.Errorf("aliases after unalias -a: %q", names) + } +} + +func TestExecInput_Aliases(t *testing.T) { + withAliases(t, nil) + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + dir := t.TempDir() + os.Chdir(dir) + defer func(saved bool) { interactiveShell = saved }(interactiveShell) + + // Aliases are expanded in interactive shells only + interactiveShell = true + execInput(`alias say='echo hello' to=out`) + execInput("say world > out") + if got, _ := os.ReadFile("out"); string(got) != "hello world\n" { + t.Errorf("alias in interactive shell: out = %q", got) + } + + interactiveShell = false + os.Remove("out") + if err := execInput("say world > out"); err == nil { + t.Error("alias was expanded in a non-interactive shell") + } + + // alias works in pipelines (listing), unalias does not + interactiveShell = true + execInput("alias | grep say > out") + if got, _ := os.ReadFile(filepath.Join(dir, "out")); string(got) != "alias say='echo hello'\n" { + t.Errorf("alias | grep = %q", got) + } + if err := execInput("echo | unalias say"); err == nil { + t.Error("unalias in a pipeline must fail") + } +} + +func TestShellCompleter_Aliases(t *testing.T) { + withAliases(t, map[string]string{"gst": "git status"}) + t.Setenv("PATH", t.TempDir()) + got, length := completeLine("gs") + if !reflect.DeepEqual(got, []string{"t "}) || length != 2 { + t.Errorf("completion of gs = %q, %d", got, length) + } +} diff --git a/main.go b/main.go index 581cf67..cdc10ba 100644 --- a/main.go +++ b/main.go @@ -702,7 +702,7 @@ type shellCompleter struct { func newShellCompleter() *shellCompleter { return &shellCompleter{ - commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "help"}, + commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "help"}, } } @@ -726,7 +726,8 @@ func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) { // Complete commands var candidates [][]rune seen := make(map[string]bool) - for _, cmd := range append(c.commands, pathExecutables(word)...) { + names := append(append(append([]string(nil), c.commands...), aliasNames()...), pathExecutables(word)...) + for _, cmd := range names { if strings.HasPrefix(cmd, word) && !seen[cmd] { seen[cmd] = true candidates = append(candidates, []rune(escapeWord(cmd[len(word):])+" ")) @@ -951,6 +952,8 @@ flags: script = func() int { return runScript(os.Stdin, "") } } + // Aliases are expanded in interactive shells, also in their startup files + interactiveShell = interactiveMode for _, file := range startupFiles(login && !noProfile, interactiveMode && !noRC) { sourceStartupFile(file) } @@ -1327,6 +1330,9 @@ scan: // and ||. It returns the error of the last command that was run; errors of // earlier commands are printed. $? is updated after each command. func execInput(input string) error { + if interactiveShell { + input = expandAliases(input) + } items, err := splitList(input) if err != nil { lastStatus = exitStatus(err) @@ -1636,19 +1642,21 @@ func applyRedirects(fds *[3]*os.File, redirects []redirect) ([]*os.File, error) // the shell cannot be used in a pipeline, since there they would run // concurrently (sh runs them in a subshell where they have no effect). var builtins = map[string]struct{ changesState bool }{ - "cd": {changesState: true}, - "pwd": {}, - "exit": {changesState: true}, - "quit": {changesState: true}, - "set": {changesState: true}, - "export": {changesState: true}, - "help": {}, - "source": {changesState: true}, - ".": {changesState: true}, - "jobs": {}, - "fg": {changesState: true}, - "bg": {changesState: true}, - "wait": {changesState: true}, + "cd": {changesState: true}, + "pwd": {}, + "exit": {changesState: true}, + "quit": {changesState: true}, + "set": {changesState: true}, + "export": {changesState: true}, + "help": {}, + "source": {changesState: true}, + ".": {changesState: true}, + "jobs": {}, + "fg": {changesState: true}, + "bg": {changesState: true}, + "wait": {changesState: true}, + "alias": {}, + "unalias": {changesState: true}, } // runBuiltin executes a built-in command, writing its output to stdout. @@ -1728,6 +1736,10 @@ func runBuiltin(args []string, stdout io.Writer) error { } os.Setenv(parts[0], parts[1]) return nil + case "alias": + return builtinAlias(args, stdout) + case "unalias": + return builtinUnalias(args) case "jobs": return builtinJobs(stdout) case "fg": @@ -1758,6 +1770,8 @@ Built-in commands: fg [%n] - Continue job n (default: current job) in the foreground bg [%n] - Continue stopped job n in the background wait [%n] - Wait for job n or all background jobs + alias [name[=value] ...] - Define or show aliases (alias ll='ls -l') + unalias name - Remove an alias (unalias -a: all aliases) help - Show this help Features: @@ -1784,6 +1798,8 @@ Features: 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file - Job control: command & runs in the background, Ctrl+Z stops the foreground job; jobs, fg, bg and wait manage the jobs + - Aliases replace the first word of a command in interactive shells + (define them in ~/.goshrc); \name or 'name' skips the alias - Non-interactive use: gosh -c 'command', gosh script, command | gosh; the exit status is the one of the last command - Startup files: login shells (-l or argv[0] starting with -) read