add parameter expansion, arithmetic, set -e/-x/pipefail, trap, eval, exec

- ${#v}, ${v#p} ${v##p} ${v%p} ${v%%p}, ${v/p/r} ${v//p/r} ${v/#p/r}
  ${v/%p/r}, ${v:off:len}, ${v-w} ${v:=w} ${v:+w} ${v:?msg}; nested ${}
- arithmetic expansion $((...)) with the C operators, ** and assignments
- set -e, set -x, set -o pipefail (also as gosh options, passed on to
  subshells); set shows variables, set -o the options
- trap for EXIT and signals; scripts with an EXIT trap run it on
  SIGINT/SIGTERM/SIGHUP too
- eval, exec (also exec > file without a command), $!, wait pid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian WaltherandClaude Opus 5.5 committed 2026-10-04 21:15:24 +02:00
1 parent f66cf538c0
commit 1f507f6562
20 files changed
+2408 -93

No files matched your search

+94 -41
View File
@@ -203,6 +203,16 @@ func tokenize(input string) ([]token, error) {
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)
@@ -522,25 +532,15 @@ func splitWords(input string) ([]string, error) {
// is not followed by a variable name is returned literally.
func expandVar(s string) (string, int, error) {
if len(s) > 1 && s[1] == '{' {
end := strings.IndexByte(s, '}')
end := paramEnd(s, 0)
if end < 0 {
return "", 0, errors.New("missing } in variable reference")
}
name, def, hasDef := strings.Cut(s[2:end], ":-")
if !isVarName(name) && !isDigits(name) && !isSpecialParam(name) {
return "", 0, fmt.Errorf("bad substitution: %s", s[:end+1])
}
value, ok := lookupParam(name)
if hasDef && value == "" {
return def, end + 1, nil
}
if !ok {
return "", 0, fmt.Errorf("%s: unbound variable", name)
}
return value, end + 1, nil
value, err := expandParam(s[2:end])
return value, end + 1, err
}
// $?, $#, $$, $@, $*, $0 and $1 to $9 ($10 is $1 followed by 0)
// $?, $#, $$, $!, $@, $*, $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 {
@@ -564,9 +564,9 @@ func expandVar(s string) (string, int, error) {
}
// isSpecialParam reports whether name is one of the special parameters ?,
// #, $, @ and *.
// #, $, !, @ and *.
func isSpecialParam(name string) bool {
return len(name) == 1 && strings.Contains("?#$@*", name)
return len(name) == 1 && strings.Contains("?#$!@*", name)
}
// positionalWords returns the number of bytes of $@ or ${@} at the start of
@@ -977,8 +977,8 @@ func completeFilePath(line, prefix string) []string {
return matches
}
const usage = `usage: gosh [-l] [-i] [--norc] [--noprofile] [script [args ...]]
gosh [-l] [--noprofile] -c command [name [args ...]]
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.
@@ -1011,7 +1011,25 @@ flags:
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:]
@@ -1072,9 +1090,12 @@ flags:
}
if !interactiveMode {
return script()
status := script()
runExitTrap()
return status
}
interactive()
runExitTrap()
return 0
}
@@ -1189,6 +1210,7 @@ func runScript(r io.Reader, name string) int {
lastStatus = exitStatus(err)
}
report(err, startLine)
runPendingTraps()
}
next:
if readErr != nil {
@@ -1278,7 +1300,7 @@ func interactive() {
// 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(make(chan os.Signal, 1), os.Interrupt)
signal.Notify(interruptChan, os.Interrupt)
histFile := historyFile()
if histFile != "" {
@@ -1308,6 +1330,7 @@ func interactive() {
var buf strings.Builder
for {
if buf.Len() == 0 {
runPendingTraps()
// Report finished and stopped background jobs
reapJobs(true)
// Update prompt dynamically (in case directory changed)
@@ -1611,6 +1634,11 @@ func runPipeline(stages []stage, text string, background bool) error {
}
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)
@@ -1620,6 +1648,21 @@ func runPipeline(stages []stage, text string, background bool) error {
// 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)
@@ -1669,12 +1712,16 @@ func startStage(st stage, stdin, stdout *os.File, j *job, 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:
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:
case background && builtin.changesState && !onlyShows(st.args):
closeOwned()
return finishedProc(fmt.Errorf("%s: cannot be run in the background", st.args[0]))
}
@@ -1895,6 +1942,9 @@ var builtins = map[string]struct{ changesState bool }{
"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
@@ -1952,22 +2002,11 @@ func runBuiltin(args []string, stdin *os.File, 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")
}
// Parse VAR=value
parts := strings.SplitN(args[1], "=", 2)
if len(parts) != 2 {
return fmt.Errorf("usage: set VAR=value")
}
os.Setenv(parts[0], parts[1])
return nil
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 {
@@ -2050,14 +2089,16 @@ 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, ...
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] - Wait for job n or all background jobs
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)
@@ -2069,6 +2110,11 @@ Built-in commands:
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:
@@ -2096,8 +2142,15 @@ Features:
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); set by gosh script
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)"