add command lists (;, &&, ||) and $?

- split input at unquoted ;, && and || and run the commands like sh;
  variables are expanded per command when it runs
- $? and ${?} expand to the exit status of the last command,
  exit/quit without argument use it
- syntax errors for missing commands around operators (status 2)
- "command not found" message instead of the raw exec error, no more
  "exit status N" message when a command fails

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian WaltherandClaude Opus 5.5 committed 2026-10-03 20:32:39 +02:00
1 parent b602e17622
commit f5c14dff90
3 files changed
+307 -20

No files matched your search

+124 -1
View File
@@ -1,8 +1,8 @@
package main
import (
"errors"
"bytes"
"errors"
"io"
"os"
"os/exec"
@@ -1348,3 +1348,126 @@ func TestReadLine_DoesNotReadAhead(t *testing.T) {
t.Errorf("readLine() consumed more than one line, %d bytes left", r.Len())
}
}
func TestSplitList(t *testing.T) {
tests := []struct {
input string
want []listItem
}{
{"", nil},
{" ", nil},
{"# only a comment", nil},
{"ls", []listItem{{"", "ls"}}},
{"a; b", []listItem{{"", "a"}, {";", " b"}}},
{"a;", []listItem{{"", "a"}}},
{"a && b || c", []listItem{{"", "a "}, {"&&", " b "}, {"||", " c"}}},
{`echo "a;b" 'c&&d' e\;f`, []listItem{{"", `echo "a;b" 'c&&d' e\;f`}}},
{`echo "x\"; y"`, []listItem{{"", `echo "x\"; y"`}}},
{"echo ${X:-a;b}; c", []listItem{{"", "echo ${X:-a;b}"}, {";", " c"}}},
{"echo a # b; c", []listItem{{"", "echo a "}}},
{"echo a#b; c", []listItem{{"", "echo a#b"}, {";", " c"}}},
{"echo a|b", []listItem{{"", "echo a|b"}}},
}
for _, tc := range tests {
got, err := splitList(tc.input)
if err != nil {
t.Errorf("splitList(%q) returned error: %v", tc.input, err)
continue
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("splitList(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestSplitList_SyntaxErrors(t *testing.T) {
for _, input := range []string{"; a", "a ;; b", "&& a", "a &&", "a ||", "a && ; b", "a || && b"} {
if _, err := splitList(input); err == nil || !strings.Contains(err.Error(), "syntax error") {
t.Errorf("splitList(%q) = %v, want syntax error", input, err)
}
}
}
func TestExecInput_Lists(t *testing.T) {
dir := t.TempDir()
t.Setenv("D", dir)
tests := []struct {
input string
created []string
missing []string
status int
}{
{"touch $D/a; touch $D/b", []string{"a", "b"}, nil, 0},
{"false; touch $D/c", []string{"c"}, nil, 0},
{"true && touch $D/d", []string{"d"}, nil, 0},
{"false && touch $D/e", nil, []string{"e"}, 1},
{"false || touch $D/f", []string{"f"}, nil, 0},
{"true || touch $D/g", nil, []string{"g"}, 0},
{"false && touch $D/h || touch $D/i", []string{"i"}, []string{"h"}, 0},
{"true && false || touch $D/j", []string{"j"}, nil, 0},
{"gosh-no-such-command || touch $D/k", []string{"k"}, nil, 0},
{"true && sh -c 'exit 4'", nil, nil, 4},
}
for _, tc := range tests {
execInput(tc.input)
if lastStatus != tc.status {
t.Errorf("%q: $? = %d, want %d", tc.input, lastStatus, tc.status)
}
for _, name := range tc.created {
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
t.Errorf("%q: expected %s to be created", tc.input, name)
}
}
for _, name := range tc.missing {
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
t.Errorf("%q: %s must not be created", tc.input, name)
}
}
}
}
func TestExecInput_ExpandsPerCommand(t *testing.T) {
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
dir := t.TempDir()
out := filepath.Join(dir, "out")
t.Setenv("GOSH_TEST_OUT", out)
os.Unsetenv("GOSH_TEST_LIST")
defer os.Unsetenv("GOSH_TEST_LIST")
// The variable is set by the first command and used by the second one
err := execInput(`set GOSH_TEST_LIST=hello && sh -c 'printf %s "$GOSH_TEST_LIST" > "$GOSH_TEST_OUT"'`)
if err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(out); string(got) != "hello" {
t.Errorf("got %q, want %q", got, "hello")
}
}
func TestExpandVar_LastStatus(t *testing.T) {
execInput("sh -c 'exit 3'")
for _, input := range []string{"$?", "${?}"} {
words, err := splitWords("echo " + input)
if err != nil {
t.Fatal(err)
}
if words[1] != "3" {
t.Errorf("%s = %q, want %q", input, words[1], "3")
}
}
execInput("true")
if words, _ := splitWords("echo $?"); words[1] != "0" {
t.Errorf("$? = %q after true, want 0", words[1])
}
}
func TestExecInput_CommandNotFound(t *testing.T) {
err := execInput("gosh-no-such-command")
if err == nil || err.Error() != "gosh-no-such-command: command not found" {
t.Errorf("got %v, want command not found", err)
}
if lastStatus != 127 {
t.Errorf("$? = %d, want 127", lastStatus)
}
}