- ${#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>
376 lines
12 KiB
Go
376 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestExpandParam(t *testing.T) {
|
|
resetShell(t)
|
|
t.Setenv("P", "/usr/local/lib/libfoo.so.1")
|
|
t.Setenv("F", "archive.tar.gz")
|
|
t.Setenv("H", "hello")
|
|
t.Setenv("E", "")
|
|
t.Setenv("U8", "Grüße")
|
|
t.Setenv("SP", "a b c")
|
|
t.Setenv("GLOB", "*.go")
|
|
os.Unsetenv("UNSET")
|
|
positional = []string{"one", "two"}
|
|
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"${#P}", "26"},
|
|
{"${#U8}", "5"},
|
|
{"${#1}", "3"},
|
|
{"${#}", "2"},
|
|
{"${P#*/}", "usr/local/lib/libfoo.so.1"},
|
|
{"${P##*/}", "libfoo.so.1"},
|
|
{"${P%/*}", "/usr/local/lib"},
|
|
{"${P%%.*}", "/usr/local/lib/libfoo"},
|
|
{"${F%.gz}", "archive.tar"},
|
|
{"${F#*.}", "tar.gz"},
|
|
{"${F##*.}", "gz"},
|
|
{"${F%.zip}", "archive.tar.gz"},
|
|
{"${U8%e}", "Grüß"},
|
|
{"${U8#?}", "rüße"},
|
|
{"${H/l/L}", "heLlo"},
|
|
{"${H//l/L}", "heLLo"},
|
|
{"${H//l}", "heo"},
|
|
{"${H/#h/H}", "Hello"},
|
|
{"${H/#e/E}", "hello"},
|
|
{"${H/%o/O}", "hellO"},
|
|
{"${H/l*/X}", "heX"},
|
|
{"${SP// /_}", "a_b__c"},
|
|
{"${H/[aeiou]/_}", "h_llo"},
|
|
{"${H:1}", "ello"},
|
|
{"${H:1:3}", "ell"},
|
|
{"${H: -2}", "lo"},
|
|
{"${H:0:-1}", "hell"},
|
|
{"${H:1+1:2*1}", "ll"},
|
|
{"${H:10}", ""},
|
|
{"${U8:1:3}", "rüß"},
|
|
{"${UNSET-dflt}", "dflt"},
|
|
{"${UNSET:-dflt}", "dflt"},
|
|
{"${E-dflt}", ""},
|
|
{"${E:-dflt}", "dflt"},
|
|
{"${H:-dflt}", "hello"},
|
|
{"${UNSET+alt}", ""},
|
|
{"${E+alt}", "alt"},
|
|
{"${E:+alt}", ""},
|
|
{"${H:+alt}", "alt"},
|
|
{"${UNSET:-${H}}", "hello"},
|
|
{"${UNSET:-${UNSET:-deep}}", "deep"},
|
|
{`${UNSET:-"a b"}`, "a b"},
|
|
{"${UNSET:-a b}", "a b"},
|
|
{"${UNSET:-$(echo sub)}", "sub"},
|
|
{"${UNSET:-$((1+2))}", "3"},
|
|
{"${UNSET:-}", ""},
|
|
{"${2:-x}", "two"},
|
|
{"${3:-three}", "three"},
|
|
// Quoted characters and values of variables are no patterns
|
|
{"${GLOB#*.}", "go"},
|
|
{`${GLOB#"*"}`, ".go"},
|
|
{"${GLOB%$GLOB}", ""},
|
|
{"${F%$GLOB}", "archive.tar.gz"},
|
|
{`${P//\//:}`, ":usr:local:lib:libfoo.so.1"},
|
|
}
|
|
for _, tc := range tests {
|
|
got, err := splitWords(`"` + tc.input + `"`)
|
|
if err != nil {
|
|
t.Errorf("%s: %v", tc.input, err)
|
|
continue
|
|
}
|
|
if len(got) != 1 || got[0] != tc.want {
|
|
t.Errorf("%s = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
}
|
|
|
|
// := assigns
|
|
defer os.Unsetenv("GOSH_ASSIGN")
|
|
os.Unsetenv("GOSH_ASSIGN")
|
|
if got, _ := splitWords("${GOSH_ASSIGN:=set} $GOSH_ASSIGN"); strings.Join(got, " ") != "set set" {
|
|
t.Errorf(":= gives %q", got)
|
|
}
|
|
|
|
for input, want := range map[string]string{
|
|
"${UNSET}": "UNSET: unbound variable",
|
|
"${#UNSET}": "UNSET: unbound variable",
|
|
"${UNSET%x}": "UNSET: unbound variable",
|
|
"${UNSET:?is required}": "UNSET: is required",
|
|
"${E:?}": "E: parameter null or not set",
|
|
"${1x}": "bad substitution: ${1x}",
|
|
"${H!}": "bad substitution: ${H!}",
|
|
"${3:=x}": "$3: cannot assign in this way",
|
|
"${H:3:-5}": "-5: substring expression < 0",
|
|
"${H": "missing } in variable reference",
|
|
} {
|
|
if _, err := splitWords(input); err == nil || !strings.Contains(err.Error(), want) {
|
|
t.Errorf("%s: err = %v, want %q", input, err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestArith(t *testing.T) {
|
|
t.Setenv("N", "5")
|
|
t.Setenv("EXPR", "N*2")
|
|
t.Setenv("EMPTY", "")
|
|
os.Unsetenv("UNSET")
|
|
tests := []struct {
|
|
expr string
|
|
want int64
|
|
}{
|
|
{"1+2*3", 7},
|
|
{"(1+2)*3", 9},
|
|
{"7/2", 3},
|
|
{"-7/2", -3},
|
|
{"7%3", 1},
|
|
{"2**10", 1024},
|
|
{"-2**2", 4},
|
|
{"2**3**2", 512},
|
|
{"1<<4", 16},
|
|
{"256>>4", 16},
|
|
{"~0", -1},
|
|
{"!0", 1},
|
|
{"!5", 0},
|
|
{"3 > 2 && 2 > 1", 1},
|
|
{"0 || 0", 0},
|
|
{"5 & 3", 1},
|
|
{"5 | 3", 7},
|
|
{"5 ^ 3", 6},
|
|
{"1 ? 10 : 20", 10},
|
|
{"0 ? 10 : 20", 20},
|
|
{"0x1F", 31},
|
|
{"010", 8},
|
|
{"N", 5},
|
|
{"N * N", 25},
|
|
{"EXPR + 1", 11},
|
|
{"EMPTY + 1", 1},
|
|
{"1, 2, 3", 3},
|
|
{"", 0},
|
|
{" 42 ", 42},
|
|
// Short-circuit: no error and no assignment on the other side
|
|
{"0 && 1/0", 0},
|
|
{"1 || UNSET", 1},
|
|
{"1 ? 2 : 1/0", 2},
|
|
}
|
|
for _, tc := range tests {
|
|
got, err := evalArith(tc.expr, 0)
|
|
if err != nil || got != tc.want {
|
|
t.Errorf("$((%s)) = %d, %v, want %d", tc.expr, got, err, tc.want)
|
|
}
|
|
}
|
|
|
|
for expr, want := range map[string]string{
|
|
"1/0": "division by 0",
|
|
"1%0": "division by 0",
|
|
"2**-1": "exponent less than 0",
|
|
"UNSET+1": "UNSET: unbound variable",
|
|
"1 +": "arithmetic syntax error",
|
|
"(1": "arithmetic syntax error",
|
|
"1 2": "arithmetic syntax error",
|
|
"1 @ 2": "invalid arithmetic operator",
|
|
"09": "value too great for base",
|
|
"5 = 3": "arithmetic syntax error",
|
|
} {
|
|
if _, err := evalArith(expr, 0); err == nil || !strings.Contains(err.Error(), want) {
|
|
t.Errorf("$((%s)): err = %v, want %q", expr, err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExec_Arith(t *testing.T) {
|
|
resetShell(t)
|
|
for _, name := range []string{"I", "J"} {
|
|
os.Unsetenv(name)
|
|
defer os.Unsetenv(name)
|
|
}
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"echo $((1 + 2)) > out", "3\n"},
|
|
{"I=5; echo $((I + 1)) $(($I * 2)) > out", "6 10\n"},
|
|
{"I=5; echo $((I++)) $I $((++I)) $((I += 10)) $I > out", "5 6 7 17 17\n"},
|
|
{"I=1; : $((I = I * 3)); echo $I > out", "3\n"},
|
|
{"I=0; while [ $I -lt 3 ]; do I=$((I + 1)); done; echo $I > out", "3\n"},
|
|
{"echo $(( $(echo 3) + ${J:-4} )) > out", "7\n"},
|
|
{`echo "$((2 * 21))" > out`, "42\n"},
|
|
{"echo x$((1+1))y > out", "x2y\n"},
|
|
{"set -- 3 4; echo $(($1 * $2)) $(($# + 1)) > out", "12 3\n"},
|
|
// $( ( ... ) ) is a command substitution of a subshell
|
|
{"echo $( (echo sub) ) > out", "sub\n"},
|
|
{"cat <<EOF > out\n$((6 * 7))\nEOF", "42\n"},
|
|
{"case $((1 + 1)) in 2) echo two > out;; esac", "two\n"},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := runIn(t, tc.input); got != tc.want {
|
|
t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// goshOutput runs gosh -c script in a new process and returns its combined
|
|
// output and exit status.
|
|
func goshOutput(t *testing.T, script string, args ...string) (string, int) {
|
|
t.Helper()
|
|
cmd := exec.Command(subshellPath, append([]string{"-c", script, "gosh"}, args...)...)
|
|
cmd.Dir = t.TempDir()
|
|
out, err := cmd.CombinedOutput()
|
|
return string(out), exitStatus(err)
|
|
}
|
|
|
|
func TestShell_Errexit(t *testing.T) {
|
|
tests := []struct {
|
|
script string
|
|
want string
|
|
status int
|
|
}{
|
|
{"set -e; echo a; false; echo never", "a\n", 1},
|
|
{"set -e; sh -c 'exit 3'; echo never", "", 3},
|
|
{"set -e; gosh-no-such-cmd; echo never", "gosh: gosh-no-such-cmd: command not found\n", 127},
|
|
// Not for conditions, negated commands and && || lists (except the last)
|
|
{"set -e; if false; then :; fi; while false; do :; done; until true; do :; done; echo ok", "ok\n", 0},
|
|
{"set -e; false || echo or; false && echo no; ! true; echo ok", "or\nok\n", 0},
|
|
{"set -e; true && false; echo never", "", 1},
|
|
{"set -e; false || false; echo never", "", 1},
|
|
{"set -e; f() { false; echo never; }; if f; then :; fi; echo cond-ok", "never\ncond-ok\n", 0},
|
|
// Also in functions, loops, subshells, pipelines and $(...)
|
|
{"set -e; f() { false; echo never; }; f; echo never", "", 1},
|
|
{"set -e; for x in 1; do false; echo never; done", "", 1},
|
|
{"set -e; (false; echo never); echo never", "", 1},
|
|
{"set -e; (exit 4); echo never", "", 4},
|
|
{"set -e; true | false; echo never", "", 1},
|
|
{"set -e; x=$(false); echo never", "", 1},
|
|
{"set -e; set +e; false; echo off", "off\n", 0},
|
|
// gosh -e
|
|
{"false; echo never", "", 1},
|
|
}
|
|
for i, tc := range tests {
|
|
script := tc.script
|
|
var out string
|
|
var status int
|
|
if i == len(tests)-1 {
|
|
cmd := exec.Command(subshellPath, "-e", "-c", script)
|
|
b, err := cmd.CombinedOutput()
|
|
out, status = string(b), exitStatus(err)
|
|
} else {
|
|
out, status = goshOutput(t, script)
|
|
}
|
|
if out != tc.want || status != tc.status {
|
|
t.Errorf("%q: %q, status %d, want %q, %d", tc.script, out, status, tc.want, tc.status)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShell_Options(t *testing.T) {
|
|
tests := []struct {
|
|
script string
|
|
want string
|
|
}{
|
|
{"set -x; x=1; echo \"a b\" $x", "+ x=1\n+ echo 'a b' 1\na b 1\n"},
|
|
{"set -x; f() { echo f; }; f", "+ f\n+ echo f\nf\n"},
|
|
// Each command once, also a function in a pipeline (in any order)
|
|
{"f() { echo f; }; (set -x; f | cat) 2>&1 | sort", "+ cat\n+ echo f\n+ f\nf\n"},
|
|
{"set -x; set +x; echo quiet", "+ set +x\nquiet\n"},
|
|
{"false | true; echo $?; set -o pipefail; false | true; echo $?; sh -c 'exit 3' | false | true; echo $?", "0\n1\n1\n"},
|
|
{"set -o pipefail; true | true; echo $?", "0\n"},
|
|
{"set -euo pipefail; set -o", "errexit on\nnounset on\npipefail on\nxtrace off\n"},
|
|
{"set -e; set +o | grep errexit", "set -o errexit\n"},
|
|
{"set -- a b; set -e x y z; echo $# $1", "3 x\n"},
|
|
{"set -e; (set -o | grep errexit)", "errexit on\n"},
|
|
{"set +u", "gosh: set: +u: unset variables are always an error in gosh\n"},
|
|
{"set -k", "gosh: set: -k: invalid option\n"},
|
|
}
|
|
for _, tc := range tests {
|
|
if out, _ := goshOutput(t, tc.script); out != tc.want {
|
|
t.Errorf("%q: %q, want %q", tc.script, out, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShell_EvalExec(t *testing.T) {
|
|
tests := []struct {
|
|
script string
|
|
want string
|
|
status int
|
|
}{
|
|
{`cmd="echo a; echo b"; eval "$cmd"`, "a\nb\n", 0},
|
|
{`eval x=5; echo $x`, "5\n", 0},
|
|
{`x=y; y=deep; eval echo \$$x`, "deep\n", 0},
|
|
{`eval "f() { echo from-eval; }"; f`, "from-eval\n", 0},
|
|
{`eval "echo piped" | tr a-z A-Z`, "PIPED\n", 0},
|
|
{`eval 'exit 3'; echo never`, "", 3},
|
|
{`eval; echo empty`, "empty\n", 0},
|
|
{`eval "if"`, "gosh: eval: syntax error: unexpected end of input\n", 1},
|
|
{`for i in 1 2; do eval "v$i=$i"; done; echo $v1$v2`, "12\n", 0},
|
|
{`exec echo replaced; echo never`, "replaced\n", 0},
|
|
{`X=1 exec sh -c 'echo $X'`, "1\n", 0},
|
|
{`exec > out; echo to-file; exec >&2; cat out`, "to-file\n", 0},
|
|
{`exec 2> err; ls /gosh-no-such-dir; exec 2>&1; grep -c gosh-no-such-dir err`, "1\n", 0},
|
|
{`printf 'l1\nl2\n' > in; exec < in; read a; read b; echo $b $a`, "l2 l1\n", 0},
|
|
{`exec gosh-no-such-cmd; echo never`, "gosh: gosh-no-such-cmd: command not found\n", 127},
|
|
{`exec echo a | tr a A`, "A\n", 0},
|
|
}
|
|
for _, tc := range tests {
|
|
if out, status := goshOutput(t, tc.script); out != tc.want || status != tc.status {
|
|
t.Errorf("%q: %q, status %d, want %q, %d", tc.script, out, status, tc.want, tc.status)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShell_Trap(t *testing.T) {
|
|
tests := []struct {
|
|
script string
|
|
want string
|
|
status int
|
|
}{
|
|
{`trap "echo bye" EXIT; echo running`, "running\nbye\n", 0},
|
|
{`trap 'echo "bye $?"' EXIT; exit 3`, "bye 3\n", 3},
|
|
{`trap 'echo "bye $?"' EXIT; false`, "bye 1\n", 1},
|
|
{`trap 'echo bye; exit 5' EXIT; true`, "bye\n", 5},
|
|
{`trap 'echo exit-trap' 0; f() { exit 2; }; f`, "exit-trap\n", 2},
|
|
{`trap 'echo set-e' EXIT; set -e; false; echo never`, "set-e\n", 1},
|
|
{`trap "echo bye" EXIT; trap - EXIT; echo no-trap`, "no-trap\n", 0},
|
|
{`trap 'echo got USR1' USR1; kill -USR1 $$; sleep 0.2; echo after`, "got USR1\nafter\n", 0},
|
|
// The trap runs after the current command (the signal arrives
|
|
// asynchronously, hence the sleep)
|
|
{`trap 'echo got $?' SIGUSR2; false; kill -USR2 $$; sleep 0.2; echo status $?`, "got 0\nstatus 0\n", 0},
|
|
{`trap '' USR1; kill -USR1 $$; echo ignored`, "ignored\n", 0},
|
|
{`trap '' USR1; sh -c 'kill -USR1 $$; echo child-ignores'`, "child-ignores\n", 0},
|
|
{`trap 'echo t' INT TERM; trap - INT; trap`, "trap -- 'echo t' TERM\n", 0},
|
|
{`trap 'echo t' INT; trap -p INT | cat`, "trap -- 'echo t' INT\n", 0},
|
|
{`trap 'echo cleanup' EXIT; kill -TERM $$; echo never`, "cleanup\n", 128 + 15},
|
|
{`trap 'echo term; exit 9' TERM; kill $$; echo never`, "term\n", 9},
|
|
{`trap 'echo x' BOGUS`, "gosh: trap: BOGUS: invalid signal specification\n", 1},
|
|
{`trap -l | grep -c TERM`, "1\n", 0},
|
|
// Traps are not inherited by subshells, ignored signals are
|
|
{`trap 'echo parent' EXIT; (echo sub)`, "sub\nparent\n", 0},
|
|
}
|
|
for _, tc := range tests {
|
|
if out, status := goshOutput(t, tc.script); out != tc.want || status != tc.status {
|
|
t.Errorf("%q: %q, status %d, want %q, %d", tc.script, out, status, tc.want, tc.status)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShell_LastBackgroundPid(t *testing.T) {
|
|
tests := []struct {
|
|
script string
|
|
want string
|
|
}{
|
|
{`sleep 0.1 & pid=$!; case $pid in *[!0-9]*) echo bad;; *) echo pid;; esac; wait $pid; echo $?`, "pid\n0\n"},
|
|
{`sh -c 'exit 7' & wait $!; echo $?`, "7\n"},
|
|
{`echo ${!:-none}`, "none\n"},
|
|
{`sleep 0.1 & sleep 0.2 & first=$!; wait %1; echo $?; wait $first; echo $?`, "0\n0\n"},
|
|
}
|
|
for _, tc := range tests {
|
|
if out, _ := goshOutput(t, tc.script); out != tc.want {
|
|
t.Errorf("%q: %q, want %q", tc.script, out, tc.want)
|
|
}
|
|
}
|
|
}
|