Compare commits

..
1 Commits
Author SHA1 Message Date
Florian WaltherandClaude Opus 5.5 187de30351 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 <noreply@anthropic.com>
2026-10-03 23:27:55 +02:00
4 changed files with 422 additions and 15 deletions

No files matched your search

+36
View File
@@ -17,6 +17,8 @@ Built-in commands:
fg [%n] - Continue job n (default: current job) in the foreground fg [%n] - Continue job n (default: current job) in the foreground
bg [%n] - Continue stopped job n in the background bg [%n] - Continue stopped job n in the background
wait [%n] - Wait for job n or all background jobs 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 help - Show this help
Features: Features:
@@ -43,6 +45,8 @@ Features:
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file
- Job control: command & runs in the background, Ctrl+Z stops the - Job control: command & runs in the background, Ctrl+Z stops the
foreground job; jobs, fg, bg and wait manage the jobs 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; - Non-interactive use: gosh -c 'command', gosh script, command | gosh;
the exit status is the one of the last command the exit status is the one of the last command
- Startup files: login shells (-l or argv[0] starting with -) read - 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 PATH=$HOME/bin:$HOME/go/bin:$PATH
export EDITOR=vim export EDITOR=vim
# Aliase
alias ll='ls -l'
alias ..='cd ..'
# Prompt-Farben (Anführungszeichen wegen des ;) # Prompt-Farben (Anführungszeichen wegen des ;)
export GOSH_COLORS='user=1;38;5;208:path=38;5;205' 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. - 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. - 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 ### Job-Control
In einer interaktiven Shell läuft jede Befehlszeile (bzw. jede Pipeline) als eigener *Job* in einer eigenen Prozessgruppe: In einer interaktiven Shell läuft jede Befehlszeile (bzw. jede Pipeline) als eigener *Job* in einer eigenen Prozessgruppe:
+191
View File
@@ -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
}
+164
View File
@@ -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)
}
}
+31 -15
View File
@@ -702,7 +702,7 @@ type shellCompleter struct {
func newShellCompleter() *shellCompleter { func newShellCompleter() *shellCompleter {
return &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 // Complete commands
var candidates [][]rune var candidates [][]rune
seen := make(map[string]bool) 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] { if strings.HasPrefix(cmd, word) && !seen[cmd] {
seen[cmd] = true seen[cmd] = true
candidates = append(candidates, []rune(escapeWord(cmd[len(word):])+" ")) candidates = append(candidates, []rune(escapeWord(cmd[len(word):])+" "))
@@ -951,6 +952,8 @@ flags:
script = func() int { return runScript(os.Stdin, "") } 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) { for _, file := range startupFiles(login && !noProfile, interactiveMode && !noRC) {
sourceStartupFile(file) sourceStartupFile(file)
} }
@@ -1327,6 +1330,9 @@ scan:
// and ||. It returns the error of the last command that was run; errors of // and ||. It returns the error of the last command that was run; errors of
// earlier commands are printed. $? is updated after each command. // earlier commands are printed. $? is updated after each command.
func execInput(input string) error { func execInput(input string) error {
if interactiveShell {
input = expandAliases(input)
}
items, err := splitList(input) items, err := splitList(input)
if err != nil { if err != nil {
lastStatus = exitStatus(err) 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 // 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). // concurrently (sh runs them in a subshell where they have no effect).
var builtins = map[string]struct{ changesState bool }{ var builtins = map[string]struct{ changesState bool }{
"cd": {changesState: true}, "cd": {changesState: true},
"pwd": {}, "pwd": {},
"exit": {changesState: true}, "exit": {changesState: true},
"quit": {changesState: true}, "quit": {changesState: true},
"set": {changesState: true}, "set": {changesState: true},
"export": {changesState: true}, "export": {changesState: true},
"help": {}, "help": {},
"source": {changesState: true}, "source": {changesState: true},
".": {changesState: true}, ".": {changesState: true},
"jobs": {}, "jobs": {},
"fg": {changesState: true}, "fg": {changesState: true},
"bg": {changesState: true}, "bg": {changesState: true},
"wait": {changesState: true}, "wait": {changesState: true},
"alias": {},
"unalias": {changesState: true},
} }
// runBuiltin executes a built-in command, writing its output to stdout. // 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]) os.Setenv(parts[0], parts[1])
return nil return nil
case "alias":
return builtinAlias(args, stdout)
case "unalias":
return builtinUnalias(args)
case "jobs": case "jobs":
return builtinJobs(stdout) return builtinJobs(stdout)
case "fg": case "fg":
@@ -1758,6 +1770,8 @@ Built-in commands:
fg [%n] - Continue job n (default: current job) in the foreground fg [%n] - Continue job n (default: current job) in the foreground
bg [%n] - Continue stopped job n in the background bg [%n] - Continue stopped job n in the background
wait [%n] - Wait for job n or all background jobs 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 help - Show this help
Features: Features:
@@ -1784,6 +1798,8 @@ Features:
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file
- Job control: command & runs in the background, Ctrl+Z stops the - Job control: command & runs in the background, Ctrl+Z stops the
foreground job; jobs, fg, bg and wait manage the jobs 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; - Non-interactive use: gosh -c 'command', gosh script, command | gosh;
the exit status is the one of the last command the exit status is the one of the last command
- Startup files: login shells (-l or argv[0] starting with -) read - Startup files: login shells (-l or argv[0] starting with -) read