- $(command) is replaced by the output of command, which runs in a subshell (gosh -c), so cd/set in it do not change the shell - trailing newlines are removed; unquoted the output is split into words at blanks and newlines, "$(...)" stays one word; wildcards in the output are not expanded - nested substitutions, quotes and operators inside are handled; the list, alias and brace scanners skip $(...) as a whole - a command that consists only of a substitution has its exit status, Ctrl+C in a substitution aborts the command - $((...)) is reported as not supported Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
202 lines
5.0 KiB
Go
202 lines
5.0 KiB
Go
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
|
|
}
|
|
}
|
|
|
|
if quote != '\'' && !escaped && isSubstStart(line, i) {
|
|
// $(...) is copied as it is; the subshell does not expand
|
|
// aliases
|
|
if end := substEnd(line, i); end >= 0 {
|
|
out.WriteString(line[i : end+1])
|
|
i = end
|
|
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
|
|
}
|