Files
gosh/compound_test.go
Florian WaltherandClaude Opus 5.5 f66cf538c0 add subshells, here-documents and redirections/pipes for control structures
- control structures can have redirections (while read l; do ...; done
  < file), which apply to all commands in them; they run in the shell
- in pipes and in the background they run in a subshell (cmd | while
  read l; do ...; done, for ...; done &, a && b &)
- ( list ) subshells; ( and ) are operators now
- here-documents <<EOF, <<-EOF, <<'EOF' and here-strings <<<word
- redirections of source apply to the commands of the file
- fix non-ASCII characters in quotes being mangled ('für')

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:57:15 +02:00

275 lines
10 KiB
Go

package main
import (
"errors"
"os"
"reflect"
"strings"
"testing"
"time"
)
func TestExec_CompoundRedirections(t *testing.T) {
resetShell(t)
for _, name := range []string{"GOSH_A", "GOSH_N"} {
os.Unsetenv(name)
defer os.Unsetenv(name)
}
tests := []struct {
input string
want string
}{
{"if true; then echo yes; fi > out", "yes\n"},
{"for x in a b; do echo $x; done > out", "a\nb\n"},
{"for x in a b; do echo $x; done >> out; echo c >> out", "a\nb\nc\n"},
{"{ echo out; echo err >&2; } > out 2>&1", "out\nerr\n"},
{"{ echo err >&2; } 2> out", "err\n"},
{"case x in x) echo case;; esac > out", "case\n"},
// The redirections apply to all commands, also nested ones
{"{ echo a; { echo b; }; } > out", "a\nb\n"},
{"{ echo a; echo b > other; echo c; } > out", "a\nc\n"},
{"f() { echo from-f; }; { f; } > out", "from-f\n"},
// Reading a file line by line in the shell itself
{"printf 'l1\\nl2 x\\n' > in; while read -r l; do echo \"<$l>\" >> out; done < in", "<l1>\n<l2 x>\n"},
{"printf 'a b\\nc d\\n' > in; while read x y; do echo $y$x >> out; done < in", "ba\ndc\n"},
{"printf '1\\n2\\n3\\n' > in; GOSH_N=0; while read l; do GOSH_N=$(expr $GOSH_N + $l); done < in; echo $GOSH_N > out", "6\n"},
// ... with the loop's variables changed in the shell
{"printf 'v\\n' > in; while read GOSH_A; do :; done < in; echo ${GOSH_A:-empty} > out", "empty\n"},
{"printf 'v\\n' > in; { read GOSH_A; } < in; echo $GOSH_A > out", "v\n"},
// Redirections are expanded
{"GOSH_A=out; { echo var; } > $GOSH_A", "var\n"},
// Files are closed and stdout is restored afterwards
{"{ echo a; } > out; echo b >> out", "a\nb\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)
}
if ioOverride != [3]*os.File{} {
t.Fatalf("%q: ioOverride not restored", tc.input)
}
}
for input, want := range map[string]string{
"{ :; } < gosh-no-such-file": "gosh-no-such-file: no such file or directory",
"{ :; } > {a,b}": "{a,b}: ambiguous redirect",
} {
if err := runInErr(t, input); err == nil || err.Error() != want {
t.Errorf("%q: err = %v, want %q", input, err, want)
}
}
}
// runInErr runs input in a temporary directory and returns its error.
func runInErr(t *testing.T, input string) error {
t.Helper()
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(t.TempDir())
return execInput(input)
}
func TestExec_CompoundPipelines(t *testing.T) {
resetShell(t)
os.Unsetenv("GOSH_A")
defer os.Unsetenv("GOSH_A")
tests := []struct {
input string
want string
}{
{"for x in 3 1 2; do echo $x; done | sort > out", "1\n2\n3\n"},
{"printf 'a b\\nc d\\n' | while read x y; do echo $y$x; done > out", "ba\ndc\n"},
{"echo hi | { read l; echo got $l; } > out", "got hi\n"},
{"if true; then echo yes; fi | tr a-z A-Z > out", "YES\n"},
{"echo x | case y in y) cat;; esac | tr x X > out", "X\n"},
{"for x in a b; do echo $x; done > /dev/null | cat > out", ""},
{"{ echo b; echo a; } | sort | head -1 > out", "a\n"},
// The parts of a pipeline run in subshells: the shell is not changed
{"echo v | while read GOSH_A; do :; done; echo ${GOSH_A:-unset} > out", "unset\n"},
// Functions and positional parameters are known in the subshell
{"f() { echo f-$1; }; set -- p; for x in 1; do f $1; done | cat > out", "f-p\n"},
// The status is the one of the last command
{"echo | while read l; do false; done; echo $? > out", "1\n"},
{"true | if false; then :; else sh -c 'exit 3'; fi; echo $? > out", "3\n"},
{"for x in 1; do sh -c 'exit 4'; done | true; echo $? > out", "0\n"},
{"! echo | while read l; do false; done; echo $? > out", "0\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_Subshells(t *testing.T) {
resetShell(t)
os.Unsetenv("GOSH_A")
defer os.Unsetenv("GOSH_A")
tests := []struct {
input string
want string
}{
{"(echo a; echo b) > out", "a\nb\n"},
{"(echo sub) | tr a-z A-Z > out", "SUB\n"},
// Changes stay in the subshell
{"mkdir d; (cd d; pwd > ../in); cd d 2>/dev/null && echo still-outside > out", "still-outside\n"},
{"GOSH_A=1; (GOSH_A=2; echo $GOSH_A > out); echo $GOSH_A >> out", "2\n1\n"},
{"(exit 3); echo $? > out", "3\n"},
{"(exit 3) || echo failed > out", "failed\n"},
{"(f() { echo in-sub; }; f) > out; f 2>> out || echo no-f >> out", "in-sub\nno-f\n"},
{"(echo a; (echo b)) > out", "a\nb\n"},
{"( echo spaced ) > out", "spaced\n"},
{"(\n echo multi\n echo line\n) > out", "multi\nline\n"},
// The subshell knows the functions and positional parameters
{"f() { echo f-$1; }; set -- p; (f $1) > out", "f-p\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_CompoundBackground(t *testing.T) {
resetShell(t)
tests := []struct {
input string
want string
}{
{"for x in a b; do echo $x; done > out & wait", "a\nb\n"},
{"true && echo and > out & wait", "and\n"},
{"false || echo or > out & wait", "or\n"},
{"(echo sub > out) & wait", "sub\n"},
{"{ echo group; } > out & wait", "group\n"},
{"! false && echo negated > out & wait", "negated\n"},
{"for x in 3 1; do echo $x; done | sort > out & wait", "1\n3\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)
}
}
// The shell does not wait for a background loop
start := time.Now()
runIn(t, "for x in 1; do sleep 1; done & echo $? > out")
if lastStatus != 0 || time.Since(start) > 500*time.Millisecond {
t.Errorf("background loop: $? = %d after %v", lastStatus, time.Since(start))
}
execInput("wait")
}
func TestExtractHeredocs(t *testing.T) {
tests := []struct {
input string
want string
}{
{"cat <<EOF\nhello\nEOF\n", "cat <<<\"hello\"\n"},
{"cat <<EOF\nhello\nEOF", "cat <<<\"hello\"\n"},
{"cat << EOF\na\nb\nEOF\necho x\n", "cat <<<\"a\nb\"\necho x\n"},
{"cat <<'EOF'\n$x \"q\" it's\nEOF\n", "cat <<<'$x \"q\" it'\\''s'\n"},
{"cat <<\"EOF\"\n$x\nEOF\n", "cat <<<'$x'\n"},
{"cat <<\\EOF\n$x\nEOF\n", "cat <<<'$x'\n"},
{"cat <<E\"O\"F\n$x\nEOF\n", "cat <<<'$x'\n"},
{"cat <<EOF\n\"q\" \\\" \\$x \\a\nEOF\n", "cat <<<\"\\\"q\\\" \\\\\\\" \\$x \\\\a\"\n"},
{"cat <<EOF\n$(echo \"a\")\nEOF\n", "cat <<<\"$(echo \"a\")\"\n"},
{"cat <<-EOF\n\t\ttabs\n\tEOF\n", "cat <<<\"tabs\"\n"},
{"cat <<EOF\nEOF\n", "cat </dev/null\n"},
{"cat <<A; cat <<B\na\nA\nb\nB\n", "cat <<<\"a\"; cat <<<\"b\"\n"},
{"cat <<EOF | sort\nb\nEOF\n", "cat <<<\"b\" | sort\n"},
// The delimiter must be the whole line
{"cat <<EOF\n EOF\nEOFX\nEOF\n", "cat <<<\" EOF\nEOFX\"\n"},
// Not here-documents
{"echo '<<EOF'\n", "echo '<<EOF'\n"},
{"echo \"<<EOF\"\n", "echo \"<<EOF\"\n"},
{"echo \\<<x\n", "echo \\<<x\n"},
{"echo # <<EOF\n", "echo # <<EOF\n"},
{"cat <<<word\n", "cat <<<word\n"},
{"echo $(echo a <<<b)\n", "echo $(echo a <<<b)\n"},
}
for _, tc := range tests {
got, err := extractHeredocs(tc.input)
if err != nil {
t.Errorf("%q: %v", tc.input, err)
continue
}
if got != tc.want {
t.Errorf("%q:\n got %q\nwant %q", tc.input, got, tc.want)
}
}
for _, input := range []string{"cat <<EOF", "cat <<EOF\n", "cat <<EOF\nbody\n", "cat <<'EOF\n"} {
if _, err := extractHeredocs(input); !errors.Is(err, errIncomplete) {
t.Errorf("%q: err = %v, want incomplete", input, err)
}
}
for _, input := range []string{"cat <<\n", "cat << ;\n"} {
if _, err := extractHeredocs(input); err == nil || errors.Is(err, errIncomplete) {
t.Errorf("%q: err = %v, want syntax error", input, err)
}
}
}
func TestExec_Heredocs(t *testing.T) {
resetShell(t)
t.Setenv("GOSH_A", "value")
tests := []struct {
input string
want string
}{
{"cat <<EOF > out\nhello\nworld\nEOF", "hello\nworld\n"},
{"cat <<EOF > out\n$GOSH_A ${GOSH_A} $(echo sub) \\$GOSH_A\nEOF", "value value sub $GOSH_A\n"},
{"cat <<'EOF' > out\n$GOSH_A $(echo sub)\nEOF", "$GOSH_A $(echo sub)\n"},
{"cat <<EOF > out\n\"quotes\" 'single' back\\slash\nEOF", "\"quotes\" 'single' back\\slash\n"},
{"cat <<EOF > out\nline \\\ncontinued\nEOF", "line continued\n"},
{"cat <<'EOF' > out\nline \\\nkept\nEOF", "line \\\nkept\n"},
{"cat <<-EOF > out\n\tindented\n\t\tmore\n\tEOF", "indented\nmore\n"},
{"cat <<EOF > out\n *.go {a,b} ~\nEOF", " *.go {a,b} ~\n"},
{"cat <<EOF > out\nEOF", ""},
{"cat <<EOF | tr a-z A-Z > out\npiped\nEOF", "PIPED\n"},
{"cat > out <<EOF\nredirect first\nEOF", "redirect first\n"},
{"cat <<A > out; cat <<B >> out\na\nA\nb\nB", "a\nb\n"},
{"while read l; do echo \"<$l>\" >> out; done <<EOF\none\ntwo\nEOF", "<one>\n<two>\n"},
{"f() { cat; }; f <<EOF > out\nto f\nEOF", "to f\n"},
{"set -- a 'b c'; cat <<EOF > out\n$# $1 $@\nEOF", "2 a a b c\n"},
{"for x in 1 2; do\n cat <<EOF >> out\nx=$x\nEOF\ndone", "x=1\nx=2\n"},
{"echo \"$(cat <<EOF\nin subst\nEOF\n)\" > out", "in subst\n"},
// Here-strings
{"cat <<<word > out", "word\n"},
{"cat <<< \"two words $GOSH_A\" > out", "two words value\n"},
{"cat <<<'*.go' > out", "*.go\n"},
{"read x y <<<'a b c'; echo $y > out", "b c\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)
}
}
}
// TestRunScript_Heredoc checks that a script reads a here-document and
// continues after it, with correct line numbers in error messages.
func TestRunScript_Heredoc(t *testing.T) {
resetShell(t)
got := runIn(t, `printf 'cat <<EOF > out\nbody\nEOF\necho after >> out\n' > s; . ./s`)
if got != "body\nafter\n" {
t.Errorf("script with here-document: out = %q", got)
}
got = runIn(t, `printf 'cat <<EOF\nbody\n' > s; . ./s 2> out`)
if !strings.Contains(got, "s:1: syntax error: unexpected end of file") {
t.Errorf("unterminated here-document: %q", got)
}
}
// TestTokenize_UTF8 checks that non-ASCII characters survive quoting and
// escaping (each byte used to be converted to a rune of its own).
func TestTokenize_UTF8(t *testing.T) {
got, err := splitWords(`echo 'wörtlich' "für" über \ä 'a'"ö"`)
want := []string{"echo", "wörtlich", "für", "über", "ä", "aö"}
if err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("got %q, %v, want %q", got, err, want)
}
if got := runIn(t, "cat <<'EOF' > out\nGrüße\nEOF"); got != "Grüße\n" {
t.Errorf("here-document: %q", got)
}
}