- 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>
364 lines
13 KiB
Go
364 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// resetShell removes functions and positional parameters after a test.
|
|
func resetShell(t *testing.T) {
|
|
t.Helper()
|
|
t.Cleanup(func() {
|
|
functions = map[string]*funcDef{}
|
|
positional, scriptName = nil, "gosh"
|
|
})
|
|
}
|
|
|
|
func TestTokenize_PositionalParameters(t *testing.T) {
|
|
resetShell(t)
|
|
scriptName, positional = "prog", []string{"a", "b c", ""}
|
|
|
|
tests := []struct {
|
|
input string
|
|
want []string
|
|
}{
|
|
{"echo $0 $1 $2", []string{"echo", "prog", "a", "b c"}},
|
|
{"echo $# ${#}", []string{"echo", "3", "3"}},
|
|
{`echo "$@"`, []string{"echo", "a", "b c", ""}},
|
|
{`echo x"$@"y`, []string{"echo", "xa", "b c", "y"}},
|
|
{`echo $@`, []string{"echo", "a", "b c", ""}},
|
|
{`echo "$*"`, []string{"echo", "a b c "}},
|
|
{`echo ${2}x $10`, []string{"echo", "b cx", "a0"}},
|
|
{`echo ${4:-four}`, []string{"echo", "four"}},
|
|
{`echo '$1' \$1`, []string{"echo", "$1", "$1"}},
|
|
}
|
|
for _, tc := range tests {
|
|
got, err := splitWords(tc.input)
|
|
if err != nil {
|
|
t.Errorf("%q: %v", tc.input, err)
|
|
continue
|
|
}
|
|
if !reflect.DeepEqual(got, tc.want) {
|
|
t.Errorf("%q: got %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
}
|
|
|
|
// An unset positional parameter is an error, like an unset variable
|
|
if _, err := splitWords("echo $4"); err == nil || !strings.Contains(err.Error(), "4: unbound variable") {
|
|
t.Errorf("$4: err = %v", err)
|
|
}
|
|
|
|
// "$@" without parameters is no word at all
|
|
positional = nil
|
|
for input, want := range map[string][]string{
|
|
`echo "$@"`: {"echo"},
|
|
`echo "$@" x`: {"echo", "x"},
|
|
`echo "x$@"`: {"echo", "x"},
|
|
`echo "$*"`: {"echo", ""},
|
|
`echo $#`: {"echo", "0"},
|
|
} {
|
|
if got, _ := splitWords(input); !reflect.DeepEqual(got, want) {
|
|
t.Errorf("%q without parameters: got %q, want %q", input, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExec_Functions(t *testing.T) {
|
|
resetShell(t)
|
|
for _, name := range []string{"GOSH_A", "GOSH_L"} {
|
|
os.Unsetenv(name)
|
|
defer os.Unsetenv(name)
|
|
}
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"f() { echo hi > out; }; f", "hi\n"},
|
|
{"f () { echo $1-$2-$# >> out; }; f a b", "a-b-2\n"},
|
|
{"f(){ echo braces >> out; }; f", "braces\n"},
|
|
{"function f { echo keyword >> out; }; f", "keyword\n"},
|
|
{"function f() { echo both >> out; }; f", "both\n"},
|
|
{"f() {\n echo multi >> out\n echo line >> out\n}\nf", "multi\nline\n"},
|
|
{"f()\n{\n echo next-line >> out\n}\nf", "next-line\n"},
|
|
{"f() if true; then echo if-body >> out; fi; f", "if-body\n"},
|
|
{`f() { for a in "$@"; do echo "<$a>" >> out; done; }; f x "y z"`, "<x>\n<y z>\n"},
|
|
// The positional parameters are restored after the call
|
|
{`set -- outer; f() { echo $1 >> out; }; f inner; echo $1 >> out`, "inner\nouter\n"},
|
|
// Functions change the shell (variables, directory)
|
|
{"f() { GOSH_A=set; }; f; echo $GOSH_A > out", "set\n"},
|
|
// return
|
|
{"f() { echo a >> out; return; echo b >> out; }; f", "a\n"},
|
|
{"f() { return 3; }; f; echo $? > out", "3\n"},
|
|
{"f() { false; return; }; f; echo $? > out", "1\n"},
|
|
{"f() { for x in 1 2 3; do if [ $x = 2 ]; then return 7; fi; echo $x >> out; done; }; f; echo $? >> out", "1\n7\n"},
|
|
{"f() { sh -c 'exit 4'; }; f; echo $? > out", "4\n"},
|
|
// local
|
|
{"GOSH_L=global; f() { local GOSH_L=local; echo $GOSH_L >> out; }; f; echo $GOSH_L >> out", "local\nglobal\n"},
|
|
{"f() { local GOSH_L; GOSH_L=x; }; f; echo ${GOSH_L:-unset} > out", "unset\n"},
|
|
{"f() { local GOSH_L=1; g; echo $GOSH_L >> out; }; g() { GOSH_L=2; }; f", "2\n"},
|
|
// Assignments before a call apply only while it runs
|
|
{"f() { echo $GOSH_A >> out; }; GOSH_A=temp f; echo ${GOSH_A:-unset} >> out", "temp\nunset\n"},
|
|
// Redirections of a call apply to all commands in the function
|
|
{"f() { echo one; echo two; }; f > out", "one\ntwo\n"},
|
|
{"f() { echo err >&2; }; f 2> out", "err\n"},
|
|
{"echo input > in; f() { cat; }; f < in > out", "input\n"},
|
|
{"f() { echo nested > out; }; g() { f; }; g", "nested\n"},
|
|
// Functions run before builtins and commands of the same name
|
|
{"echo() { printf 'own\\n' > out; }; echo x; unset -f echo", "own\n"},
|
|
{"f() { echo x > out; }; unset -f f; f", ""},
|
|
// In pipelines, in the background and in $(...) they run in a subshell
|
|
{"f() { echo piped; }; f | tr a-z A-Z > out", "PIPED\n"},
|
|
{"f() { tr a-z A-Z; }; echo lower | f > out", "LOWER\n"},
|
|
{"f() { echo bg > out; }; f & wait", "bg\n"},
|
|
{`f() { echo "sub $1"; }; echo "$(f arg)" > out`, "sub arg\n"},
|
|
{`f() { g; }; g() { echo g-in-sub; }; echo $(f) > out`, "g-in-sub\n"},
|
|
{`set -- p1; echo "$(echo $1)" > out`, "p1\n"},
|
|
{"f() { GOSH_A=changed; }; f | cat; echo ${GOSH_A:-unchanged} > out", "unchanged\n"},
|
|
}
|
|
for _, tc := range tests {
|
|
os.Unsetenv("GOSH_A")
|
|
os.Unsetenv("GOSH_L")
|
|
if got := runIn(t, tc.input); got != tc.want {
|
|
t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
if funcDepth != 0 || len(localFrames) != 0 || ioOverride != [3]*os.File{} {
|
|
t.Fatalf("%q: function state not restored", tc.input)
|
|
}
|
|
}
|
|
|
|
for input, want := range map[string]string{
|
|
"return": "return: can only `return' from a function or sourced script",
|
|
"local x": "local: can only be used in a function",
|
|
"f() { f; }; f": "f: maximum function nesting level exceeded (1000)",
|
|
"f() echo x": "f: the body of a function must be a compound command like { ...; }",
|
|
"f() { return x; }; f": "return: x: numeric argument required",
|
|
} {
|
|
if err := execInput(input); err == nil || err.Error() != want {
|
|
t.Errorf("%q: err = %v, want %q", input, err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExec_Case(t *testing.T) {
|
|
resetShell(t)
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"case abc in a*) echo a > out;; b*) echo b > out;; esac", "a\n"},
|
|
{"case bcd in a*) echo a > out;; b*) echo b > out;; esac", "b\n"},
|
|
{"case xyz in a*) echo a > out;; esac", ""},
|
|
{"case stop in start|run) echo s > out;; stop|halt) echo h > out;; esac", "h\n"},
|
|
{"case x in (x) echo paren > out;; esac", "paren\n"},
|
|
{"case x in x)echo tight > out;;esac", "tight\n"},
|
|
{"case x in x ) echo blank > out ;; esac", "blank\n"},
|
|
{"case x in\n y) echo y > out ;;\n x)\n echo x1 >> out\n echo x2 >> out\n ;;\nesac", "x1\nx2\n"},
|
|
// The last clause needs no ;;
|
|
{"case x in x) echo last > out; esac", "last\n"},
|
|
{"case x in x) ;; esac; echo empty > out", "empty\n"},
|
|
{"case x in esac; echo none > out", "none\n"},
|
|
// Patterns: wildcards, also across /, quoted characters are literal
|
|
{"case a/b/c in *) echo star > out;; esac", "star\n"},
|
|
{"case a/b in a?b) echo q > out;; esac", "q\n"},
|
|
{"case b in [abc]) echo class > out;; esac", "class\n"},
|
|
{"case d in [!abc]) echo neg > out;; esac", "neg\n"},
|
|
{`case '*' in "*") echo quoted > out;; *) echo glob > out;; esac`, "quoted\n"},
|
|
{`case abc in "*") echo quoted > out;; *) echo glob > out;; esac`, "glob\n"},
|
|
{`case 'a b' in "a b") echo space > out;; esac`, "space\n"},
|
|
{`case 'x)' in 'x)') echo paren-in-quotes > out;; esac`, "paren-in-quotes\n"},
|
|
// Variables in the word and in patterns
|
|
{`set -- --help; case $1 in -h|--help) echo help > out;; esac`, "help\n"},
|
|
// Values of variables are no patterns (like everywhere in gosh)
|
|
{`P='b*'; case bar in $P) echo pattern > out;; *) echo literal > out;; esac`, "literal\n"},
|
|
{`P='b*'; case 'b*' in $P) echo same > out;; esac`, "same\n"},
|
|
{`case $(echo hi) in hi) echo subst > out;; esac`, "subst\n"},
|
|
{`case "" in "") echo empty-word > out;; esac`, "empty-word\n"},
|
|
// Only the first matching clause runs
|
|
{"case a in a) echo 1 >> out;; *) echo 2 >> out;; esac", "1\n"},
|
|
// Nested in other structures
|
|
{"for x in a b; do case $x in a) echo A >> out;; *) echo other >> out;; esac; done", "A\nother\n"},
|
|
{"f() { case $1 in y) return 0;; *) return 1;; esac; }; f y && echo yes > out", "yes\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)
|
|
}
|
|
}
|
|
|
|
for input, status := range map[string]int{
|
|
"case x in y) false;; esac": 0,
|
|
"case x in x) false;; esac": 1,
|
|
"case x in x) true;; esac": 0,
|
|
"case x in x) echo;": 2,
|
|
"case x y in x) ;; esac": 2,
|
|
"echo a;; echo b": 2,
|
|
} {
|
|
runIn(t, input)
|
|
if lastStatus != status {
|
|
t.Errorf("%q: $? = %d, want %d", input, lastStatus, status)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExec_Group(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"{ echo a >> out; echo b >> out; }", "a\nb\n"},
|
|
{"{\n echo multi >> out\n}", "multi\n"},
|
|
{"false || { echo fallback >> out; }", "fallback\n"},
|
|
{"echo { } > out", "{ }\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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExec_PositionalBuiltins(t *testing.T) {
|
|
resetShell(t)
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"set -- a b c; echo $# $1 $3 > out", "3 a c\n"},
|
|
{"set -- a b c; shift; echo $# $1 > out", "2 b\n"},
|
|
{"set -- a b c; shift 2; echo $# $1 > out", "1 c\n"},
|
|
{"set -- a b; shift 3; echo $# $? > out", "2 1\n"},
|
|
{"set --; echo $# > out", "0\n"},
|
|
{"set -- a b; for x; do echo $x >> out; done", "a\nb\n"},
|
|
{"set -- 1 2 3; while [ $# -gt 0 ]; do echo $1 >> out; shift; done", "1\n2\n3\n"},
|
|
// source with arguments
|
|
{"echo 'echo $# $1 > out' > lib; set -- x; . ./lib a b", "2 a\n"},
|
|
{"echo 'echo $1 > out' > lib; set -- x; . ./lib a b; echo $1 >> out", "a\nx\n"},
|
|
// return ends a sourced file
|
|
{"printf 'echo one >> out\\nreturn 3\\necho two >> out\\n' > lib; . ./lib; echo $? >> out", "one\n3\n"},
|
|
}
|
|
for _, tc := range tests {
|
|
positional = nil
|
|
if got := runIn(t, tc.input); got != tc.want {
|
|
t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRead(t *testing.T) {
|
|
for _, name := range []string{"GOSH_A", "GOSH_B", "GOSH_C", "REPLY", "IFS"} {
|
|
os.Unsetenv(name)
|
|
defer os.Unsetenv(name)
|
|
}
|
|
tests := []struct {
|
|
input string
|
|
data string
|
|
want string
|
|
}{
|
|
{"read GOSH_A", "hello world\n", "[hello world]"},
|
|
{"read GOSH_A GOSH_B", " one two three \n", "[one][two three]"},
|
|
{"read GOSH_A GOSH_B GOSH_C", "one\n", "[one][][]"},
|
|
{"read GOSH_A", "no newline", "[no newline]"},
|
|
{"read GOSH_A GOSH_B", `a\ b c` + "\n", "[a b][c]"},
|
|
{"read -r GOSH_A GOSH_B", `a\ b c` + "\n", `[a\][b c]`},
|
|
{"read GOSH_A", "con\\\ntinued\n", "[continued]"},
|
|
{"read", " reply \n", "[ reply ]"},
|
|
{"IFS=:; read GOSH_A GOSH_B GOSH_C", "a::b:c\n", "[a][][b:c]"},
|
|
{"IFS=:; read GOSH_A GOSH_B", "a b:c d\n", "[a b][c d]"},
|
|
{"IFS=; read GOSH_A", " keep \n", "[ keep ]"},
|
|
{"read GOSH_A; read GOSH_B", "first\nsecond\n", "[first][second]"},
|
|
}
|
|
for _, tc := range tests {
|
|
for _, name := range []string{"GOSH_A", "GOSH_B", "GOSH_C", "REPLY", "IFS"} {
|
|
os.Unsetenv(name)
|
|
}
|
|
dir := t.TempDir()
|
|
in := filepath.Join(dir, "in")
|
|
os.WriteFile(in, []byte(tc.data), 0644)
|
|
f, _ := os.Open(in)
|
|
saved := os.Stdin
|
|
os.Stdin = f
|
|
execInput(tc.input)
|
|
os.Stdin = saved
|
|
f.Close()
|
|
|
|
var got strings.Builder
|
|
names := []string{"GOSH_A", "GOSH_B", "GOSH_C"}
|
|
if !strings.Contains(tc.input, "GOSH_") {
|
|
names = []string{"REPLY"}
|
|
}
|
|
for _, name := range names {
|
|
if v, ok := os.LookupEnv(name); ok {
|
|
got.WriteString("[" + v + "]")
|
|
}
|
|
}
|
|
if got.String() != tc.want {
|
|
t.Errorf("%q with %q: got %s, want %s", tc.input, tc.data, got.String(), tc.want)
|
|
}
|
|
}
|
|
|
|
// Status 1 at the end of the input, also after a last line without newline
|
|
for data, want := range map[string]int{"line\n": 0, "partial": 1, "": 1} {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "in"), []byte(data), 0644)
|
|
runIn(t, "read GOSH_A < "+filepath.Join(dir, "in"))
|
|
if lastStatus != want {
|
|
t.Errorf("read from %q: $? = %d, want %d", data, lastStatus, want)
|
|
}
|
|
}
|
|
|
|
if err := execInput("read 1x"); err == nil || err.Error() != "read: `1x': not a valid identifier" {
|
|
t.Errorf("read 1x: %v", err)
|
|
}
|
|
if err := execInput("echo a | read GOSH_A"); err == nil || !strings.Contains(err.Error(), "cannot be used in a pipeline") {
|
|
t.Errorf("read in a pipeline: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestShell_Arguments checks $0 and the positional parameters of scripts and
|
|
// gosh -c, and that a script reads lines from stdin with read.
|
|
func TestShell_Arguments(t *testing.T) {
|
|
dir := t.TempDir()
|
|
script := filepath.Join(dir, "script.gosh")
|
|
os.WriteFile(script, []byte(`echo "$# [$1] [$2]"
|
|
usage() {
|
|
echo "usage: $1" >&2
|
|
return 2
|
|
}
|
|
case ${1:-} in
|
|
-h) usage x; exit $? ;;
|
|
esac
|
|
while read -r line; do
|
|
echo "line: $line"
|
|
done
|
|
`), 0644)
|
|
|
|
tests := []struct {
|
|
args []string
|
|
stdin string
|
|
want string
|
|
}{
|
|
{[]string{"-c", `echo $0 $# "$@"`}, "", subshellPath + " 0\n"},
|
|
{[]string{"-c", `echo $0 $# "$@"`, "name", "a", "b c"}, "", "name 2 a b c\n"},
|
|
{[]string{"-c", `for a; do echo "<$a>"; done`, "n", "x y", "z"}, "", "<x y>\n<z>\n"},
|
|
{[]string{script, "one", "two words"}, "l1\nl2\n", "2 [one] [two words]\nline: l1\nline: l2\n"},
|
|
}
|
|
for _, tc := range tests {
|
|
cmd := exec.Command(subshellPath, tc.args...)
|
|
cmd.Stdin = strings.NewReader(tc.stdin)
|
|
out, _ := cmd.Output()
|
|
if string(out) != tc.want {
|
|
t.Errorf("gosh %q: got %q, want %q", tc.args, out, tc.want)
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command(subshellPath, script, "-h")
|
|
out, err := cmd.CombinedOutput()
|
|
if exitStatus(err) != 2 || !strings.Contains(string(out), "usage: x") {
|
|
t.Errorf("script -h: status %d, output %q", exitStatus(err), out)
|
|
}
|
|
}
|