add functions, positional parameters, case and read
- functions: name() { ...; }, function name { ...; }, return, local,
unset -f; in pipes, & and $(...) they run in a subshell that gets all
function definitions and the positional parameters
- positional parameters $0, $1 ... ${10}, $#, "$@", "$*", $$; set by
gosh script args, gosh -c command name args, function calls,
source file args and set --; shift; for x without "in"
- case word in pattern|pattern) ...;; esac and { list; } groups
- read [-r] [-p prompt] [name ...] with $IFS splitting and $REPLY
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9fe8b06fba
commit
443367f66f
9 files changed
+1629
-98
No files matched your search
@@ -12,6 +12,7 @@ import (
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -152,18 +153,20 @@ func tokenize(input string) ([]token, error) {
|
||||
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() {
|
||||
if inWord {
|
||||
// "$@" 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 = false, false, true
|
||||
glob, assign, plain, noParams = false, false, true, false
|
||||
inWord = false
|
||||
rawStart = -1
|
||||
}
|
||||
@@ -227,6 +230,20 @@ func tokenize(input string) ([]token, error) {
|
||||
}
|
||||
}
|
||||
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:])
|
||||
@@ -506,13 +523,10 @@ func expandVar(s string) (string, int, error) {
|
||||
return "", 0, errors.New("missing } in variable reference")
|
||||
}
|
||||
name, def, hasDef := strings.Cut(s[2:end], ":-")
|
||||
if name == "?" {
|
||||
return strconv.Itoa(lastStatus), end + 1, nil
|
||||
}
|
||||
if !isVarName(name) {
|
||||
if !isVarName(name) && !isDigits(name) && !isSpecialParam(name) {
|
||||
return "", 0, fmt.Errorf("bad substitution: %s", s[:end+1])
|
||||
}
|
||||
value, ok := os.LookupEnv(name)
|
||||
value, ok := lookupParam(name)
|
||||
if hasDef && value == "" {
|
||||
return def, end + 1, nil
|
||||
}
|
||||
@@ -522,8 +536,13 @@ func expandVar(s string) (string, int, error) {
|
||||
return value, end + 1, nil
|
||||
}
|
||||
|
||||
if len(s) > 1 && s[1] == '?' {
|
||||
return strconv.Itoa(lastStatus), 2, nil
|
||||
// $?, $#, $$, $@, $*, $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
|
||||
@@ -540,6 +559,25 @@ func expandVar(s string) (string, int, error) {
|
||||
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
|
||||
@@ -760,7 +798,7 @@ type shellCompleter struct {
|
||||
|
||||
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"},
|
||||
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "break", "continue", "unset", "help", "return", "local", "shift", "read"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -777,7 +815,7 @@ func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
|
||||
// ... 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", "!":
|
||||
case "if", "then", "elif", "else", "do", "while", "until", "!", "{", ";;":
|
||||
isFirstWord = true
|
||||
}
|
||||
}
|
||||
@@ -791,7 +829,7 @@ func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
|
||||
// Complete commands
|
||||
var candidates [][]rune
|
||||
seen := make(map[string]bool)
|
||||
names := append(append(append([]string(nil), c.commands...), aliasNames()...), pathExecutables(word)...)
|
||||
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
|
||||
@@ -935,8 +973,8 @@ func completeFilePath(line, prefix string) []string {
|
||||
return matches
|
||||
}
|
||||
|
||||
const usage = `usage: gosh [-l] [-i] [--norc] [--noprofile] [script]
|
||||
gosh [-l] [--noprofile] -c command
|
||||
const usage = `usage: gosh [-l] [-i] [--norc] [--noprofile] [script [args ...]]
|
||||
gosh [-l] [--noprofile] -c command [name [args ...]]
|
||||
gosh -version`
|
||||
|
||||
// sysConfDir holds the system-wide startup files.
|
||||
@@ -949,6 +987,7 @@ func main() {
|
||||
if strings.HasPrefix(os.Args[0], "-") {
|
||||
args = append([]string{"-l"}, args...)
|
||||
}
|
||||
scriptName = os.Args[0]
|
||||
os.Exit(run(args))
|
||||
}
|
||||
|
||||
@@ -986,21 +1025,26 @@ flags:
|
||||
fmt.Println(usage)
|
||||
return 0
|
||||
case arg == "-c":
|
||||
// Like sh, further arguments are allowed (they would become $0,
|
||||
// $1, ...), but gosh has no positional parameters yet.
|
||||
// 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)
|
||||
@@ -1064,6 +1108,8 @@ func sourceStartupFile(path string) {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
sourceDepth++
|
||||
defer func() { sourceDepth-- }()
|
||||
runScript(bufio.NewReader(f), path)
|
||||
}
|
||||
|
||||
@@ -1127,12 +1173,18 @@ func runScript(r io.Reader, name string) int {
|
||||
goto next // read the rest of the command
|
||||
}
|
||||
buf.Reset()
|
||||
if err != nil {
|
||||
lastStatus = exitStatus(err)
|
||||
report(err, startLine)
|
||||
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 {
|
||||
report(tree.exec(), startLine)
|
||||
lastStatus = exitStatus(err)
|
||||
}
|
||||
report(err, startLine)
|
||||
}
|
||||
next:
|
||||
if readErr != nil {
|
||||
@@ -1192,6 +1244,10 @@ func exitStatus(err error) int {
|
||||
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() {
|
||||
@@ -1339,11 +1395,12 @@ func printError(err error) {
|
||||
exitErr *exec.ExitError
|
||||
statusErr statusError
|
||||
lc loopControl
|
||||
ret returnSignal
|
||||
)
|
||||
if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) || errors.As(err, &lc) {
|
||||
if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) || errors.As(err, &lc) || errors.As(err, &ret) {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "gosh:", err)
|
||||
fmt.Fprintln(stdio(2), "gosh:", err)
|
||||
}
|
||||
|
||||
// execInput parses and executes input: a command line or a whole script.
|
||||
@@ -1476,7 +1533,7 @@ func runPipeline(stages []stage, text string, background bool) error {
|
||||
shellTTY, _ = readline.GetState(ttyFd)
|
||||
}
|
||||
|
||||
stdin := os.Stdin
|
||||
stdin := stdio(0)
|
||||
if background && !jobControl {
|
||||
// Without job control background jobs must not read from the
|
||||
// terminal, like in sh
|
||||
@@ -1486,12 +1543,12 @@ func runPipeline(stages []stage, text string, background bool) error {
|
||||
}
|
||||
var startErr error
|
||||
for i, st := range stages {
|
||||
stdout := os.Stdout
|
||||
stdout := stdio(1)
|
||||
var next *os.File
|
||||
if i < len(stages)-1 {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
if stdin != os.Stdin {
|
||||
if stdin != stdio(0) {
|
||||
stdin.Close()
|
||||
}
|
||||
startErr = err
|
||||
@@ -1531,10 +1588,10 @@ func runPipeline(stages []stage, text string, background bool) error {
|
||||
// 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 {
|
||||
var owned []*os.File
|
||||
if stdin != os.Stdin {
|
||||
if stdin != stdio(0) {
|
||||
owned = append(owned, stdin)
|
||||
}
|
||||
if stdout != os.Stdout {
|
||||
if stdout != stdio(1) {
|
||||
owned = append(owned, stdout)
|
||||
}
|
||||
closeOwned := func() {
|
||||
@@ -1547,7 +1604,7 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
|
||||
// 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, os.Stderr}
|
||||
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 {
|
||||
@@ -1570,7 +1627,12 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
|
||||
return finishedProc(err)
|
||||
}
|
||||
|
||||
if builtin, ok := builtins[st.args[0]]; ok {
|
||||
fn, isFunc := functions[st.args[0]]
|
||||
if isFunc && !inPipeline && !background {
|
||||
defer closeOwned()
|
||||
return finishedProc(callFunction(fn, st.args, fds, st.assigns))
|
||||
}
|
||||
if builtin, ok := builtins[st.args[0]]; !isFunc && ok {
|
||||
switch {
|
||||
case inPipeline && builtin.changesState:
|
||||
closeOwned()
|
||||
@@ -1581,8 +1643,8 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
|
||||
}
|
||||
run := func() error {
|
||||
defer closeOwned()
|
||||
err := runBuiltin(st.args, fds[1])
|
||||
if err != nil && fds[2] != os.Stderr {
|
||||
err := runBuiltin(st.args, fds[0], fds[1])
|
||||
if err != nil && fds[2] != stdio(2) {
|
||||
// Report the error where stderr is redirected to
|
||||
fmt.Fprintln(fds[2], "gosh:", err)
|
||||
return statusError(1)
|
||||
@@ -1599,7 +1661,17 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
|
||||
return p
|
||||
}
|
||||
|
||||
cmd := exec.Command(st.args[0], st.args[1:]...)
|
||||
var cmd *exec.Cmd
|
||||
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
|
||||
@@ -1734,10 +1806,15 @@ var builtins = map[string]struct{ changesState bool }{
|
||||
"unset": {changesState: true},
|
||||
"break": {changesState: true},
|
||||
"continue": {changesState: true},
|
||||
"return": {changesState: true},
|
||||
"local": {changesState: true},
|
||||
"shift": {changesState: true},
|
||||
"read": {changesState: true},
|
||||
}
|
||||
|
||||
// runBuiltin executes a built-in command, writing its output to stdout.
|
||||
func runBuiltin(args []string, stdout io.Writer) error {
|
||||
// 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 -')
|
||||
@@ -1790,6 +1867,11 @@ func runBuiltin(args []string, stdout io.Writer) error {
|
||||
}
|
||||
return exitShell(lastStatus)
|
||||
case "set":
|
||||
// set -- args: set the positional parameters
|
||||
if len(args) > 1 && args[1] == "--" {
|
||||
positional = slices.Clone(args[2:])
|
||||
return nil
|
||||
}
|
||||
// set VAR=value - set environment variable
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("usage: set VAR=value")
|
||||
@@ -1817,12 +1899,21 @@ func runBuiltin(args []string, stdout io.Writer) error {
|
||||
// Does nothing, successfully (e.g. while :; do ...)
|
||||
return nil
|
||||
case "unset":
|
||||
// unset NAME ... - remove variables
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("usage: unset NAME ...")
|
||||
// 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 args[1:] {
|
||||
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
|
||||
@@ -1832,6 +1923,14 @@ func runBuiltin(args []string, stdout io.Writer) error {
|
||||
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":
|
||||
@@ -1845,9 +1944,15 @@ func runBuiltin(args []string, stdout io.Writer) error {
|
||||
case "wait":
|
||||
return builtinWait(args)
|
||||
case "source", ".":
|
||||
// source file - run the commands of file in the current shell
|
||||
// source file [args] - run the commands of file in the current shell
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("usage: %s file", args[0])
|
||||
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":
|
||||
@@ -1860,9 +1965,10 @@ Built-in commands:
|
||||
exit [code] - Exit shell with status code (default: $?)
|
||||
quit [code] - Quit shell with status code (default: $?)
|
||||
set VAR=val - Set environment variable
|
||||
set -- args - Set the positional parameters $1, $2, ...
|
||||
export VAR=val - Export environment variable
|
||||
unset VAR - Remove a variable
|
||||
source file - Run the commands of file in this shell (also: . file)
|
||||
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
|
||||
@@ -1872,6 +1978,12 @@ Built-in commands:
|
||||
: - 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)
|
||||
help - Show this help
|
||||
|
||||
Features:
|
||||
@@ -1899,6 +2011,9 @@ Features:
|
||||
a pattern
|
||||
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
|
||||
using an unset variable is an error, use ${VAR:-} for an empty default
|
||||
- Positional parameters: $0, $1 ... $9, ${10}, $# (count), "$@" (one
|
||||
word each), "$*" (one word), $$ (process ID); 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
|
||||
@@ -1906,7 +2021,10 @@ Features:
|
||||
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 negates the status; they can span several lines (prompt "> ")
|
||||
- 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
|
||||
- Job control: command & runs in the background, Ctrl+Z stops the
|
||||
|
||||
Reference in new issue
Block a user