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"`, "\n\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"}, "", "\n\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) } }