add control structures: if, for, while, until
- new parser (parser.go) for lists, pipelines, ! and if/elif/else/fi, for ... in ... do/done, while/until ... do/done; simple commands are kept as text and expanded when they run (in every iteration) - break [n], continue [n] and the : builtin - incomplete input (open control structure, trailing &&, |, quote or backslash) continues on the next line: prompt "> " in interactive shells, scripts and startup files read on - Ctrl+C aborts the whole command line, including loops - aliases are expanded at the start of every simple command, also inside control structures - control structures in pipelines, with redirections or in the background are reported as not supported - completion: commands after if, then, do etc. - main_test.go reformatted with gofmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e49fe6fe11
commit
f79fb0b8d5
7 files changed
+1362
-237
No files matched your search
+172
-50
@@ -170,12 +170,12 @@ func TestExecInput_CdNoArgs(t *testing.T) {
|
||||
// Save current directory
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
|
||||
|
||||
err := execInput("cd")
|
||||
if err != nil {
|
||||
t.Errorf("cd with no args should go to home, got error: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Verify we changed to home directory
|
||||
home := getHomeDir()
|
||||
newDir, _ := os.Getwd()
|
||||
@@ -289,7 +289,7 @@ func TestShell_EOF_AfterCommand(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Logf("Shell exited with: %v", err)
|
||||
}
|
||||
|
||||
|
||||
output := stdout.String()
|
||||
// Should contain the echo output
|
||||
if !strings.Contains(output, "test") {
|
||||
@@ -313,7 +313,7 @@ func TestShell_SIGINT_HandlerSetup(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Logf("Shell exited with: %v, stderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "test") {
|
||||
t.Errorf("expected 'test' in output, got: %s", output)
|
||||
@@ -333,7 +333,7 @@ func TestExecInput_CdToHomeWithTilde(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Errorf("cd ~ failed: %v", err)
|
||||
}
|
||||
|
||||
|
||||
home := getHomeDir()
|
||||
newDir, _ := os.Getwd()
|
||||
if newDir != home {
|
||||
@@ -364,13 +364,13 @@ func TestExecInput_CdDash(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("cd to tmpDir failed: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Now cd - should go back
|
||||
err = execInput("cd -")
|
||||
if err != nil {
|
||||
t.Fatalf("cd - failed: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Verify we're back in origDir
|
||||
newDir, _ := os.Getwd()
|
||||
if newDir != origDir {
|
||||
@@ -383,7 +383,7 @@ func TestExecInput_CdDashNoPrevious(t *testing.T) {
|
||||
origPrevDir := previousDir
|
||||
defer func() { previousDir = origPrevDir }()
|
||||
previousDir = ""
|
||||
|
||||
|
||||
// cd - with no previous directory should return an error
|
||||
err := execInput("cd -")
|
||||
if err == nil {
|
||||
@@ -400,7 +400,7 @@ func TestExecInput_CdDashNoPrevious(t *testing.T) {
|
||||
|
||||
func TestGetPrompt_Format(t *testing.T) {
|
||||
prompt := getPrompt()
|
||||
|
||||
|
||||
// Should contain username@hostname:path $ pattern
|
||||
if !strings.Contains(prompt, "@") {
|
||||
t.Errorf("expected @ in prompt, got: %s", prompt)
|
||||
@@ -417,10 +417,10 @@ func TestGetPrompt_IncludesHome(t *testing.T) {
|
||||
// Save and restore current directory
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
|
||||
|
||||
// Change to home directory
|
||||
os.Chdir(getHomeDir())
|
||||
|
||||
|
||||
prompt := getPrompt()
|
||||
// When in home, should show ~
|
||||
if !strings.Contains(prompt, "~") {
|
||||
@@ -436,7 +436,7 @@ func TestExecInput_ExitWithStatusCode(t *testing.T) {
|
||||
// We can't easily test os.Exit from a unit test
|
||||
// So we just verify the function doesn't panic with a status code
|
||||
// The actual exit behavior is tested in integration tests
|
||||
|
||||
|
||||
// This test just documents the expected behavior
|
||||
// Actual testing requires subprocess
|
||||
_ = true // Placeholder - integration tests handle this
|
||||
@@ -457,19 +457,19 @@ func TestShell_ExitWithStatusCode(t *testing.T) {
|
||||
{"quit 0\n", 0},
|
||||
{"quit 5\n", 5},
|
||||
}
|
||||
|
||||
|
||||
// Build the binary once
|
||||
build := exec.Command("go", "build", "-o", "gosh_test", ".")
|
||||
if err := build.Run(); err != nil {
|
||||
t.Fatalf("failed to build: %v", err)
|
||||
}
|
||||
defer os.Remove("gosh_test")
|
||||
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
cmd := exec.Command("./gosh_test")
|
||||
cmd.Stdin = strings.NewReader(tc.input)
|
||||
|
||||
|
||||
err := cmd.Run()
|
||||
if err == nil && tc.expected != 0 {
|
||||
t.Errorf("expected non-zero exit code %d, got 0", tc.expected)
|
||||
@@ -598,13 +598,13 @@ func TestExecInput_SetEnvVar(t *testing.T) {
|
||||
// Save original value
|
||||
orig := os.Getenv("GOSH_TEST_VAR")
|
||||
defer os.Setenv("GOSH_TEST_VAR", orig)
|
||||
|
||||
|
||||
// Set a new variable
|
||||
err := execInput("set GOSH_TEST_VAR=test_value")
|
||||
if err != nil {
|
||||
t.Errorf("set failed: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Verify it was set
|
||||
val := os.Getenv("GOSH_TEST_VAR")
|
||||
if val != "test_value" {
|
||||
@@ -616,13 +616,13 @@ func TestExecInput_ExportEnvVar(t *testing.T) {
|
||||
// Save original value
|
||||
orig := os.Getenv("GOSH_TEST_VAR2")
|
||||
defer os.Setenv("GOSH_TEST_VAR2", orig)
|
||||
|
||||
|
||||
// Export a new variable
|
||||
err := execInput("export GOSH_TEST_VAR2=exported_value")
|
||||
if err != nil {
|
||||
t.Errorf("export failed: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Verify it was set
|
||||
val := os.Getenv("GOSH_TEST_VAR2")
|
||||
if val != "exported_value" {
|
||||
@@ -842,7 +842,7 @@ func TestShellCompleter_Do(t *testing.T) {
|
||||
want []string
|
||||
length int
|
||||
}{
|
||||
{"c", []string{"d "}, 1},
|
||||
{"c", []string{"d ", "ontinue "}, 1},
|
||||
{"e", []string{"cho ", "xit ", "xport "}, 1},
|
||||
{"gi", []string{"tty "}, 2},
|
||||
{" gi", []string{"tty "}, 2},
|
||||
@@ -1387,48 +1387,170 @@ func TestReadLine_DoesNotReadAhead(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitList(t *testing.T) {
|
||||
// treeString formats a syntax tree for the tests: simple commands in
|
||||
// brackets, background commands with a trailing &.
|
||||
func treeString(l *listNode) string {
|
||||
var parts []string
|
||||
for _, e := range l.items {
|
||||
s := pipelineString(e.andOr.first)
|
||||
for _, r := range e.andOr.rest {
|
||||
s += " " + r.op + " " + pipelineString(r.pipeline)
|
||||
}
|
||||
if e.background {
|
||||
s += " &"
|
||||
}
|
||||
parts = append(parts, s)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func pipelineString(pl *pipelineNode) string {
|
||||
s := ""
|
||||
if pl.negate {
|
||||
s = "! "
|
||||
}
|
||||
switch n := pl.cmd.(type) {
|
||||
case *simpleNode:
|
||||
s += "[" + n.text + "]"
|
||||
case *ifNode:
|
||||
s += "if"
|
||||
for i := range n.conds {
|
||||
if i > 0 {
|
||||
s += " elif"
|
||||
}
|
||||
s += " " + treeString(n.conds[i]) + " then " + treeString(n.bodies[i])
|
||||
}
|
||||
if n.elseBody != nil {
|
||||
s += " else " + treeString(n.elseBody)
|
||||
}
|
||||
s += " fi"
|
||||
case *forNode:
|
||||
s += "for " + n.name
|
||||
if n.hasIn {
|
||||
s += " in (" + n.words + ")"
|
||||
}
|
||||
s += " do " + treeString(n.body) + " done"
|
||||
case *whileNode:
|
||||
if n.until {
|
||||
s += "until "
|
||||
} else {
|
||||
s += "while "
|
||||
}
|
||||
s += treeString(n.cond) + " do " + treeString(n.body) + " done"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestParse_Lists(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want []listItem
|
||||
want string
|
||||
}{
|
||||
{"", nil},
|
||||
{" ", nil},
|
||||
{"# only a comment", nil},
|
||||
{"ls", []listItem{{"", "ls", false}}},
|
||||
{"a; b", []listItem{{"", "a", false}, {";", " b", false}}},
|
||||
{"a;", []listItem{{"", "a", false}}},
|
||||
{"a && b || c", []listItem{{"", "a ", false}, {"&&", " b ", false}, {"||", " c", false}}},
|
||||
{`echo "a;b" 'c&&d' e\;f`, []listItem{{"", `echo "a;b" 'c&&d' e\;f`, false}}},
|
||||
{`echo "x\"; y"`, []listItem{{"", `echo "x\"; y"`, false}}},
|
||||
{"echo ${X:-a;b}; c", []listItem{{"", "echo ${X:-a;b}", false}, {";", " c", false}}},
|
||||
{"echo a # b; c", []listItem{{"", "echo a ", false}}},
|
||||
{"echo a#b; c", []listItem{{"", "echo a#b", false}, {";", " c", false}}},
|
||||
{"echo a|b", []listItem{{"", "echo a|b", false}}},
|
||||
{"a & b", []listItem{{"", "a ", true}, {";", " b", false}}},
|
||||
{"a &", []listItem{{"", "a ", true}}},
|
||||
{"a&", []listItem{{"", "a", true}}},
|
||||
{"a 2>&1 &", []listItem{{"", "a 2>&1 ", true}}},
|
||||
{"a &> f &", []listItem{{"", "a &> f ", true}}},
|
||||
{"a >&2 && b", []listItem{{"", "a >&2 ", false}, {"&&", " b", false}}},
|
||||
{`echo "a & b" \&`, []listItem{{"", `echo "a & b" \&`, false}}},
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
{"# only a comment", ""},
|
||||
{"ls", "[ls]"},
|
||||
{"a; b", "[a]; [b]"},
|
||||
{"a;", "[a]"},
|
||||
{"a\nb\n", "[a]; [b]"},
|
||||
{"a && b || c", "[a] && [b] || [c]"},
|
||||
{"a &&\n b", "[a] && [b]"},
|
||||
{"a | b|c", "[a | b|c]"},
|
||||
{"a |\n b", "[a |\n b]"},
|
||||
{`echo "a;b" 'c&&d' e\;f`, `[echo "a;b" 'c&&d' e\;f]`},
|
||||
{`echo "x\"; y"`, `[echo "x\"; y"]`},
|
||||
{"echo ${X:-a;b}; c", "[echo ${X:-a;b}]; [c]"},
|
||||
{"echo a # b; c", "[echo a]"},
|
||||
{"echo a#b; c", "[echo a#b]; [c]"},
|
||||
{"a & b", "[a] &; [b]"},
|
||||
{"a&", "[a] &"},
|
||||
{"a 2>&1 &", "[a 2>&1] &"},
|
||||
{"a &> f &", "[a &> f] &"},
|
||||
{"a >&2 && b", "[a >&2] && [b]"},
|
||||
{`echo "a & b" \&`, `[echo "a & b" \&]`},
|
||||
{"echo $(a; b && c | d) \"$(e || f)\" & g", `[echo $(a; b && c | d) "$(e || f)"] &; [g]`},
|
||||
{"! false", "! [false]"},
|
||||
{"echo a \\\n b", "[echo a b]"},
|
||||
// Reserved words are only special as the first word of a command
|
||||
{"echo if then fi do done", "[echo if then fi do done]"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, err := splitList(tc.input)
|
||||
tree, err := parse(tc.input)
|
||||
if err != nil {
|
||||
t.Errorf("splitList(%q) returned error: %v", tc.input, err)
|
||||
t.Errorf("parse(%q) returned error: %v", tc.input, err)
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("splitList(%q) = %+v, want %+v", tc.input, got, tc.want)
|
||||
if got := treeString(tree); got != tc.want {
|
||||
t.Errorf("parse(%q) = %s, want %s", 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", "& 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 TestParse_ControlStructures(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"if a; then b; fi", "if [a] then [b] fi"},
|
||||
{"if a\nthen\n b\n c\nfi", "if [a] then [b]; [c] fi"},
|
||||
{"if a; then b; elif c; then d; else e; fi", "if [a] then [b] elif [c] then [d] else [e] fi"},
|
||||
{"if a && ! b; then c; fi; echo x", "if [a] && ! [b] then [c] fi; [echo x]"},
|
||||
{"for x in a b c; do echo $x; done", "for x in (a b c) do [echo $x] done"},
|
||||
{"for x in *.go {1..3}\ndo\n echo $x\ndone", "for x in (*.go {1..3}) do [echo $x] done"},
|
||||
{"for x; do echo; done", "for x do [echo] done"},
|
||||
{"for x in; do echo; done", "for x in () do [echo] done"},
|
||||
{"while a; do b; done", "while [a] do [b] done"},
|
||||
{"until a; do b; done", "until [a] do [b] done"},
|
||||
{"while a; do if b; then break; fi; done", "while [a] do if [b] then [break] fi done"},
|
||||
{"for i in 1 2; do for j in 3 4; do echo $i$j; done; done", "for i in (1 2) do for j in (3 4) do [echo $i$j] done done"},
|
||||
{"if a; then if b; then c; fi fi", "if [a] then if [b] then [c] fi fi"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tree, err := parse(tc.input)
|
||||
if err != nil {
|
||||
t.Errorf("parse(%q) returned error: %v", tc.input, err)
|
||||
continue
|
||||
}
|
||||
if got := treeString(tree); got != tc.want {
|
||||
t.Errorf("parse(%q) = %s, want %s", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_Incomplete(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"if a", "if a; then", "if a; then b", "if a; then b; else", "if a; then b; elif c",
|
||||
"for x", "for x in a b", "for x in a; do", "for x in a; do b",
|
||||
"while a", "while a; do b", "until a; do",
|
||||
"a &&", "a ||", "a |", "echo 'open", `echo "open`, "echo $(a", "echo a \\",
|
||||
} {
|
||||
if _, err := parse(input); !errors.Is(err, errIncomplete) {
|
||||
t.Errorf("parse(%q) = %v, want incomplete", input, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_SyntaxErrors(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"; a", "a ;; b", "&& a", "a && ; b", "a || && b", "& a", "a & && b", "a & & b",
|
||||
"fi", "then", "done", "if a; then fi", "if ; then a; fi", "while a; do done",
|
||||
"if a; then b; done", "for 1x in a; do b; done", "for x in a do b; done; x",
|
||||
"a | | b", "| a",
|
||||
} {
|
||||
_, err := parse(input)
|
||||
if err == nil || errors.Is(err, errIncomplete) {
|
||||
t.Errorf("parse(%q) = %v, want syntax error", input, err)
|
||||
}
|
||||
}
|
||||
for input, want := range map[string]string{
|
||||
"for x in a; do b; done | cat": "control structures in pipelines are not supported",
|
||||
"echo | while a; do b; done": "control structures in pipelines are not supported",
|
||||
"if a; then b; fi > out": "redirections of control structures are not supported (near `>')",
|
||||
"for x in a; do b; done &": "only simple commands and pipelines can run in the background",
|
||||
"a && b &": "only simple commands and pipelines can run in the background",
|
||||
} {
|
||||
if _, err := parse(input); err == nil || err.Error() != want {
|
||||
t.Errorf("parse(%q) = %v, want %q", input, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user