Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2246 lines
64 KiB
Go
2246 lines
64 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"os/user"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/chzyer/readline"
|
|
)
|
|
|
|
// ErrNoPath is returned when 'cd' was called without a second argument.
|
|
var ErrNoPath = errors.New("path required")
|
|
|
|
// Build information, set at build time via
|
|
// -ldflags "-X main.version=... -X main.commit=... -X main.branch=...".
|
|
var (
|
|
version = "dev"
|
|
commit = ""
|
|
branch = ""
|
|
)
|
|
|
|
// versionInfo returns the version including branch and commit, e.g.
|
|
// "0.2.1 (branch: main, commit: 03e791c)". Without ldflags (plain go build)
|
|
// the commit is taken from the VCS information that Go embeds in the binary.
|
|
func versionInfo() string {
|
|
c := commit
|
|
if c == "" {
|
|
if info, ok := debug.ReadBuildInfo(); ok {
|
|
var dirty bool
|
|
for _, s := range info.Settings {
|
|
switch s.Key {
|
|
case "vcs.revision":
|
|
c = s.Value
|
|
case "vcs.modified":
|
|
dirty = s.Value == "true"
|
|
}
|
|
}
|
|
if len(c) > 7 {
|
|
c = c[:7]
|
|
}
|
|
if c != "" && dirty {
|
|
c += "-dirty"
|
|
}
|
|
}
|
|
}
|
|
|
|
var details []string
|
|
if branch != "" {
|
|
details = append(details, "branch: "+branch)
|
|
}
|
|
if c != "" {
|
|
details = append(details, "commit: "+c)
|
|
}
|
|
if len(details) == 0 {
|
|
return version
|
|
}
|
|
return version + " (" + strings.Join(details, ", ") + ")"
|
|
}
|
|
|
|
// previousDir tracks the previous working directory for 'cd -' support
|
|
var previousDir string
|
|
|
|
// getHomeDir returns the user's home directory: $HOME like in sh, or the
|
|
// home directory from the user database if $HOME is not set
|
|
func getHomeDir() string {
|
|
if home := os.Getenv("HOME"); home != "" {
|
|
return home
|
|
}
|
|
usr, err := user.Current()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return usr.HomeDir
|
|
}
|
|
|
|
// expandTilde expands ~ to the home directory and ~user to user's home
|
|
func expandTilde(path string) string {
|
|
if len(path) == 0 {
|
|
return path
|
|
}
|
|
|
|
// Handle ~ at the start
|
|
if strings.HasPrefix(path, "~") {
|
|
home := getHomeDir()
|
|
if home == "" {
|
|
return path // Can't expand, return as-is
|
|
}
|
|
|
|
// Handle ~ (just home)
|
|
if len(path) == 1 {
|
|
return home
|
|
}
|
|
|
|
// Handle ~/path or ~user/path
|
|
// For now, just handle ~/path
|
|
if path[1] == '/' {
|
|
return filepath.Join(home, path[2:])
|
|
}
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
// token is a word or an operator of a command line.
|
|
type token struct {
|
|
op string // "" for a word, otherwise |, <, >, >>, >&, <&, &> or &>>
|
|
word string // the word, if op == ""
|
|
pattern string // the word as glob pattern (quoted characters escaped)
|
|
glob bool // the word contains unquoted * ? or [ and is expanded
|
|
assign bool // the word is an assignment name=value (unquoted name and =)
|
|
fd int // the redirected file descriptor (for redirections)
|
|
dup int // the target file descriptor of >& and <&
|
|
}
|
|
|
|
// tokenize splits input into words and operators like a (very) simple POSIX
|
|
// shell:
|
|
// - words are separated by unquoted blanks, an unquoted # starts a comment
|
|
// - '...' is taken literally, nothing is expanded inside
|
|
// - "..." expands $VAR and ${VAR}; \ only escapes $ " \ and `
|
|
// - outside quotes \ escapes any character
|
|
// - $(command) is replaced by the output of command (see commandSubst);
|
|
// unquoted, the output is split into words at blanks and newlines
|
|
// - $VAR, ${VAR} and ${VAR:-default} are expanded outside single quotes;
|
|
// an unset variable is an error, so that e.g. "rm -rf $DIR/x" never runs
|
|
// with an empty $DIR
|
|
// - ~ and ~/... are expanded at the start of an unquoted word, and after
|
|
// the = and : of an assignment (PATH=~/bin:~/go/bin)
|
|
// - unquoted |, <, >, >>, N<, N>, N>>, N>&M, N<&M, &> and &>> are operators
|
|
//
|
|
// Expanded values are never split into several words.
|
|
func tokenize(input string) ([]token, error) {
|
|
var (
|
|
tokens []token
|
|
b strings.Builder
|
|
pat strings.Builder // the word as glob pattern, quoted parts escaped
|
|
glob bool // the word contains unquoted * ? or [
|
|
inWord bool // a word has been started (even if it is still empty)
|
|
plain = true // the word so far has no quotes, escapes or expansions
|
|
assign bool // the word is an assignment name=value
|
|
tildeOK bool // ~ may be expanded here (after = or : of an assignment)
|
|
noParams bool // the word contains "$@" without positional parameters
|
|
quote byte
|
|
escaped bool
|
|
rawStart = -1 // start of the current word in input
|
|
)
|
|
|
|
endWord := func() {
|
|
// "$@" without parameters is no word at all, unlike ""
|
|
if inWord && !(noParams && b.Len() == 0) {
|
|
tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob, assign: assign})
|
|
}
|
|
b.Reset()
|
|
pat.Reset()
|
|
glob, assign, plain, noParams = false, false, true, false
|
|
inWord = false
|
|
rawStart = -1
|
|
}
|
|
// literal adds quoted, escaped or expanded text: it is never a pattern
|
|
literal := func(s string) {
|
|
plain = false
|
|
b.WriteString(s)
|
|
pat.WriteString(escapeGlob(s))
|
|
}
|
|
startRaw := func(i int) {
|
|
if rawStart < 0 {
|
|
rawStart = i
|
|
}
|
|
}
|
|
|
|
for i := 0; i < len(input); i++ {
|
|
ch := input[i]
|
|
afterAssignSep := tildeOK
|
|
tildeOK = false
|
|
switch {
|
|
case escaped:
|
|
if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) {
|
|
literal("\\")
|
|
}
|
|
literal(input[i : i+1])
|
|
inWord = true
|
|
escaped = false
|
|
case quote == '\'':
|
|
if ch == '\'' {
|
|
quote = 0
|
|
} else {
|
|
literal(input[i : i+1])
|
|
}
|
|
case ch == '\\':
|
|
startRaw(i)
|
|
escaped = true
|
|
case arithEnd(input, i) >= 0:
|
|
// $((expression)): the value is one word
|
|
startRaw(i)
|
|
value, n, err := expandDollar(input[i:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
literal(value)
|
|
inWord = true
|
|
i += n - 1
|
|
case isSubstStart(input, i):
|
|
startRaw(i)
|
|
end := substEnd(input, i)
|
|
if end < 0 {
|
|
return nil, errors.New("missing ) in command substitution")
|
|
}
|
|
out, err := commandSubst(input[i+2 : end])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if quote == '"' || assign {
|
|
// Quoted or in an assignment, the output is one word
|
|
literal(out)
|
|
inWord = true
|
|
} else {
|
|
// Unquoted, the output is split into words at blanks and
|
|
// newlines (but, unlike in sh, wildcards in it are not
|
|
// expanded)
|
|
for k, field := range strings.Fields(out) {
|
|
if k > 0 {
|
|
endWord()
|
|
}
|
|
literal(field)
|
|
inWord = true
|
|
}
|
|
}
|
|
i = end
|
|
case ch == '$' && !assign && positionalWords(input[i:], quote == '"') > 0:
|
|
// $@ and "$@": one word per positional parameter
|
|
startRaw(i)
|
|
if len(positional) == 0 && quote == '"' {
|
|
noParams = true
|
|
}
|
|
for k, param := range positional {
|
|
if k > 0 {
|
|
endWord()
|
|
}
|
|
literal(param)
|
|
inWord = true
|
|
}
|
|
i += positionalWords(input[i:], quote == '"') - 1
|
|
case ch == '$':
|
|
startRaw(i)
|
|
value, n, err := expandVar(input[i:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Expanded values are never patterns (and never split), so that
|
|
// e.g. a * in a variable does not match files
|
|
literal(value)
|
|
// A literal "$" counts as content, an expansion that is empty does
|
|
// not (unless quoted), like in sh
|
|
if n == 1 || value != "" {
|
|
inWord = true
|
|
}
|
|
i += n - 1
|
|
case quote == '"':
|
|
if ch == '"' {
|
|
quote = 0
|
|
} else {
|
|
literal(input[i : i+1])
|
|
}
|
|
case ch == '"' || ch == '\'':
|
|
startRaw(i)
|
|
plain = false
|
|
quote = ch
|
|
inWord = true
|
|
case ch == ' ' || ch == '\t' || ch == '\n':
|
|
endWord()
|
|
case ch == '#' && !inWord && b.Len() == 0:
|
|
// Comment until the end of the line
|
|
i = len(input)
|
|
case ch == '|':
|
|
endWord()
|
|
tokens = append(tokens, token{op: "|"})
|
|
case ch == '&' && i+1 < len(input) && input[i+1] == '>':
|
|
// &> and &>> redirect stdout and stderr
|
|
endWord()
|
|
op := "&>"
|
|
if i+2 < len(input) && input[i+2] == '>' {
|
|
op = "&>>"
|
|
}
|
|
tokens = append(tokens, token{op: op, fd: 1})
|
|
i += len(op) - 1
|
|
case ch == '<' || ch == '>':
|
|
// A word consisting only of unquoted digits right before the
|
|
// operator is the file descriptor, e.g. 2>file
|
|
fd := 0
|
|
if ch == '>' {
|
|
fd = 1
|
|
}
|
|
if inWord && rawStart >= 0 && isDigits(input[rawStart:i]) {
|
|
fd, _ = strconv.Atoi(input[rawStart:i])
|
|
b.Reset()
|
|
pat.Reset()
|
|
glob = false
|
|
inWord = false
|
|
rawStart = -1
|
|
} else {
|
|
endWord()
|
|
}
|
|
t := token{op: string(ch), fd: fd}
|
|
switch {
|
|
case strings.HasPrefix(input[i:], "<<<"):
|
|
// <<<word: the word (and a newline) is the input
|
|
t.op = "<<<"
|
|
i += 2
|
|
case ch == '>' && i+1 < len(input) && input[i+1] == '>':
|
|
t.op = ">>"
|
|
i++
|
|
case i+1 < len(input) && input[i+1] == '&':
|
|
// N>&M and N<&M duplicate file descriptor M
|
|
j := i + 2
|
|
for j < len(input) && input[j] >= '0' && input[j] <= '9' {
|
|
j++
|
|
}
|
|
if j == i+2 {
|
|
return nil, syntaxError(input[i : i+2])
|
|
}
|
|
t.op = string(ch) + "&"
|
|
t.dup, _ = strconv.Atoi(input[i+2 : j])
|
|
i = j - 1
|
|
}
|
|
tokens = append(tokens, t)
|
|
case ch == '~' && (!inWord && b.Len() == 0 || afterAssignSep) &&
|
|
(i+1 == len(input) || strings.IndexByte("/ \t\n|<>:", input[i+1]) >= 0):
|
|
startRaw(i)
|
|
home := getHomeDir()
|
|
if home == "" {
|
|
home = "~"
|
|
}
|
|
literal(home)
|
|
inWord = true
|
|
default:
|
|
startRaw(i)
|
|
switch {
|
|
case ch == '=' && plain && !assign && isVarName(b.String()):
|
|
// name=value; ~ may follow
|
|
assign, tildeOK = true, true
|
|
case ch == ':' && assign:
|
|
// PATH=~/bin:~/go/bin
|
|
tildeOK = true
|
|
}
|
|
b.WriteByte(ch)
|
|
switch {
|
|
case ch == '*' || ch == '?' || ch == '[':
|
|
glob = true
|
|
pat.WriteByte(ch)
|
|
case ch == '!' && strings.HasSuffix(pat.String(), "[") && !strings.HasSuffix(pat.String(), "\\["):
|
|
// POSIX [!...] is [^...] in Go
|
|
pat.WriteByte('^')
|
|
default:
|
|
pat.WriteByte(ch)
|
|
}
|
|
inWord = true
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case escaped:
|
|
return nil, errors.New("unexpected end of input after \\")
|
|
case quote != 0:
|
|
return nil, fmt.Errorf("unterminated %c quote", quote)
|
|
}
|
|
endWord()
|
|
return tokens, nil
|
|
}
|
|
|
|
// escapeGlob escapes the characters that are special in a glob pattern.
|
|
func escapeGlob(s string) string {
|
|
var b strings.Builder
|
|
for i := 0; i < len(s); i++ {
|
|
if strings.IndexByte("*?[]\\", s[i]) >= 0 {
|
|
b.WriteByte('\\')
|
|
}
|
|
b.WriteByte(s[i])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// hasGlobMeta reports whether pattern contains an unescaped * ? or [.
|
|
func hasGlobMeta(pattern string) bool {
|
|
for i := 0; i < len(pattern); i++ {
|
|
switch pattern[i] {
|
|
case '\\':
|
|
i++
|
|
case '*', '?', '[':
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// unescapeGlob removes the escapes from a pattern without wildcards.
|
|
func unescapeGlob(pattern string) string {
|
|
var b strings.Builder
|
|
for i := 0; i < len(pattern); i++ {
|
|
if pattern[i] == '\\' && i+1 < len(pattern) {
|
|
i++
|
|
}
|
|
b.WriteByte(pattern[i])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// expandGlob returns the paths matching pattern, sorted. Like sh, * and ?
|
|
// do not match a leading dot (hidden files), unless the pattern component
|
|
// starts with a dot, and a trailing / only matches directories. An invalid
|
|
// pattern matches nothing.
|
|
func expandGlob(pattern string) []string {
|
|
paths := []string{""}
|
|
if strings.HasPrefix(pattern, "/") {
|
|
paths = []string{"/"}
|
|
}
|
|
components := strings.Split(strings.Trim(pattern, "/"), "/")
|
|
join := func(dir, name string) string {
|
|
if dir == "" || strings.HasSuffix(dir, "/") {
|
|
return dir + name
|
|
}
|
|
return dir + "/" + name
|
|
}
|
|
|
|
for _, comp := range components {
|
|
if comp == "" {
|
|
continue // a//b
|
|
}
|
|
var next []string
|
|
for _, dir := range paths {
|
|
if !hasGlobMeta(comp) {
|
|
p := join(dir, unescapeGlob(comp))
|
|
if _, err := os.Lstat(p); err == nil {
|
|
next = append(next, p)
|
|
}
|
|
continue
|
|
}
|
|
readDir := dir
|
|
if readDir == "" {
|
|
readDir = "."
|
|
}
|
|
entries, err := os.ReadDir(readDir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if strings.HasPrefix(name, ".") && !strings.HasPrefix(comp, ".") && !strings.HasPrefix(comp, "\\.") {
|
|
continue
|
|
}
|
|
ok, err := filepath.Match(comp, name)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if ok {
|
|
next = append(next, join(dir, name))
|
|
}
|
|
}
|
|
}
|
|
paths = next
|
|
}
|
|
|
|
if strings.HasSuffix(pattern, "/") {
|
|
var dirs []string
|
|
for _, p := range paths {
|
|
if info, err := os.Stat(p); err == nil && info.IsDir() {
|
|
dirs = append(dirs, p+"/")
|
|
}
|
|
}
|
|
paths = dirs
|
|
}
|
|
sort.Strings(paths)
|
|
return paths
|
|
}
|
|
|
|
// expandWord returns the words a token expands to: the matching paths for
|
|
// a pattern, or the word itself if it is no pattern or nothing matches (like
|
|
// in sh).
|
|
func expandWord(t token) []string {
|
|
if t.glob {
|
|
if matches := expandGlob(t.pattern); len(matches) > 0 {
|
|
return matches
|
|
}
|
|
}
|
|
return []string{t.word}
|
|
}
|
|
|
|
// isDigits reports whether s is a non-empty string of ASCII digits.
|
|
func isDigits(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] < '0' || s[i] > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// splitWords splits input into words (see tokenize). Operators are an error.
|
|
func splitWords(input string) ([]string, error) {
|
|
tokens, err := tokenize(input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var words []string
|
|
for _, t := range tokens {
|
|
if t.op != "" {
|
|
return nil, fmt.Errorf("unexpected operator %s", t.op)
|
|
}
|
|
words = append(words, t.word)
|
|
}
|
|
return words, nil
|
|
}
|
|
|
|
// expandVar expands the variable reference at the start of s (which starts
|
|
// with "$"). It returns the value and the number of bytes consumed. A "$" that
|
|
// is not followed by a variable name is returned literally.
|
|
func expandVar(s string) (string, int, error) {
|
|
if len(s) > 1 && s[1] == '{' {
|
|
end := paramEnd(s, 0)
|
|
if end < 0 {
|
|
return "", 0, errors.New("missing } in variable reference")
|
|
}
|
|
value, err := expandParam(s[2:end])
|
|
return value, end + 1, err
|
|
}
|
|
|
|
// $?, $#, $$, $!, $@, $*, $0 and $1 to $9 ($10 is $1 followed by 0)
|
|
if len(s) > 1 && (isSpecialParam(s[1:2]) || isDigits(s[1:2])) {
|
|
value, ok := lookupParam(s[1:2])
|
|
if !ok {
|
|
return "", 0, fmt.Errorf("%s: unbound variable", s[1:2])
|
|
}
|
|
return value, 2, nil
|
|
}
|
|
|
|
n := 1
|
|
for n < len(s) && isVarNameByte(s[n], n == 1) {
|
|
n++
|
|
}
|
|
if n == 1 {
|
|
return "$", 1, nil
|
|
}
|
|
value, ok := os.LookupEnv(s[1:n])
|
|
if !ok {
|
|
return "", 0, fmt.Errorf("%s: unbound variable", s[1:n])
|
|
}
|
|
return value, n, nil
|
|
}
|
|
|
|
// isSpecialParam reports whether name is one of the special parameters ?,
|
|
// #, $, !, @ and *.
|
|
func isSpecialParam(name string) bool {
|
|
return len(name) == 1 && strings.Contains("?#$!@*", name)
|
|
}
|
|
|
|
// positionalWords returns the number of bytes of $@ or ${@} at the start of
|
|
// s, which expands to one word per positional parameter (also in double
|
|
// quotes). Unquoted, $* and ${*} do the same (gosh never splits values).
|
|
// It returns 0 for anything else.
|
|
func positionalWords(s string, quoted bool) int {
|
|
for _, p := range []string{"$@", "${@}", "$*", "${*}"} {
|
|
if strings.HasPrefix(s, p) && (strings.Contains(p, "@") || !quoted) {
|
|
return len(p)
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func isVarName(name string) bool {
|
|
if name == "" {
|
|
return false
|
|
}
|
|
for i := 0; i < len(name); i++ {
|
|
if !isVarNameByte(name[i], i == 0) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isVarNameByte(c byte, first bool) bool {
|
|
return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' ||
|
|
!first && c >= '0' && c <= '9'
|
|
}
|
|
|
|
// sanitize replaces non-printable characters (e.g. terminal escape sequences
|
|
// in directory names) with a visible escape so they cannot manipulate the
|
|
// terminal.
|
|
func sanitize(s string) string {
|
|
var b strings.Builder
|
|
for i := 0; i < len(s); {
|
|
r, size := utf8.DecodeRuneInString(s[i:])
|
|
switch {
|
|
case r == utf8.RuneError && size == 1:
|
|
fmt.Fprintf(&b, "\\x%02x", s[i])
|
|
case !unicode.IsPrint(r):
|
|
if r < 0x100 {
|
|
fmt.Fprintf(&b, "\\x%02x", r)
|
|
} else {
|
|
fmt.Fprintf(&b, "\\u%04x", r)
|
|
}
|
|
default:
|
|
b.WriteRune(r)
|
|
}
|
|
i += size
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// isPrintable reports whether s only consists of printable characters
|
|
func isPrintable(s string) bool {
|
|
return sanitize(s) == s
|
|
}
|
|
|
|
// historyFile returns the path of the history file: $GOSH_HISTFILE if set,
|
|
// otherwise $XDG_STATE_HOME/gosh/history (default ~/.local/state/gosh/history).
|
|
// An empty string disables the history.
|
|
func historyFile() string {
|
|
if path, ok := os.LookupEnv("GOSH_HISTFILE"); ok {
|
|
return path
|
|
}
|
|
state := os.Getenv("XDG_STATE_HOME")
|
|
if state == "" {
|
|
home := getHomeDir()
|
|
if home == "" {
|
|
return ""
|
|
}
|
|
state = filepath.Join(home, ".local", "state")
|
|
}
|
|
return filepath.Join(state, "gosh", "history")
|
|
}
|
|
|
|
// prepareHistoryFile makes sure that the history file and its directory are
|
|
// only accessible by the current user. readline itself creates the file (and
|
|
// a temporary file next to it) with mode 0666 & ~umask.
|
|
func prepareHistoryFile(path string) error {
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return err
|
|
}
|
|
// Only restrict the directory if it is ours (and not e.g. $HOME itself)
|
|
if filepath.Base(dir) == "gosh" {
|
|
if err := os.Chmod(dir, 0700); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
return f.Chmod(0600)
|
|
}
|
|
|
|
// getPrompt returns the expanded $PS1, or if it is not set the default
|
|
// prompt "user@host:dir $".
|
|
func getPrompt() string {
|
|
if prompt, ok := promptString("PS1"); ok {
|
|
return prompt
|
|
}
|
|
|
|
// Get current working directory
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
wd = "?"
|
|
}
|
|
|
|
// Get username
|
|
usr, err := user.Current()
|
|
username := "?"
|
|
if err == nil && usr.Username != "" {
|
|
username = usr.Username
|
|
}
|
|
|
|
// Get hostname
|
|
hostname, err := os.Hostname()
|
|
if err != nil {
|
|
hostname = "unknown"
|
|
}
|
|
|
|
// Replace the home directory with ~
|
|
wd = tildePath(wd, getHomeDir())
|
|
|
|
var colors *promptColors
|
|
if useColor() {
|
|
c := parseColors(os.Getenv("GOSH_COLORS"))
|
|
colors = &c
|
|
}
|
|
return buildPrompt(username, hostname, wd, os.Geteuid() == 0, lastStatus, colors)
|
|
}
|
|
|
|
// promptColors holds the SGR parameters (e.g. "1;32") for the parts of the
|
|
// prompt. An empty value means no color for that part.
|
|
type promptColors struct {
|
|
user string // user@host
|
|
root string // user@host when running as root
|
|
path string // working directory
|
|
error string // exit status of the last command, if it failed
|
|
}
|
|
|
|
// defaultColors: bold green user, bold red root, bold blue directory, bold
|
|
// red exit status
|
|
var defaultColors = promptColors{user: "1;32", root: "1;31", path: "1;34", error: "1;31"}
|
|
|
|
// parseColors reads a GOSH_COLORS value like "user=1;33:path=36:error=31"
|
|
// and overrides the corresponding defaults. Unknown keys and values that are
|
|
// not plain SGR parameters (digits and ';') are ignored, so the variable
|
|
// cannot be used to write arbitrary escape sequences to the terminal.
|
|
func parseColors(spec string) promptColors {
|
|
colors := defaultColors
|
|
for _, entry := range strings.Split(spec, ":") {
|
|
key, value, ok := strings.Cut(entry, "=")
|
|
if !ok || !isSGR(value) {
|
|
continue
|
|
}
|
|
switch key {
|
|
case "user":
|
|
colors.user = value
|
|
case "root":
|
|
colors.root = value
|
|
case "path":
|
|
colors.path = value
|
|
case "error":
|
|
colors.error = value
|
|
}
|
|
}
|
|
return colors
|
|
}
|
|
|
|
// isSGR reports whether s consists only of digits and ';'. The empty string
|
|
// is allowed and disables the color.
|
|
func isSGR(s string) bool {
|
|
for _, r := range s {
|
|
if (r < '0' || r > '9') && r != ';' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// colorize wraps text in the SGR sequence for sgr, or returns it unchanged if
|
|
// sgr is empty.
|
|
func colorize(text, sgr string) string {
|
|
if sgr == "" {
|
|
return text
|
|
}
|
|
return "\x1b[" + sgr + "m" + text + "\x1b[0m"
|
|
}
|
|
|
|
// buildPrompt formats the prompt as user@host:path $, or user@host:path [N] $
|
|
// if the last command failed with exit status N. The parts are sanitized
|
|
// before the color codes are added, so only our own escape sequences end up
|
|
// in the prompt. colors == nil disables colors.
|
|
func buildPrompt(username, hostname, path string, root bool, status int, colors *promptColors) string {
|
|
userHost := sanitize(username + "@" + hostname)
|
|
path = sanitize(path)
|
|
var failed string
|
|
if status != 0 {
|
|
failed = " [" + strconv.Itoa(status) + "]"
|
|
}
|
|
if colors == nil {
|
|
return userHost + ":" + path + failed + " $ "
|
|
}
|
|
userColor := colors.user
|
|
if root {
|
|
userColor = colors.root
|
|
}
|
|
if failed != "" {
|
|
failed = " " + colorize(failed[1:], colors.error)
|
|
}
|
|
return colorize(userHost, userColor) + ":" + colorize(path, colors.path) + failed + " $ "
|
|
}
|
|
|
|
// useColor reports whether the prompt should be colored: stdout must be a
|
|
// terminal, TERM must not be "dumb" and NO_COLOR (https://no-color.org) must
|
|
// not be set.
|
|
func useColor() bool {
|
|
if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" {
|
|
return false
|
|
}
|
|
return readline.IsTerminal(int(os.Stdout.Fd()))
|
|
}
|
|
|
|
// reportCwd tells the terminal the current working directory with the OSC 7
|
|
// escape sequence, so that new tabs or windows open in the same directory
|
|
// (VTE terminals like GNOME Terminal, kitty, WezTerm, foot, iTerm2, ...).
|
|
// Terminals without support ignore it. Nothing is written if stdout is not a
|
|
// terminal or TERM is "dumb".
|
|
func reportCwd() {
|
|
if os.Getenv("TERM") == "dumb" || !readline.IsTerminal(int(os.Stdout.Fd())) {
|
|
return
|
|
}
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
return
|
|
}
|
|
hostname, _ := os.Hostname()
|
|
fmt.Fprint(os.Stdout, osc7Sequence(hostname, wd))
|
|
}
|
|
|
|
// osc7Sequence returns ESC ] 7 ; file://host/path BEL. Host and path are
|
|
// percent-encoded, so control characters in a directory name cannot end the
|
|
// sequence early or inject other escape sequences.
|
|
func osc7Sequence(hostname, dir string) string {
|
|
return "\x1b]7;file://" + percentEncode(hostname, false) + percentEncode(dir, true) + "\a"
|
|
}
|
|
|
|
// percentEncode encodes every byte except unreserved URI characters (and '/'
|
|
// if keepSlash) as %XX.
|
|
func percentEncode(s string, keepSlash bool) string {
|
|
const hex = "0123456789ABCDEF"
|
|
var b strings.Builder
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
|
|
c == '-' || c == '.' || c == '_' || c == '~' || c == '/' && keepSlash {
|
|
b.WriteByte(c)
|
|
} else {
|
|
b.WriteByte('%')
|
|
b.WriteByte(hex[c>>4])
|
|
b.WriteByte(hex[c&15])
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// shellCompleter implements readline.AutoCompleter for contextual completion
|
|
type shellCompleter struct {
|
|
commands []string
|
|
}
|
|
|
|
func newShellCompleter() *shellCompleter {
|
|
return &shellCompleter{
|
|
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "break", "continue", "unset", "help", "return", "local", "shift", "read"},
|
|
}
|
|
}
|
|
|
|
// Do implements readline.AutoCompleter interface.
|
|
// readline expects the candidates to contain only the text that still has to
|
|
// be inserted after the cursor, and the returned length to be the number of
|
|
// runes of the current word that are already typed.
|
|
func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
|
|
word, start, quote := currentWord(string(line[:pos]))
|
|
// A command is expected at the start and after |, ;, && and ||
|
|
before := strings.TrimSpace(string(line[:start]))
|
|
isFirstWord := before == "" || strings.HasSuffix(before, "|") ||
|
|
strings.HasSuffix(before, ";") || strings.HasSuffix(before, "&&")
|
|
// ... and after if, then, do etc.
|
|
if fields := strings.Fields(before); len(fields) > 0 {
|
|
switch fields[len(fields)-1] {
|
|
case "if", "then", "elif", "else", "do", "while", "until", "!", "{", ";;":
|
|
isFirstWord = true
|
|
}
|
|
}
|
|
|
|
// Plain "~" -> "~/"
|
|
if word == "~" {
|
|
return [][]rune{[]rune("/")}, 1
|
|
}
|
|
|
|
if isFirstWord && !strings.Contains(word, "/") && !strings.HasPrefix(word, "~") {
|
|
// Complete commands
|
|
var candidates [][]rune
|
|
seen := make(map[string]bool)
|
|
names := append(append(append(append([]string(nil), c.commands...), aliasNames()...), functionNames()...), pathExecutables(word)...)
|
|
for _, cmd := range names {
|
|
if strings.HasPrefix(cmd, word) && !seen[cmd] {
|
|
seen[cmd] = true
|
|
candidates = append(candidates, []rune(escapeWord(cmd[len(word):])+" "))
|
|
}
|
|
}
|
|
return candidates, len([]rune(word))
|
|
}
|
|
|
|
// Complete file paths; only the part after the last slash is completed
|
|
pattern := word[strings.LastIndex(word, "/")+1:]
|
|
matches := completeFilePath(string(line[:pos]), word)
|
|
var candidates [][]rune
|
|
for _, m := range matches {
|
|
rest := m[len(pattern):]
|
|
if quote == 0 {
|
|
rest = escapeWord(rest)
|
|
}
|
|
candidates = append(candidates, []rune(rest))
|
|
}
|
|
return candidates, len([]rune(pattern))
|
|
}
|
|
|
|
// pathExecutables returns the names of all executables in $PATH starting
|
|
// with prefix
|
|
func pathExecutables(prefix string) []string {
|
|
var names []string
|
|
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
|
|
if dir == "" {
|
|
dir = "."
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if !strings.HasPrefix(name, prefix) || !isPrintable(name) {
|
|
continue
|
|
}
|
|
// Follow symlinks, only regular files with an executable bit count
|
|
info, err := os.Stat(filepath.Join(dir, name))
|
|
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0111 == 0 {
|
|
continue
|
|
}
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
// currentWord returns the (unescaped, unquoted) word the cursor is in, the
|
|
// byte offset where it starts in s and the quote character that is still open
|
|
// (0 if none).
|
|
func currentWord(s string) (word string, start int, quote byte) {
|
|
var b strings.Builder
|
|
escaped := false
|
|
for i := 0; i < len(s); i++ {
|
|
ch := s[i]
|
|
switch {
|
|
case escaped:
|
|
b.WriteByte(ch)
|
|
escaped = false
|
|
case ch == '\\' && quote != '\'':
|
|
escaped = true
|
|
case quote != 0:
|
|
if ch == quote {
|
|
quote = 0
|
|
} else {
|
|
b.WriteByte(ch)
|
|
}
|
|
case ch == '"' || ch == '\'':
|
|
quote = ch
|
|
case ch == ' ' || ch == '\t' || strings.IndexByte(";|&<>", ch) >= 0:
|
|
// Blanks and operators end a word
|
|
b.Reset()
|
|
start = i + 1
|
|
default:
|
|
b.WriteByte(ch)
|
|
}
|
|
}
|
|
return b.String(), start, quote
|
|
}
|
|
|
|
// escapeWord escapes characters that would otherwise be interpreted by the
|
|
// shell. # and ~ are only special at the start of a word, so they are only
|
|
// escaped at the start of s.
|
|
func escapeWord(s string) string {
|
|
var b strings.Builder
|
|
for i, r := range s {
|
|
if strings.ContainsRune(" \t\\'\"`;|&<>()*?[]{}$!", r) || (i == 0 && (r == '#' || r == '~')) {
|
|
b.WriteByte('\\')
|
|
}
|
|
b.WriteRune(r)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// completeFilePath handles file path completion.
|
|
// prefix is the unquoted path typed so far. It returns the names (with a
|
|
// trailing "/" for directories) of all entries in the directory part of
|
|
// prefix that start with the part after the last slash.
|
|
func completeFilePath(line, prefix string) []string {
|
|
// Split textually so that "." or "~" are not normalized away
|
|
slash := strings.LastIndex(prefix, "/")
|
|
dir, pattern := prefix[:slash+1], prefix[slash+1:]
|
|
|
|
switch {
|
|
case dir == "":
|
|
dir = "."
|
|
case strings.HasPrefix(dir, "~"):
|
|
dir = expandTilde(dir)
|
|
}
|
|
|
|
// Read the directory contents (relative paths resolve against the cwd)
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Collect matching entries
|
|
var matches []string
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if !strings.HasPrefix(name, pattern) {
|
|
continue
|
|
}
|
|
// Hide dotfiles unless explicitly asked for
|
|
if strings.HasPrefix(name, ".") && !strings.HasPrefix(pattern, ".") {
|
|
continue
|
|
}
|
|
// Never insert control characters (e.g. escape sequences) into the line
|
|
if !isPrintable(name) {
|
|
continue
|
|
}
|
|
// Follow symlinks to decide whether this is a directory
|
|
if info, err := os.Stat(filepath.Join(dir, name)); err == nil && info.IsDir() {
|
|
name += "/"
|
|
}
|
|
matches = append(matches, name)
|
|
}
|
|
|
|
return matches
|
|
}
|
|
|
|
const usage = `usage: gosh [-l] [-i] [-ex] [-o option] [--norc] [--noprofile] [script [args ...]]
|
|
gosh [-l] [-ex] [-o option] [--noprofile] -c command [name [args ...]]
|
|
gosh -version`
|
|
|
|
// sysConfDir holds the system-wide startup files.
|
|
var sysConfDir = "/etc/gosh"
|
|
|
|
func main() {
|
|
args := os.Args[1:]
|
|
// login(1), sshd and "su -" start a login shell with a leading - in
|
|
// argv[0], e.g. "-gosh"
|
|
if strings.HasPrefix(os.Args[0], "-") {
|
|
args = append([]string{"-l"}, args...)
|
|
}
|
|
scriptName = os.Args[0]
|
|
os.Exit(run(args))
|
|
}
|
|
|
|
// run parses the command line and runs gosh in the matching mode. It returns
|
|
// the exit status of the shell.
|
|
func run(args []string) int {
|
|
var login, forceInteractive, noRC, noProfile bool
|
|
flags:
|
|
for len(args) > 0 {
|
|
switch args[0] {
|
|
case "-l", "--login":
|
|
login = true
|
|
case "-i":
|
|
// Interactive mode even if stdin is not a terminal
|
|
forceInteractive = true
|
|
case "--norc":
|
|
noRC = true
|
|
case "--noprofile":
|
|
noProfile = true
|
|
case "-o", "+o":
|
|
// -o pipefail etc., like set
|
|
if len(args) < 2 {
|
|
fmt.Fprintln(os.Stderr, "gosh: -o: option requires an argument")
|
|
return 2
|
|
}
|
|
if err := setOption(0, args[1], args[0] == "-o"); err != nil {
|
|
fmt.Fprintln(os.Stderr, "gosh:", err)
|
|
return 2
|
|
}
|
|
args = args[1:]
|
|
default:
|
|
// -e, -x and combinations like -ex, like set
|
|
if len(args[0]) > 1 && args[0][0] == '-' && strings.Trim(args[0][1:], "ex") == "" {
|
|
for _, c := range []byte(args[0][1:]) {
|
|
setOption(c, "", true)
|
|
}
|
|
break
|
|
}
|
|
break flags
|
|
}
|
|
args = args[1:]
|
|
}
|
|
|
|
// Determine what to run, before the startup files are read
|
|
var script func() int
|
|
interactiveMode := false
|
|
if len(args) > 0 {
|
|
switch arg := args[0]; {
|
|
case arg == "-version" || arg == "--version" || arg == "-V":
|
|
fmt.Println("gosh", versionInfo())
|
|
return 0
|
|
case arg == "-h" || arg == "--help":
|
|
fmt.Println(usage)
|
|
return 0
|
|
case arg == "-c":
|
|
// gosh -c command [name [args ...]]: name is $0, args are $1, ...
|
|
if len(args) < 2 {
|
|
fmt.Fprintln(os.Stderr, "gosh: -c: option requires an argument")
|
|
fmt.Fprintln(os.Stderr, usage)
|
|
return 2
|
|
}
|
|
if len(args) > 2 {
|
|
scriptName, positional = args[2], args[3:]
|
|
}
|
|
script = func() int { return runScript(strings.NewReader(args[1]), "") }
|
|
case strings.HasPrefix(arg, "-") && arg != "-":
|
|
fmt.Fprintf(os.Stderr, "gosh: %s: invalid option\n", arg)
|
|
fmt.Fprintln(os.Stderr, usage)
|
|
return 2
|
|
case arg == "-":
|
|
positional = args[1:]
|
|
script = func() int { return runScript(os.Stdin, "") }
|
|
default:
|
|
// gosh script [args ...]
|
|
scriptName, positional = arg, args[1:]
|
|
f, err := os.Open(arg)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "gosh:", err)
|
|
return 127
|
|
}
|
|
defer f.Close()
|
|
script = func() int { return runScript(bufio.NewReader(f), arg) }
|
|
}
|
|
} else if forceInteractive || readline.IsTerminal(int(os.Stdin.Fd())) {
|
|
interactiveMode = true
|
|
} else {
|
|
// Without a terminal (e.g. "echo ls | gosh") read the commands from
|
|
// 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) {
|
|
sourceStartupFile(file)
|
|
}
|
|
|
|
if !interactiveMode {
|
|
status := script()
|
|
runExitTrap()
|
|
return status
|
|
}
|
|
interactive()
|
|
runExitTrap()
|
|
return 0
|
|
}
|
|
|
|
// startupFiles returns the startup files to read, in order: for login
|
|
// shells /etc/gosh/profile and ~/.gosh_profile, for interactive shells
|
|
// /etc/gosh/goshrc and ~/.goshrc. Non-interactive shells (gosh -c, scripts)
|
|
// read no files unless they are login shells.
|
|
func startupFiles(login, interactive bool) []string {
|
|
home := getHomeDir()
|
|
var files []string
|
|
if login {
|
|
files = append(files, filepath.Join(sysConfDir, "profile"))
|
|
if home != "" {
|
|
files = append(files, filepath.Join(home, ".gosh_profile"))
|
|
}
|
|
}
|
|
if interactive {
|
|
files = append(files, filepath.Join(sysConfDir, "goshrc"))
|
|
if home != "" {
|
|
files = append(files, filepath.Join(home, ".goshrc"))
|
|
}
|
|
}
|
|
return files
|
|
}
|
|
|
|
// sourceStartupFile runs a startup file in the current shell. A missing file
|
|
// is skipped silently, other errors are reported.
|
|
func sourceStartupFile(path string) {
|
|
f, err := os.Open(path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "gosh:", err)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
sourceDepth++
|
|
defer func() { sourceDepth-- }()
|
|
runScript(bufio.NewReader(f), path)
|
|
}
|
|
|
|
// sourceDepth counts nested source commands, to stop a file that sources
|
|
// itself.
|
|
var sourceDepth int
|
|
|
|
// sourceFile runs the commands of a file in the current shell (the source and
|
|
// . builtins) and returns an error carrying the status of the last command.
|
|
func sourceFile(cmd, path string) error {
|
|
if sourceDepth >= 100 {
|
|
return fmt.Errorf("%s: %s: maximum nesting depth exceeded", cmd, sanitize(path))
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
var pathErr *fs.PathError
|
|
if errors.As(err, &pathErr) {
|
|
err = pathErr.Err
|
|
}
|
|
return fmt.Errorf("%s: %s: %v", cmd, sanitize(path), err)
|
|
}
|
|
defer f.Close()
|
|
|
|
sourceDepth++
|
|
defer func() { sourceDepth-- }()
|
|
if status := runScript(bufio.NewReader(f), path); status != 0 {
|
|
return statusError(status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runScript executes the commands from r. Each command is executed as soon
|
|
// as it is complete (a control structure can span several lines). Like sh,
|
|
// it does not stop at a failing command and returns the exit status of the
|
|
// last one. If name is set, errors are reported with file name and line.
|
|
func runScript(r io.Reader, name string) int {
|
|
var (
|
|
buf strings.Builder
|
|
startLine int
|
|
)
|
|
report := func(err error, line int) {
|
|
if name != "" && err != nil {
|
|
err = fmt.Errorf("%s:%d: %w", sanitize(name), line, err)
|
|
}
|
|
printError(err)
|
|
}
|
|
|
|
for lineNo := 1; ; lineNo++ {
|
|
line, readErr := readLine(r)
|
|
if readErr == nil || line != "" {
|
|
if buf.Len() == 0 {
|
|
if strings.TrimSpace(line) == "" {
|
|
goto next
|
|
}
|
|
startLine = lineNo
|
|
}
|
|
buf.WriteString(line)
|
|
buf.WriteByte('\n')
|
|
tree, err := parse(buf.String())
|
|
if errors.Is(err, errIncomplete) {
|
|
goto next // read the rest of the command
|
|
}
|
|
buf.Reset()
|
|
if err == nil {
|
|
err = tree.exec()
|
|
var ret returnSignal
|
|
if errors.As(err, &ret) {
|
|
// return in a sourced file
|
|
lastStatus = ret.status
|
|
return lastStatus
|
|
}
|
|
} else {
|
|
lastStatus = exitStatus(err)
|
|
}
|
|
report(err, startLine)
|
|
runPendingTraps()
|
|
}
|
|
next:
|
|
if readErr != nil {
|
|
if readErr != io.EOF {
|
|
fmt.Fprintln(os.Stderr, "gosh:", readErr)
|
|
return 1
|
|
}
|
|
if buf.Len() > 0 {
|
|
lastStatus = 2
|
|
report(errors.New("syntax error: unexpected end of file"), startLine)
|
|
}
|
|
return lastStatus
|
|
}
|
|
}
|
|
}
|
|
|
|
// readLine reads a single line from r without the trailing newline. It reads
|
|
// byte by byte so that nothing after the line is consumed: when the script
|
|
// comes from stdin, the commands it starts read the rest of stdin (like in
|
|
// sh, "printf 'cat\nhello\n' | gosh" prints hello). Pass a bufio.Reader if
|
|
// that does not matter.
|
|
func readLine(r io.Reader) (string, error) {
|
|
var b strings.Builder
|
|
buf := make([]byte, 1)
|
|
for {
|
|
n, err := r.Read(buf)
|
|
if n > 0 {
|
|
if buf[0] == '\n' {
|
|
return b.String(), nil
|
|
}
|
|
b.WriteByte(buf[0])
|
|
}
|
|
if err != nil {
|
|
return b.String(), err
|
|
}
|
|
}
|
|
}
|
|
|
|
// exitStatus converts the result of execInput into a shell exit status: the
|
|
// exit code of the command, 128+n if it was killed by signal n, 127 if it was
|
|
// not found, 126 if it could not be executed and 1 for other errors.
|
|
func exitStatus(err error) int {
|
|
if err == nil {
|
|
return 0
|
|
}
|
|
var statusErr statusError
|
|
if errors.As(err, &statusErr) {
|
|
return int(statusErr)
|
|
}
|
|
var (
|
|
synErr syntaxError
|
|
lc loopControl
|
|
)
|
|
if errors.As(err, &synErr) || errors.Is(err, errIncomplete) {
|
|
return 2
|
|
}
|
|
if errors.As(err, &lc) {
|
|
return 0
|
|
}
|
|
var ret returnSignal
|
|
if errors.As(err, &ret) {
|
|
return ret.status
|
|
}
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
|
|
return 128 + int(ws.Signal())
|
|
}
|
|
return exitErr.ExitCode()
|
|
}
|
|
if errors.Is(err, errCommandNotFound) || errors.Is(err, fs.ErrNotExist) {
|
|
return 127
|
|
}
|
|
if errors.Is(err, fs.ErrPermission) {
|
|
return 126
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// interactive runs the interactive shell with line editing, history and
|
|
// completion until the user exits.
|
|
func interactive() {
|
|
interactiveShell = true
|
|
initJobControl()
|
|
defer releaseTerminal()
|
|
|
|
// Ctrl+C sends SIGINT to the whole foreground process group. Catch it so
|
|
// that it only stops the running command and not the shell. (signal.Ignore
|
|
// would be inherited by the child processes.)
|
|
signal.Notify(interruptChan, os.Interrupt)
|
|
|
|
histFile := historyFile()
|
|
if histFile != "" {
|
|
if err := prepareHistoryFile(histFile); err != nil {
|
|
fmt.Fprintln(os.Stderr, "History disabled:", err)
|
|
histFile = ""
|
|
}
|
|
}
|
|
|
|
// Create readline instance
|
|
l, err := readline.NewEx(&readline.Config{
|
|
Prompt: getPrompt(),
|
|
HistoryFile: histFile,
|
|
DisableAutoSaveHistory: true,
|
|
AutoComplete: newShellCompleter(),
|
|
InterruptPrompt: "^C",
|
|
EOFPrompt: "exit",
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "Error initializing readline:", err)
|
|
os.Exit(1)
|
|
}
|
|
defer l.Close()
|
|
|
|
// Main loop. A command that is not complete yet (e.g. if without fi)
|
|
// is continued on the next line with the prompt $PS2 (default "> ").
|
|
var buf strings.Builder
|
|
for {
|
|
var prompt string
|
|
if buf.Len() == 0 {
|
|
runPendingTraps()
|
|
// Report finished and stopped background jobs
|
|
reapJobs(true)
|
|
// Update prompt dynamically (in case directory changed)
|
|
reportCwd()
|
|
prompt = getPrompt()
|
|
} else if ps2, ok := promptString("PS2"); ok {
|
|
prompt = ps2
|
|
} else {
|
|
prompt = "> "
|
|
}
|
|
prefix, prompt := splitPrompt(prompt)
|
|
fmt.Print(prefix)
|
|
l.SetPrompt(prompt)
|
|
|
|
line, err := l.Readline()
|
|
if err != nil {
|
|
// Ctrl+C discards the current line (and an incomplete command)
|
|
if err == readline.ErrInterrupt {
|
|
buf.Reset()
|
|
continue
|
|
}
|
|
// Handle EOF (Ctrl+D)
|
|
if err == io.EOF {
|
|
fmt.Println()
|
|
if buf.Len() > 0 {
|
|
buf.Reset()
|
|
lastStatus = 2
|
|
fmt.Fprintln(os.Stderr, "gosh: syntax error: unexpected end of file")
|
|
continue
|
|
}
|
|
if hasStoppedJobs() && !exitWarned {
|
|
exitWarned = true
|
|
fmt.Fprintln(os.Stderr, "gosh: there are stopped jobs")
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
fmt.Fprintln(os.Stderr, "Error reading input:", err)
|
|
continue
|
|
}
|
|
|
|
// Skip empty input
|
|
if buf.Len() == 0 && strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
|
|
// Add to history, unless the line starts with a space (like bash's
|
|
// HISTCONTROL=ignorespace) so that secrets can be kept out of it
|
|
if !strings.HasPrefix(line, " ") {
|
|
l.SaveHistory(line)
|
|
}
|
|
|
|
buf.WriteString(line)
|
|
buf.WriteByte('\n')
|
|
tree, err := parse(buf.String())
|
|
if errors.Is(err, errIncomplete) {
|
|
continue
|
|
}
|
|
buf.Reset()
|
|
if err != nil {
|
|
lastStatus = exitStatus(err)
|
|
printError(err)
|
|
continue
|
|
}
|
|
printError(tree.exec())
|
|
}
|
|
}
|
|
|
|
// lastStatus is the exit status of the last command ($?).
|
|
var lastStatus int
|
|
|
|
// statusError is an error that only carries an exit status; the problem has
|
|
// already been reported.
|
|
type statusError int
|
|
|
|
func (e statusError) Error() string {
|
|
return "exit status " + strconv.Itoa(int(e))
|
|
}
|
|
|
|
// syntaxError is a syntax error in the input (exit status 2, like sh).
|
|
type syntaxError string
|
|
|
|
func (e syntaxError) Error() string {
|
|
return "syntax error near unexpected token `" + string(e) + "'"
|
|
}
|
|
|
|
// errCommandNotFound is returned when an external command does not exist.
|
|
var errCommandNotFound = errors.New("command not found")
|
|
|
|
// printError prints an error of a command to stderr. Commands that ran and
|
|
// failed have already reported the problem themselves, only their exit
|
|
// status is kept.
|
|
func printError(err error) {
|
|
var (
|
|
exitErr *exec.ExitError
|
|
statusErr statusError
|
|
lc loopControl
|
|
ret returnSignal
|
|
)
|
|
if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) || errors.As(err, &lc) || errors.As(err, &ret) {
|
|
return
|
|
}
|
|
fmt.Fprintln(stdio(2), "gosh:", err)
|
|
}
|
|
|
|
// execInput parses and executes input: a command line or a whole script.
|
|
// 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 {
|
|
tree, err := parse(input)
|
|
if err != nil {
|
|
lastStatus = exitStatus(err)
|
|
return err
|
|
}
|
|
return tree.exec()
|
|
}
|
|
|
|
// execCommand executes a pipeline of simple commands with redirections, in
|
|
// the background if background is set.
|
|
func execCommand(input string, background bool) error {
|
|
substStatus = 0
|
|
stages, err := expandStages(input)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(stages) == 0 {
|
|
// A command that consists only of $(...) has its status
|
|
if substStatus != 0 {
|
|
return statusError(substStatus)
|
|
}
|
|
return nil
|
|
}
|
|
// "exit" with stopped jobs only warns, unless it is repeated right away
|
|
if name := stages[0].args; len(name) == 0 || (name[0] != "exit" && name[0] != "quit") {
|
|
exitWarned = false
|
|
}
|
|
return runPipeline(stages, strings.TrimSpace(input), background)
|
|
}
|
|
|
|
// expandStages expands the text of a pipeline of simple commands and splits
|
|
// it into its stages.
|
|
func expandStages(input string) ([]stage, error) {
|
|
// Brace expansion comes first, so that its results are expanded further
|
|
input, err := expandBraceWords(input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Split the input into words and operators, expanding variables and ~ on
|
|
// the way.
|
|
tokens, err := tokenize(input)
|
|
var (
|
|
synErr syntaxError
|
|
statusErr statusError
|
|
)
|
|
if errors.As(err, &synErr) || errors.As(err, &statusErr) {
|
|
return nil, err
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid input: %w", err)
|
|
}
|
|
return parsePipeline(tokens)
|
|
}
|
|
|
|
// openRedirections expands and opens the redirections of a compound command
|
|
// and returns the resulting stdin, stdout and stderr, and the files to close
|
|
// afterwards.
|
|
func openRedirections(text string) ([3]*os.File, []*os.File, error) {
|
|
fds := [3]*os.File{stdio(0), stdio(1), stdio(2)}
|
|
stages, err := expandStages(text)
|
|
if err != nil {
|
|
return fds, nil, err
|
|
}
|
|
if len(stages) != 1 || len(stages[0].args) > 0 || len(stages[0].assigns) > 0 {
|
|
return fds, nil, syntaxError(text)
|
|
}
|
|
opened, err := applyRedirects(&fds, stages[0].redirects)
|
|
return fds, opened, err
|
|
}
|
|
|
|
// redirect is a redirection of a simple command, e.g. 2>>file or 2>&1.
|
|
type redirect struct {
|
|
op string // <, >, >>, &>, &>>, >&, <& or <<<
|
|
fd int // the redirected file descriptor
|
|
dup int // the target file descriptor of >& and <&
|
|
file string // the file name for all other operators (the text for <<<)
|
|
}
|
|
|
|
// stage is a simple command of a pipeline, or a script that runs in a
|
|
// subshell (a compound command in a pipeline or in the background).
|
|
type stage struct {
|
|
assigns []string // name=value before the command name
|
|
args []string
|
|
redirects []redirect
|
|
script string
|
|
}
|
|
|
|
// parsePipeline groups tokens into the stages of a pipeline.
|
|
func parsePipeline(tokens []token) ([]stage, error) {
|
|
var (
|
|
stages []stage
|
|
cur stage
|
|
)
|
|
isEmpty := func(s stage) bool { return len(s.assigns) == 0 && len(s.args) == 0 && len(s.redirects) == 0 }
|
|
|
|
for i := 0; i < len(tokens); i++ {
|
|
t := tokens[i]
|
|
switch t.op {
|
|
case "":
|
|
if t.assign && len(cur.args) == 0 {
|
|
// The value is not split and not expanded as a pattern
|
|
cur.assigns = append(cur.assigns, t.word)
|
|
} else {
|
|
cur.args = append(cur.args, expandWord(t)...)
|
|
}
|
|
case "|":
|
|
if isEmpty(cur) {
|
|
return nil, syntaxError("|")
|
|
}
|
|
stages = append(stages, cur)
|
|
cur = stage{}
|
|
case ">&", "<&":
|
|
cur.redirects = append(cur.redirects, redirect{op: t.op, fd: t.fd, dup: t.dup})
|
|
default:
|
|
if i+1 == len(tokens) {
|
|
return nil, syntaxError("newline")
|
|
}
|
|
if next := tokens[i+1]; next.op != "" {
|
|
return nil, syntaxError(next.op)
|
|
}
|
|
i++
|
|
if t.op == "<<<" {
|
|
// A here-string is not expanded as a pattern
|
|
cur.redirects = append(cur.redirects, redirect{op: t.op, fd: t.fd, file: tokens[i].word})
|
|
continue
|
|
}
|
|
files := expandWord(tokens[i])
|
|
if len(files) > 1 {
|
|
return nil, fmt.Errorf("%s: ambiguous redirect", sanitize(tokens[i].word))
|
|
}
|
|
cur.redirects = append(cur.redirects, redirect{op: t.op, fd: t.fd, file: files[0]})
|
|
}
|
|
}
|
|
|
|
if isEmpty(cur) {
|
|
if len(stages) > 0 {
|
|
return nil, syntaxError("|")
|
|
}
|
|
return nil, nil
|
|
}
|
|
return append(stages, cur), nil
|
|
}
|
|
|
|
// runPipeline runs the stages of a pipeline concurrently as a job,
|
|
// connecting the stdout of each stage to the stdin of the next one. In the
|
|
// foreground it waits for the job and returns the error of the last stage (so
|
|
// $? is its exit status, like in sh); errors of the other stages are printed.
|
|
// In the background it adds the job to the job table and returns nil.
|
|
func runPipeline(stages []stage, text string, background bool) error {
|
|
j := &job{text: text}
|
|
if jobControl && !background {
|
|
shellTTY, _ = readline.GetState(ttyFd)
|
|
}
|
|
|
|
stdin := stdio(0)
|
|
if background && !jobControl {
|
|
// Without job control background jobs must not read from the
|
|
// terminal, like in sh
|
|
if devNull, err := os.Open(os.DevNull); err == nil {
|
|
stdin = devNull
|
|
}
|
|
}
|
|
var startErr error
|
|
for i, st := range stages {
|
|
stdout := stdio(1)
|
|
var next *os.File
|
|
if i < len(stages)-1 {
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
if stdin != stdio(0) {
|
|
stdin.Close()
|
|
}
|
|
startErr = err
|
|
break
|
|
}
|
|
stdout, next = w, r
|
|
}
|
|
j.procs = append(j.procs, startStage(st, stdin, stdout, j, len(stages) > 1, background))
|
|
stdin = next
|
|
}
|
|
if startErr != nil {
|
|
// Let the stages that already run finish
|
|
if len(j.procs) > 0 {
|
|
waitForeground(j)
|
|
}
|
|
return startErr
|
|
}
|
|
|
|
if background {
|
|
addJob(j)
|
|
if interactiveShell {
|
|
pid := 0
|
|
for _, p := range j.procs {
|
|
if p.cmd != nil {
|
|
pid = p.cmd.Process.Pid
|
|
}
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[%d] %d\n", j.id, pid)
|
|
}
|
|
for _, p := range j.procs {
|
|
if p.cmd != nil {
|
|
lastBackgroundPid = p.cmd.Process.Pid
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return waitForeground(j)
|
|
}
|
|
|
|
// startStage starts a stage of the pipeline of job j with the given stdin and
|
|
// stdout. Pipe ends passed in are closed by startStage once the stage no
|
|
// longer needs them, so that the neighbors see EOF or get SIGPIPE.
|
|
func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background bool) *proc {
|
|
if (inPipeline || background) && st.script == "" {
|
|
// eval and exec in a pipeline or in the background run in a
|
|
// subshell, like in sh
|
|
switch firstArg(st.args) {
|
|
case "eval":
|
|
st.script, st.args = strings.Join(st.args[1:], " "), nil
|
|
case "exec":
|
|
st.args = st.args[1:]
|
|
}
|
|
}
|
|
if _, isFunc := functions[firstArg(st.args)]; xtrace && st.script == "" && !(isFunc && (inPipeline || background)) {
|
|
// (a function in a pipeline is shown by its subshell)
|
|
traceCommand(st)
|
|
}
|
|
|
|
var owned []*os.File
|
|
if stdin != stdio(0) {
|
|
owned = append(owned, stdin)
|
|
}
|
|
if stdout != stdio(1) {
|
|
owned = append(owned, stdout)
|
|
}
|
|
closeOwned := func() {
|
|
for _, f := range owned {
|
|
f.Close()
|
|
}
|
|
}
|
|
|
|
// Connect the command to the terminal unless redirected. Without Stdin
|
|
// the command would read from /dev/null, so interactive programs (su,
|
|
// vim, ssh, ...) would get EOF right away. readline only reads from stdin
|
|
// while a line is being edited, so it does not steal input.
|
|
fds := [3]*os.File{stdin, stdout, stdio(2)}
|
|
opened, err := applyRedirects(&fds, st.redirects)
|
|
owned = append(owned, opened...)
|
|
if err == nil && len(st.args) == 0 && len(st.assigns) > 0 {
|
|
// Assignments without a command set variables of the shell (not in
|
|
// a pipeline or in the background, where sh runs them in a
|
|
// subshell). The status is the one of the last $(...) in them.
|
|
if !inPipeline && !background {
|
|
for _, a := range st.assigns {
|
|
name, value, _ := strings.Cut(a, "=")
|
|
os.Setenv(name, value)
|
|
}
|
|
}
|
|
if substStatus != 0 {
|
|
err = statusError(substStatus)
|
|
}
|
|
}
|
|
if err != nil || len(st.args) == 0 && st.script == "" {
|
|
// A command without arguments only creates or truncates the files
|
|
closeOwned()
|
|
return finishedProc(err)
|
|
}
|
|
|
|
var fn *funcDef
|
|
isFunc := false
|
|
if st.script == "" {
|
|
fn, isFunc = functions[st.args[0]]
|
|
}
|
|
if isFunc && !inPipeline && !background {
|
|
defer closeOwned()
|
|
return finishedProc(callFunction(fn, st.args, fds, st.assigns))
|
|
}
|
|
if !isFunc && st.script == "" && st.args[0] == "exec" && !inPipeline && !background {
|
|
defer closeOwned()
|
|
return finishedProc(builtinExec(st.args, fds, st.assigns))
|
|
}
|
|
if builtin, ok := builtins[firstArg(st.args)]; !isFunc && st.script == "" && ok {
|
|
switch {
|
|
case inPipeline && builtin.changesState && !onlyShows(st.args):
|
|
closeOwned()
|
|
return finishedProc(fmt.Errorf("%s: cannot be used in a pipeline", st.args[0]))
|
|
case background && builtin.changesState && !onlyShows(st.args):
|
|
closeOwned()
|
|
return finishedProc(fmt.Errorf("%s: cannot be run in the background", st.args[0]))
|
|
}
|
|
shellStderr := stdio(2)
|
|
run := func() error {
|
|
defer closeOwned()
|
|
err := runBuiltin(st.args, fds[0], fds[1])
|
|
if err != nil && fds[2] != shellStderr {
|
|
// Report the error where stderr is redirected to
|
|
fmt.Fprintln(fds[2], "gosh:", err)
|
|
return statusError(1)
|
|
}
|
|
return err
|
|
}
|
|
if !inPipeline && !background {
|
|
// name=value before a builtin applies only while it runs, its
|
|
// redirections also to the commands it runs (source file > out)
|
|
defer setTemporarily(st.assigns)()
|
|
saved := ioOverride
|
|
ioOverride = fds
|
|
defer func() { ioOverride = saved }()
|
|
return finishedProc(run())
|
|
}
|
|
p := &proc{done: make(chan error, 1)}
|
|
go func() { p.done <- run() }()
|
|
return p
|
|
}
|
|
|
|
var cmd *exec.Cmd
|
|
if st.script != "" {
|
|
cmd, err = subshellCommand(st.script, positional)
|
|
if err != nil {
|
|
closeOwned()
|
|
return finishedProc(err)
|
|
}
|
|
} else if isFunc {
|
|
// A function in a pipeline or in the background runs in a subshell
|
|
cmd, err = subshellCommand(`"$@"`, st.args)
|
|
if err != nil {
|
|
closeOwned()
|
|
return finishedProc(err)
|
|
}
|
|
} else {
|
|
cmd = exec.Command(st.args[0], st.args[1:]...)
|
|
}
|
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2]
|
|
if len(st.assigns) > 0 {
|
|
// name=value before a command is only in its environment (the
|
|
// last value of a name wins)
|
|
cmd.Env = append(os.Environ(), st.assigns...)
|
|
}
|
|
if jobControl {
|
|
// All processes of the job share a process group: the one of the
|
|
// first process. In the foreground, the first process makes its
|
|
// group the foreground group of the terminal before it starts the
|
|
// program, so that it can use the terminal right away.
|
|
attr := &syscall.SysProcAttr{Setpgid: true, Pgid: j.pgid}
|
|
if j.pgid == 0 && !background {
|
|
if ctty := ttyIndex(fds); ctty >= 0 {
|
|
attr.Foreground, attr.Ctty = true, ctty
|
|
}
|
|
}
|
|
cmd.SysProcAttr = attr
|
|
}
|
|
err = cmd.Start()
|
|
// The child has its own copies of the files now
|
|
closeOwned()
|
|
if errors.Is(err, exec.ErrNotFound) {
|
|
err = fmt.Errorf("%s: %w", sanitize(firstArg(st.args)), errCommandNotFound)
|
|
}
|
|
if err != nil {
|
|
return finishedProc(err)
|
|
}
|
|
if jobControl && j.pgid == 0 {
|
|
j.pgid = cmd.Process.Pid
|
|
if !background {
|
|
// In case the process could not do it (no terminal in fds)
|
|
setForeground(j.pgid)
|
|
}
|
|
}
|
|
return &proc{cmd: cmd}
|
|
}
|
|
|
|
func firstArg(args []string) string {
|
|
if len(args) == 0 {
|
|
return ""
|
|
}
|
|
return args[0]
|
|
}
|
|
|
|
// setTemporarily sets the variables of the assignments and returns a
|
|
// function that restores the previous values.
|
|
func setTemporarily(assigns []string) func() {
|
|
type saved struct {
|
|
name, value string
|
|
ok bool
|
|
}
|
|
var restore []saved
|
|
for _, a := range assigns {
|
|
name, value, _ := strings.Cut(a, "=")
|
|
old, ok := os.LookupEnv(name)
|
|
restore = append(restore, saved{name, old, ok})
|
|
os.Setenv(name, value)
|
|
}
|
|
return func() {
|
|
for i := len(restore) - 1; i >= 0; i-- {
|
|
if r := restore[i]; r.ok {
|
|
os.Setenv(r.name, r.value)
|
|
} else {
|
|
os.Unsetenv(r.name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// applyRedirects applies the redirections in order to fds (stdin, stdout,
|
|
// stderr) and returns the files it opened.
|
|
func applyRedirects(fds *[3]*os.File, redirects []redirect) ([]*os.File, error) {
|
|
var opened []*os.File
|
|
fail := func(err error) ([]*os.File, error) {
|
|
for _, f := range opened {
|
|
f.Close()
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
for _, r := range redirects {
|
|
if r.fd > 2 {
|
|
return fail(fmt.Errorf("%d: bad file descriptor", r.fd))
|
|
}
|
|
if r.op == ">&" || r.op == "<&" {
|
|
if r.dup > 2 {
|
|
return fail(fmt.Errorf("%d: bad file descriptor", r.dup))
|
|
}
|
|
fds[r.fd] = fds[r.dup]
|
|
continue
|
|
}
|
|
|
|
if r.op == "<<<" {
|
|
f, err := hereFile(r.file + "\n")
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
opened = append(opened, f)
|
|
fds[r.fd] = f
|
|
continue
|
|
}
|
|
|
|
flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC
|
|
switch r.op {
|
|
case "<":
|
|
flags = os.O_RDONLY
|
|
case ">>", "&>>":
|
|
flags = os.O_WRONLY | os.O_CREATE | os.O_APPEND
|
|
}
|
|
f, err := os.OpenFile(r.file, flags, 0666)
|
|
if err != nil {
|
|
// "file: No such file or directory" with exit status 1, like sh
|
|
var pathErr *fs.PathError
|
|
if errors.As(err, &pathErr) {
|
|
err = fmt.Errorf("%s: %v", sanitize(r.file), pathErr.Err)
|
|
}
|
|
return fail(err)
|
|
}
|
|
opened = append(opened, f)
|
|
fds[r.fd] = f
|
|
if r.op == "&>" || r.op == "&>>" {
|
|
fds[2] = f
|
|
}
|
|
}
|
|
return opened, nil
|
|
}
|
|
|
|
// hereFile returns a file to read content from: a temporary file that is
|
|
// already removed (like bash does for here-documents), so that large inputs
|
|
// work and nothing is left behind.
|
|
func hereFile(content string) (*os.File, error) {
|
|
f, err := os.CreateTemp("", "gosh-here-*")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
os.Remove(f.Name())
|
|
if _, err := f.WriteString(content); err != nil {
|
|
f.Close()
|
|
return nil, err
|
|
}
|
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
|
f.Close()
|
|
return nil, err
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// builtins lists the built-in commands. Commands that change the state of
|
|
// 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},
|
|
"alias": {},
|
|
"unalias": {changesState: true},
|
|
":": {},
|
|
"unset": {changesState: true},
|
|
"break": {changesState: true},
|
|
"continue": {changesState: true},
|
|
"return": {changesState: true},
|
|
"local": {changesState: true},
|
|
"shift": {changesState: true},
|
|
"read": {changesState: true},
|
|
"trap": {changesState: true},
|
|
"eval": {changesState: true},
|
|
"exec": {changesState: true},
|
|
}
|
|
|
|
// runBuiltin executes a built-in command, reading from stdin and writing its
|
|
// output to stdout.
|
|
func runBuiltin(args []string, stdin *os.File, stdout io.Writer) error {
|
|
switch args[0] {
|
|
case "cd":
|
|
// Save current directory before changing (for 'cd -')
|
|
currentDir, _ := os.Getwd()
|
|
|
|
// Handle different cd cases
|
|
if len(args) < 2 {
|
|
// cd with no args: go to home directory
|
|
previousDir = currentDir
|
|
return os.Chdir(getHomeDir())
|
|
}
|
|
|
|
path := args[1]
|
|
|
|
// Handle 'cd -' : go to previous directory
|
|
if path == "-" {
|
|
if previousDir == "" {
|
|
return fmt.Errorf("no previous directory")
|
|
}
|
|
// Save current directory as the new previous
|
|
// so that next 'cd -' will come back here
|
|
prevDir := previousDir
|
|
previousDir = currentDir
|
|
return os.Chdir(prevDir)
|
|
}
|
|
|
|
// Save current directory as previous before changing
|
|
previousDir = currentDir
|
|
|
|
// Change the directory and return the error.
|
|
return os.Chdir(path)
|
|
case "pwd":
|
|
wd, err := os.Getwd()
|
|
fmt.Fprintln(stdout, wd)
|
|
return err
|
|
case "exit":
|
|
// exit with optional status code
|
|
if len(args) > 1 {
|
|
var exitCode int
|
|
fmt.Sscanf(args[1], "%d", &exitCode)
|
|
return exitShell(exitCode)
|
|
}
|
|
return exitShell(lastStatus)
|
|
case "quit":
|
|
// quit with optional status code
|
|
if len(args) > 1 {
|
|
var exitCode int
|
|
fmt.Sscanf(args[1], "%d", &exitCode)
|
|
return exitShell(exitCode)
|
|
}
|
|
return exitShell(lastStatus)
|
|
case "set":
|
|
return builtinSet(args, stdout)
|
|
case "trap":
|
|
return builtinTrap(args, stdout)
|
|
case "eval":
|
|
return builtinEval(args)
|
|
case "export":
|
|
// export VAR=value - same as set but also marks for child processes
|
|
if len(args) < 2 {
|
|
return fmt.Errorf("usage: export VAR=value")
|
|
}
|
|
// Parse VAR=value
|
|
parts := strings.SplitN(args[1], "=", 2)
|
|
if len(parts) != 2 {
|
|
return fmt.Errorf("usage: export VAR=value")
|
|
}
|
|
os.Setenv(parts[0], parts[1])
|
|
return nil
|
|
case ":":
|
|
// Does nothing, successfully (e.g. while :; do ...)
|
|
return nil
|
|
case "unset":
|
|
// unset [-v] NAME ... - remove variables, unset -f NAME ... - functions
|
|
names := args[1:]
|
|
funcs := len(names) > 0 && names[0] == "-f"
|
|
if len(names) > 0 && (names[0] == "-f" || names[0] == "-v") {
|
|
names = names[1:]
|
|
}
|
|
if len(names) == 0 {
|
|
return fmt.Errorf("usage: unset [-f|-v] NAME ...")
|
|
}
|
|
var err error
|
|
for _, name := range names {
|
|
if funcs {
|
|
delete(functions, name)
|
|
continue
|
|
}
|
|
if !isVarName(name) {
|
|
err = fmt.Errorf("unset: `%s': not a valid identifier", sanitize(name))
|
|
continue
|
|
}
|
|
os.Unsetenv(name)
|
|
}
|
|
return err
|
|
case "break", "continue":
|
|
return builtinLoopControl(args)
|
|
case "return":
|
|
return builtinReturn(args)
|
|
case "local":
|
|
return builtinLocal(args)
|
|
case "shift":
|
|
return builtinShift(args)
|
|
case "read":
|
|
return builtinRead(args, stdin)
|
|
case "alias":
|
|
return builtinAlias(args, stdout)
|
|
case "unalias":
|
|
return builtinUnalias(args)
|
|
case "jobs":
|
|
return builtinJobs(stdout)
|
|
case "fg":
|
|
return builtinFg(args, stdout)
|
|
case "bg":
|
|
return builtinBg(args, stdout)
|
|
case "wait":
|
|
return builtinWait(args)
|
|
case "source", ".":
|
|
// source file [args] - run the commands of file in the current shell
|
|
if len(args) < 2 {
|
|
return fmt.Errorf("usage: %s file [args ...]", args[0])
|
|
}
|
|
if len(args) > 2 {
|
|
// The arguments are the positional parameters while it runs
|
|
saved := positional
|
|
positional = slices.Clone(args[2:])
|
|
defer func() { positional = saved }()
|
|
}
|
|
return sourceFile(args[0], args[1])
|
|
case "help":
|
|
fmt.Fprintln(stdout, "gosh - Go Shell\nVersion: " + versionInfo() + `
|
|
|
|
Built-in commands:
|
|
cd [dir] - Change directory (cd with no args goes to home)
|
|
pwd - Print working directory
|
|
echo [args] - Echo arguments
|
|
exit [code] - Exit shell with status code (default: $?)
|
|
quit [code] - Quit shell with status code (default: $?)
|
|
set VAR=val - Set environment variable
|
|
set [-ex] [-o pipefail] [--] [args] - Options: -e exit when a command
|
|
fails, -x print commands, -o pipefail; args are $1, ...
|
|
(set: show variables, set -o: show options)
|
|
export VAR=val - Export environment variable
|
|
unset VAR - Remove a variable (unset -f name: a function)
|
|
source file [args] - Run the commands of file in this shell (also: . file)
|
|
jobs - List background and stopped jobs
|
|
fg [%n] - Continue job n (default: current job) in the foreground
|
|
bg [%n] - Continue stopped job n in the background
|
|
wait [%n|pid] - Wait for job n (or process pid, e.g. $!) or all jobs
|
|
alias [name[=value] ...] - Define or show aliases (alias ll='ls -l')
|
|
unalias name - Remove an alias (unalias -a: all aliases)
|
|
: - Do nothing (exit status 0)
|
|
break [n] - Leave the innermost (or n-th) for/while/until loop
|
|
continue [n] - Continue with the next iteration of a loop
|
|
return [n] - Leave a function or sourced file with status n
|
|
local VAR[=val] - Variable that gets its old value back when the
|
|
function returns
|
|
shift [n] - Remove the first n positional parameters (default 1)
|
|
read [-r] [-p prompt] [VAR ...] - Read a line into variables (split
|
|
at $IFS; the last one gets the rest; default $REPLY)
|
|
eval args - Run the arguments as a command in this shell
|
|
exec [cmd] - Replace the shell with cmd; without cmd, its
|
|
redirections apply to the shell (exec > log)
|
|
trap [cmd] SIG... - Run cmd on a signal (INT, TERM, HUP, USR1, ...)
|
|
or on EXIT; trap '' SIG ignores it, trap - SIG resets
|
|
help - Show this help
|
|
|
|
Features:
|
|
- Argument parsing with quotes and escapes ('...' is literal,
|
|
"..." expands variables, \ escapes the next character)
|
|
- Ctrl+D to exit, Ctrl+C to cancel the current line or command
|
|
- Command history (up/down arrows), stored in
|
|
$XDG_STATE_HOME/gosh/history (default ~/.local/state/gosh/history,
|
|
override with $GOSH_HISTFILE); lines starting with a space are not saved
|
|
- Tab completion for commands and file paths
|
|
- Tilde expansion (~, ~/path) at the start of unquoted words
|
|
- Brace expansion: a{b,c}d -> abd acd, {1..5}, {01..10..2}, {a..e}
|
|
- Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files
|
|
unless the pattern starts with a dot); quoted wildcards and values of
|
|
variables are not expanded, a pattern without match is kept as is
|
|
- Prompt user@host:dir $, with [N] before the $ if the last command
|
|
failed with exit status N
|
|
- Colored prompt (user@host green, red for root; directory blue; exit
|
|
status red), configurable with $GOSH_COLORS, e.g.
|
|
GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values
|
|
are ANSI SGR codes, empty = no color); disabled with $NO_COLOR,
|
|
TERM=dumb or when output is not a terminal
|
|
- Custom prompt with $PS1 (and $PS2 for continuation lines, default
|
|
"> ") using the escapes of bash: \u user, \h/\H host, \w/\W directory,
|
|
\$ (# for root), \t \T \@ \A \d time/date, \j jobs, \n newline,
|
|
\e escape, \[ \]; $VAR, $(...) and $((...)) are expanded, e.g.
|
|
PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\w\$ '
|
|
- Reports the working directory to the terminal (OSC 7) before each
|
|
prompt, so new tabs/windows open in the same directory
|
|
- Variables: name=value sets a variable (like set), name=value command
|
|
sets it only for that command; the value is not split or expanded as
|
|
a pattern
|
|
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
|
|
using an unset variable is an error, use ${VAR:-} for an empty default
|
|
- Parameter expansion: ${#VAR} (length), ${VAR#pat} ${VAR##pat}
|
|
${VAR%pat} ${VAR%%pat} (remove prefix/suffix), ${VAR/pat/rep}
|
|
${VAR//pat/rep}, ${VAR:offset:length}, ${VAR-x} ${VAR:=x} ${VAR:+x}
|
|
${VAR:?msg}
|
|
- Arithmetic: $((expression)) with integers and the C operators
|
|
(+ - * / % ** << >> < == && || ?: = += ++ ...)
|
|
- Positional parameters: $0, $1 ... $9, ${10}, $# (count), "$@" (one
|
|
word each), "$*" (one word), $$ (process ID), $! (process ID of the
|
|
last background job); set by gosh script
|
|
args, gosh -c command name args, functions, set -- and source
|
|
- Command substitution: $(command) is replaced by the output of command
|
|
(run in a subshell); unquoted it is split into words, "$(command)"
|
|
stays one word
|
|
- Command lists: a ; b (run both), a && b (b only if a succeeded),
|
|
a || b (b only if a failed); $? is the exit status of the last command
|
|
- Control structures: if cmd; then ...; elif cmd; then ...; else ...; fi,
|
|
for x in words; do ...; done, while cmd; do ...; done, until ...,
|
|
case word in pattern|pattern) ...;; esac, { cmd; cmd; } groups,
|
|
( cmd; cmd ) subshells; ! cmd negates the status; they can span
|
|
several lines (prompt "> "), have redirections (done < file) and can
|
|
be used in pipes (| while read l; do ...; done) and with &
|
|
- Functions: name() { ...; } (or function name { ...; }); the arguments
|
|
are $1, $2, ...; in pipes, & and $(...) they run in a subshell
|
|
- Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
|
|
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file,
|
|
<<EOF here-documents (<<-EOF strips tabs, <<'EOF' expands nothing),
|
|
<<<word here-strings
|
|
- 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
|
|
/etc/gosh/profile and ~/.gosh_profile, interactive shells read
|
|
/etc/gosh/goshrc and ~/.goshrc (skip with --noprofile, --norc)`)
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%s: not a builtin", args[0])
|
|
}
|