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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user