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:
Florian WaltherandClaude Opus 5.5 committed 2026-10-04 00:05:28 +02:00
1 parent e49fe6fe11
commit f79fb0b8d5
7 files changed
+1362 -237

No files matched your search

+131 -173
View File
@@ -742,7 +742,7 @@ type shellCompleter struct {
func newShellCompleter() *shellCompleter {
return &shellCompleter{
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "help"},
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "break", "continue", "help"},
}
}
@@ -756,6 +756,13 @@ func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
before := strings.TrimSpace(string(line[:start]))
isFirstWord := before == "" || strings.HasSuffix(before, "|") ||
strings.HasSuffix(before, ";") || strings.HasSuffix(before, "&&")
// ... and after if, then, do etc.
if fields := strings.Fields(before); len(fields) > 0 {
switch fields[len(fields)-1] {
case "if", "then", "elif", "else", "do", "while", "until", "!":
isFirstWord = true
}
}
// Plain "~" -> "~/"
if word == "~" {
@@ -1070,28 +1077,56 @@ func sourceFile(cmd, path string) error {
return nil
}
// runScript executes the commands from r line by line. Like sh, it does not
// stop at a failing command and returns the exit status of the last one.
// Empty lines and lines starting with # (e.g. a #!/usr/bin/gosh line) are
// skipped. If name is set, errors are reported with file name and line.
// runScript executes the commands from r. Each command is executed as soon
// as it is complete (a control structure can span several lines). Like sh,
// it does not stop at a failing command and returns the exit status of the
// last one. If name is set, errors are reported with file name and line.
func runScript(r io.Reader, name string) int {
status := 0
for lineNo := 1; ; lineNo++ {
line, err := readLine(r)
if trimmed := strings.TrimSpace(line); trimmed != "" && !strings.HasPrefix(trimmed, "#") {
cmdErr := execInput(line)
status = lastStatus
if name != "" && cmdErr != nil {
cmdErr = fmt.Errorf("%s:%d: %w", sanitize(name), lineNo, cmdErr)
}
printError(cmdErr)
var (
buf strings.Builder
startLine int
)
report := func(err error, line int) {
if name != "" && err != nil {
err = fmt.Errorf("%s:%d: %w", sanitize(name), line, err)
}
if err != nil {
if err != io.EOF {
fmt.Fprintln(os.Stderr, "gosh:", err)
printError(err)
}
for lineNo := 1; ; lineNo++ {
line, readErr := readLine(r)
if readErr == nil || line != "" {
if buf.Len() == 0 {
if strings.TrimSpace(line) == "" {
goto next
}
startLine = lineNo
}
buf.WriteString(line)
buf.WriteByte('\n')
tree, err := parse(buf.String())
if errors.Is(err, errIncomplete) {
goto next // read the rest of the command
}
buf.Reset()
if err != nil {
lastStatus = exitStatus(err)
report(err, startLine)
} else {
report(tree.exec(), startLine)
}
}
next:
if readErr != nil {
if readErr != io.EOF {
fmt.Fprintln(os.Stderr, "gosh:", readErr)
return 1
}
return status
if buf.Len() > 0 {
lastStatus = 2
report(errors.New("syntax error: unexpected end of file"), startLine)
}
return lastStatus
}
}
}
@@ -1129,10 +1164,16 @@ func exitStatus(err error) int {
if errors.As(err, &statusErr) {
return int(statusErr)
}
var synErr syntaxError
if errors.As(err, &synErr) {
var (
synErr syntaxError
lc loopControl
)
if errors.As(err, &synErr) || errors.Is(err, errIncomplete) {
return 2
}
if errors.As(err, &lc) {
return 0
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
@@ -1184,23 +1225,35 @@ func interactive() {
}
defer l.Close()
// Main loop
// Main loop. A command that is not complete yet (e.g. if without fi)
// is continued on the next line with the prompt "> ".
var buf strings.Builder
for {
// Report finished and stopped background jobs
reapJobs(true)
if buf.Len() == 0 {
// Report finished and stopped background jobs
reapJobs(true)
// Update prompt dynamically (in case directory changed)
l.SetPrompt(getPrompt())
} else {
l.SetPrompt("> ")
}
// Update prompt dynamically (in case directory changed)
l.SetPrompt(getPrompt())
line, err := l.Readline()
if err != nil {
// Ctrl+C discards the current line
// Ctrl+C discards the current line (and an incomplete command)
if err == readline.ErrInterrupt {
buf.Reset()
continue
}
// Handle EOF (Ctrl+D)
if err == io.EOF {
fmt.Println()
if buf.Len() > 0 {
buf.Reset()
lastStatus = 2
fmt.Fprintln(os.Stderr, "gosh: syntax error: unexpected end of file")
continue
}
if hasStoppedJobs() && !exitWarned {
exitWarned = true
fmt.Fprintln(os.Stderr, "gosh: there are stopped jobs")
@@ -1213,7 +1266,7 @@ func interactive() {
}
// Skip empty input
if strings.TrimSpace(line) == "" {
if buf.Len() == 0 && strings.TrimSpace(line) == "" {
continue
}
@@ -1223,8 +1276,19 @@ func interactive() {
l.SaveHistory(line)
}
// Execute the input
printError(execInput(line))
buf.WriteString(line)
buf.WriteByte('\n')
tree, err := parse(buf.String())
if errors.Is(err, errIncomplete) {
continue
}
buf.Reset()
if err != nil {
lastStatus = exitStatus(err)
printError(err)
continue
}
printError(tree.exec())
}
}
@@ -1256,144 +1320,24 @@ func printError(err error) {
var (
exitErr *exec.ExitError
statusErr statusError
lc loopControl
)
if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) {
if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) || errors.As(err, &lc) {
return
}
fmt.Fprintln(os.Stderr, "gosh:", err)
}
// listItem is a command of a command list together with the operator that
// connects it to the previous command ("" for the first one).
type listItem struct {
op string // "", ";", "&&" or "||"
command string
background bool // the command ends with &
}
// splitList splits input at unquoted ;, &, && and ||. Quotes, escapes, ${...}
// and comments are handled like in splitWords, but nothing is expanded yet:
// that happens per command when it runs, so "cd /tmp && echo $PWD" works.
func splitList(input string) ([]listItem, error) {
var (
items []listItem
op string
start int
quote byte
escaped bool
wordStart = true // the next character starts a word
)
end := len(input)
add := func(i int, next string) error {
command := input[start:i]
if strings.TrimSpace(command) == "" {
// "a;" is fine, but an operator needs a command on both sides
if next != "" || op == "&&" || op == "||" {
if next == "" {
next = op
}
return syntaxError(next)
}
return nil
}
items = append(items, listItem{op: op, command: command})
return nil
}
scan:
for i := 0; i < len(input); i++ {
ch := input[i]
isWordStart := wordStart
wordStart = false
switch {
case escaped:
escaped = false
case quote == '\'':
if ch == '\'' {
quote = 0
}
case ch == '\\':
escaped = true
case isSubstStart(input, i):
// Skip $(...) as a whole, its ; | & belong to the subshell
if j := substEnd(input, i); j >= 0 {
i = j
}
case ch == '$' && i+1 < len(input) && input[i+1] == '{':
// Skip ${...} as a whole, like expandVar does
if j := strings.IndexByte(input[i:], '}'); j >= 0 {
i += j
}
case quote == '"':
if ch == '"' {
quote = 0
}
case ch == '"' || ch == '\'':
quote = ch
case ch == ' ' || ch == '\t' || ch == '\n':
wordStart = true
case ch == '#' && isWordStart:
end = i
break scan
case ch == '&' && !(i+1 < len(input) && strings.IndexByte("&>", input[i+1]) >= 0) &&
!(i > 0 && strings.IndexByte("<>", input[i-1]) >= 0):
// A single & (not &&, &>, >& or <&) runs the command in the
// background
if err := add(i, "&"); err != nil {
return nil, err
}
items[len(items)-1].background = true
op = ";"
start = i + 1
wordStart = true
case ch == ';',
ch == '&' && i+1 < len(input) && input[i+1] == '&',
ch == '|' && i+1 < len(input) && input[i+1] == '|':
next := input[i : i+1]
if ch != ';' {
next = input[i : i+2]
}
if err := add(i, next); err != nil {
return nil, err
}
i += len(next) - 1
op = next
start = i + 1
wordStart = true
}
}
// An unterminated quote or a trailing \ is reported by splitWords
if err := add(end, ""); err != nil {
return nil, err
}
return items, nil
}
// execInput executes a line of input: a list of commands separated by ;, &&
// and ||. It returns the error of the last command that was run; errors of
// earlier commands are printed. $? is updated after each command.
// execInput parses and executes input: a command line or a whole script.
// It returns the error of the last command that was run; errors of earlier
// commands are printed. $? is updated after each command.
func execInput(input string) error {
if interactiveShell {
input = expandAliases(input)
}
items, err := splitList(input)
tree, err := parse(input)
if err != nil {
lastStatus = exitStatus(err)
return err
}
var lastErr error
for _, item := range items {
if (item.op == "&&" && lastStatus != 0) || (item.op == "||" && lastStatus == 0) {
continue
}
printError(lastErr)
lastErr = execCommand(item.command, item.background)
lastStatus = exitStatus(lastErr)
}
return lastErr
return tree.exec()
}
// execCommand executes a pipeline of simple commands with redirections, in
@@ -1701,21 +1645,24 @@ func applyRedirects(fds *[3]*os.File, redirects []redirect) ([]*os.File, error)
// the shell cannot be used in a pipeline, since there they would run
// concurrently (sh runs them in a subshell where they have no effect).
var builtins = map[string]struct{ changesState bool }{
"cd": {changesState: true},
"pwd": {},
"exit": {changesState: true},
"quit": {changesState: true},
"set": {changesState: true},
"export": {changesState: true},
"help": {},
"source": {changesState: true},
".": {changesState: true},
"jobs": {},
"fg": {changesState: true},
"bg": {changesState: true},
"wait": {changesState: true},
"alias": {},
"unalias": {changesState: true},
"cd": {changesState: true},
"pwd": {},
"exit": {changesState: true},
"quit": {changesState: true},
"set": {changesState: true},
"export": {changesState: true},
"help": {},
"source": {changesState: true},
".": {changesState: true},
"jobs": {},
"fg": {changesState: true},
"bg": {changesState: true},
"wait": {changesState: true},
"alias": {},
"unalias": {changesState: true},
":": {},
"break": {changesState: true},
"continue": {changesState: true},
}
// runBuiltin executes a built-in command, writing its output to stdout.
@@ -1795,6 +1742,11 @@ func runBuiltin(args []string, stdout io.Writer) error {
}
os.Setenv(parts[0], parts[1])
return nil
case ":":
// Does nothing, successfully (e.g. while :; do ...)
return nil
case "break", "continue":
return builtinLoopControl(args)
case "alias":
return builtinAlias(args, stdout)
case "unalias":
@@ -1831,6 +1783,9 @@ Built-in commands:
wait [%n] - Wait for job n or all background jobs
alias [name[=value] ...] - Define or show aliases (alias ll='ls -l')
unalias name - Remove an alias (unalias -a: all aliases)
: - Do nothing (exit status 0)
break [n] - Leave the innermost (or n-th) for/while/until loop
continue [n] - Continue with the next iteration of a loop
help - Show this help
Features:
@@ -1860,6 +1815,9 @@ Features:
stays one word
- Command lists: a ; b (run both), a && b (b only if a succeeded),
a || b (b only if a failed); $? is the exit status of the last command
- Control structures: if cmd; then ...; elif cmd; then ...; else ...; fi,
for x in words; do ...; done, while cmd; do ...; done, until ...,
! cmd negates the status; they can span several lines (prompt "> ")
- Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file
- Job control: command & runs in the background, Ctrl+Z stops the