add functions, positional parameters, case and read

- functions: name() { ...; }, function name { ...; }, return, local,
  unset -f; in pipes, & and $(...) they run in a subshell that gets all
  function definitions and the positional parameters
- positional parameters $0, $1 ... ${10}, $#, "$@", "$*", $$; set by
  gosh script args, gosh -c command name args, function calls,
  source file args and set --; shift; for x without "in"
- case word in pattern|pattern) ...;; esac and { list; } groups
- read [-r] [-p prompt] [name ...] with $IFS splitting and $REPLY

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian WaltherandClaude Opus 5.5 committed 2026-10-04 17:20:59 +02:00
1 parent 9fe8b06fba
commit 443367f66f
9 files changed
+1629 -98

No files matched your search

+160 -42
View File
@@ -12,6 +12,7 @@ import (
"os/user"
"path/filepath"
"runtime/debug"
"slices"
"sort"
"strconv"
"strings"
@@ -152,18 +153,20 @@ func tokenize(input string) ([]token, error) {
plain = true // the word so far has no quotes, escapes or expansions
assign bool // the word is an assignment name=value
tildeOK bool // ~ may be expanded here (after = or : of an assignment)
noParams bool // the word contains "$@" without positional parameters
quote byte
escaped bool
rawStart = -1 // start of the current word in input
)
endWord := func() {
if inWord {
// "$@" without parameters is no word at all, unlike ""
if inWord && !(noParams && b.Len() == 0) {
tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob, assign: assign})
}
b.Reset()
pat.Reset()
glob, assign, plain = false, false, true
glob, assign, plain, noParams = false, false, true, false
inWord = false
rawStart = -1
}
@@ -227,6 +230,20 @@ func tokenize(input string) ([]token, error) {
}
}
i = end
case ch == '$' && !assign && positionalWords(input[i:], quote == '"') > 0:
// $@ and "$@": one word per positional parameter
startRaw(i)
if len(positional) == 0 && quote == '"' {
noParams = true
}
for k, param := range positional {
if k > 0 {
endWord()
}
literal(param)
inWord = true
}
i += positionalWords(input[i:], quote == '"') - 1
case ch == '$':
startRaw(i)
value, n, err := expandVar(input[i:])
@@ -506,13 +523,10 @@ func expandVar(s string) (string, int, error) {
return "", 0, errors.New("missing } in variable reference")
}
name, def, hasDef := strings.Cut(s[2:end], ":-")
if name == "?" {
return strconv.Itoa(lastStatus), end + 1, nil
}
if !isVarName(name) {
if !isVarName(name) && !isDigits(name) && !isSpecialParam(name) {
return "", 0, fmt.Errorf("bad substitution: %s", s[:end+1])
}
value, ok := os.LookupEnv(name)
value, ok := lookupParam(name)
if hasDef && value == "" {
return def, end + 1, nil
}
@@ -522,8 +536,13 @@ func expandVar(s string) (string, int, error) {
return value, end + 1, nil
}
if len(s) > 1 && s[1] == '?' {
return strconv.Itoa(lastStatus), 2, nil
// $?, $#, $$, $@, $*, $0 and $1 to $9 ($10 is $1 followed by 0)
if len(s) > 1 && (isSpecialParam(s[1:2]) || isDigits(s[1:2])) {
value, ok := lookupParam(s[1:2])
if !ok {
return "", 0, fmt.Errorf("%s: unbound variable", s[1:2])
}
return value, 2, nil
}
n := 1
@@ -540,6 +559,25 @@ func expandVar(s string) (string, int, error) {
return value, n, nil
}
// isSpecialParam reports whether name is one of the special parameters ?,
// #, $, @ and *.
func isSpecialParam(name string) bool {
return len(name) == 1 && strings.Contains("?#$@*", name)
}
// positionalWords returns the number of bytes of $@ or ${@} at the start of
// s, which expands to one word per positional parameter (also in double
// quotes). Unquoted, $* and ${*} do the same (gosh never splits values).
// It returns 0 for anything else.
func positionalWords(s string, quoted bool) int {
for _, p := range []string{"$@", "${@}", "$*", "${*}"} {
if strings.HasPrefix(s, p) && (strings.Contains(p, "@") || !quoted) {
return len(p)
}
}
return 0
}
func isVarName(name string) bool {
if name == "" {
return false
@@ -760,7 +798,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", "break", "continue", "unset", "help"},
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "break", "continue", "unset", "help", "return", "local", "shift", "read"},
}
}
@@ -777,7 +815,7 @@ func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
// ... 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", "!":
case "if", "then", "elif", "else", "do", "while", "until", "!", "{", ";;":
isFirstWord = true
}
}
@@ -791,7 +829,7 @@ func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
// Complete commands
var candidates [][]rune
seen := make(map[string]bool)
names := append(append(append([]string(nil), c.commands...), aliasNames()...), pathExecutables(word)...)
names := append(append(append(append([]string(nil), c.commands...), aliasNames()...), functionNames()...), pathExecutables(word)...)
for _, cmd := range names {
if strings.HasPrefix(cmd, word) && !seen[cmd] {
seen[cmd] = true
@@ -935,8 +973,8 @@ func completeFilePath(line, prefix string) []string {
return matches
}
const usage = `usage: gosh [-l] [-i] [--norc] [--noprofile] [script]
gosh [-l] [--noprofile] -c command
const usage = `usage: gosh [-l] [-i] [--norc] [--noprofile] [script [args ...]]
gosh [-l] [--noprofile] -c command [name [args ...]]
gosh -version`
// sysConfDir holds the system-wide startup files.
@@ -949,6 +987,7 @@ func main() {
if strings.HasPrefix(os.Args[0], "-") {
args = append([]string{"-l"}, args...)
}
scriptName = os.Args[0]
os.Exit(run(args))
}
@@ -986,21 +1025,26 @@ flags:
fmt.Println(usage)
return 0
case arg == "-c":
// Like sh, further arguments are allowed (they would become $0,
// $1, ...), but gosh has no positional parameters yet.
// gosh -c command [name [args ...]]: name is $0, args are $1, ...
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "gosh: -c: option requires an argument")
fmt.Fprintln(os.Stderr, usage)
return 2
}
if len(args) > 2 {
scriptName, positional = args[2], args[3:]
}
script = func() int { return runScript(strings.NewReader(args[1]), "") }
case strings.HasPrefix(arg, "-") && arg != "-":
fmt.Fprintf(os.Stderr, "gosh: %s: invalid option\n", arg)
fmt.Fprintln(os.Stderr, usage)
return 2
case arg == "-":
positional = args[1:]
script = func() int { return runScript(os.Stdin, "") }
default:
// gosh script [args ...]
scriptName, positional = arg, args[1:]
f, err := os.Open(arg)
if err != nil {
fmt.Fprintln(os.Stderr, "gosh:", err)
@@ -1064,6 +1108,8 @@ func sourceStartupFile(path string) {
return
}
defer f.Close()
sourceDepth++
defer func() { sourceDepth-- }()
runScript(bufio.NewReader(f), path)
}
@@ -1127,12 +1173,18 @@ func runScript(r io.Reader, name string) int {
goto next // read the rest of the command
}
buf.Reset()
if err != nil {
lastStatus = exitStatus(err)
report(err, startLine)
if err == nil {
err = tree.exec()
var ret returnSignal
if errors.As(err, &ret) {
// return in a sourced file
lastStatus = ret.status
return lastStatus
}
} else {
report(tree.exec(), startLine)
lastStatus = exitStatus(err)
}
report(err, startLine)
}
next:
if readErr != nil {
@@ -1192,6 +1244,10 @@ func exitStatus(err error) int {
if errors.As(err, &lc) {
return 0
}
var ret returnSignal
if errors.As(err, &ret) {
return ret.status
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
@@ -1339,11 +1395,12 @@ func printError(err error) {
exitErr *exec.ExitError
statusErr statusError
lc loopControl
ret returnSignal
)
if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) || errors.As(err, &lc) {
if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) || errors.As(err, &lc) || errors.As(err, &ret) {
return
}
fmt.Fprintln(os.Stderr, "gosh:", err)
fmt.Fprintln(stdio(2), "gosh:", err)
}
// execInput parses and executes input: a command line or a whole script.
@@ -1476,7 +1533,7 @@ func runPipeline(stages []stage, text string, background bool) error {
shellTTY, _ = readline.GetState(ttyFd)
}
stdin := os.Stdin
stdin := stdio(0)
if background && !jobControl {
// Without job control background jobs must not read from the
// terminal, like in sh
@@ -1486,12 +1543,12 @@ func runPipeline(stages []stage, text string, background bool) error {
}
var startErr error
for i, st := range stages {
stdout := os.Stdout
stdout := stdio(1)
var next *os.File
if i < len(stages)-1 {
r, w, err := os.Pipe()
if err != nil {
if stdin != os.Stdin {
if stdin != stdio(0) {
stdin.Close()
}
startErr = err
@@ -1531,10 +1588,10 @@ func runPipeline(stages []stage, text string, background bool) error {
// longer needs them, so that the neighbors see EOF or get SIGPIPE.
func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background bool) *proc {
var owned []*os.File
if stdin != os.Stdin {
if stdin != stdio(0) {
owned = append(owned, stdin)
}
if stdout != os.Stdout {
if stdout != stdio(1) {
owned = append(owned, stdout)
}
closeOwned := func() {
@@ -1547,7 +1604,7 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
// the command would read from /dev/null, so interactive programs (su,
// vim, ssh, ...) would get EOF right away. readline only reads from stdin
// while a line is being edited, so it does not steal input.
fds := [3]*os.File{stdin, stdout, os.Stderr}
fds := [3]*os.File{stdin, stdout, stdio(2)}
opened, err := applyRedirects(&fds, st.redirects)
owned = append(owned, opened...)
if err == nil && len(st.args) == 0 && len(st.assigns) > 0 {
@@ -1570,7 +1627,12 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
return finishedProc(err)
}
if builtin, ok := builtins[st.args[0]]; ok {
fn, isFunc := functions[st.args[0]]
if isFunc && !inPipeline && !background {
defer closeOwned()
return finishedProc(callFunction(fn, st.args, fds, st.assigns))
}
if builtin, ok := builtins[st.args[0]]; !isFunc && ok {
switch {
case inPipeline && builtin.changesState:
closeOwned()
@@ -1581,8 +1643,8 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
}
run := func() error {
defer closeOwned()
err := runBuiltin(st.args, fds[1])
if err != nil && fds[2] != os.Stderr {
err := runBuiltin(st.args, fds[0], fds[1])
if err != nil && fds[2] != stdio(2) {
// Report the error where stderr is redirected to
fmt.Fprintln(fds[2], "gosh:", err)
return statusError(1)
@@ -1599,7 +1661,17 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
return p
}
cmd := exec.Command(st.args[0], st.args[1:]...)
var cmd *exec.Cmd
if isFunc {
// A function in a pipeline or in the background runs in a subshell
cmd, err = subshellCommand(`"$@"`, st.args)
if err != nil {
closeOwned()
return finishedProc(err)
}
} else {
cmd = exec.Command(st.args[0], st.args[1:]...)
}
cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2]
if len(st.assigns) > 0 {
// name=value before a command is only in its environment (the
@@ -1734,10 +1806,15 @@ var builtins = map[string]struct{ changesState bool }{
"unset": {changesState: true},
"break": {changesState: true},
"continue": {changesState: true},
"return": {changesState: true},
"local": {changesState: true},
"shift": {changesState: true},
"read": {changesState: true},
}
// runBuiltin executes a built-in command, writing its output to stdout.
func runBuiltin(args []string, stdout io.Writer) error {
// runBuiltin executes a built-in command, reading from stdin and writing its
// output to stdout.
func runBuiltin(args []string, stdin *os.File, stdout io.Writer) error {
switch args[0] {
case "cd":
// Save current directory before changing (for 'cd -')
@@ -1790,6 +1867,11 @@ func runBuiltin(args []string, stdout io.Writer) error {
}
return exitShell(lastStatus)
case "set":
// set -- args: set the positional parameters
if len(args) > 1 && args[1] == "--" {
positional = slices.Clone(args[2:])
return nil
}
// set VAR=value - set environment variable
if len(args) < 2 {
return fmt.Errorf("usage: set VAR=value")
@@ -1817,12 +1899,21 @@ func runBuiltin(args []string, stdout io.Writer) error {
// Does nothing, successfully (e.g. while :; do ...)
return nil
case "unset":
// unset NAME ... - remove variables
if len(args) < 2 {
return fmt.Errorf("usage: unset NAME ...")
// unset [-v] NAME ... - remove variables, unset -f NAME ... - functions
names := args[1:]
funcs := len(names) > 0 && names[0] == "-f"
if len(names) > 0 && (names[0] == "-f" || names[0] == "-v") {
names = names[1:]
}
if len(names) == 0 {
return fmt.Errorf("usage: unset [-f|-v] NAME ...")
}
var err error
for _, name := range args[1:] {
for _, name := range names {
if funcs {
delete(functions, name)
continue
}
if !isVarName(name) {
err = fmt.Errorf("unset: `%s': not a valid identifier", sanitize(name))
continue
@@ -1832,6 +1923,14 @@ func runBuiltin(args []string, stdout io.Writer) error {
return err
case "break", "continue":
return builtinLoopControl(args)
case "return":
return builtinReturn(args)
case "local":
return builtinLocal(args)
case "shift":
return builtinShift(args)
case "read":
return builtinRead(args, stdin)
case "alias":
return builtinAlias(args, stdout)
case "unalias":
@@ -1845,9 +1944,15 @@ func runBuiltin(args []string, stdout io.Writer) error {
case "wait":
return builtinWait(args)
case "source", ".":
// source file - run the commands of file in the current shell
// source file [args] - run the commands of file in the current shell
if len(args) < 2 {
return fmt.Errorf("usage: %s file", args[0])
return fmt.Errorf("usage: %s file [args ...]", args[0])
}
if len(args) > 2 {
// The arguments are the positional parameters while it runs
saved := positional
positional = slices.Clone(args[2:])
defer func() { positional = saved }()
}
return sourceFile(args[0], args[1])
case "help":
@@ -1860,9 +1965,10 @@ Built-in commands:
exit [code] - Exit shell with status code (default: $?)
quit [code] - Quit shell with status code (default: $?)
set VAR=val - Set environment variable
set -- args - Set the positional parameters $1, $2, ...
export VAR=val - Export environment variable
unset VAR - Remove a variable
source file - Run the commands of file in this shell (also: . file)
unset VAR - Remove a variable (unset -f name: a function)
source file [args] - Run the commands of file in this shell (also: . file)
jobs - List background and stopped jobs
fg [%n] - Continue job n (default: current job) in the foreground
bg [%n] - Continue stopped job n in the background
@@ -1872,6 +1978,12 @@ Built-in commands:
: - 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
return [n] - Leave a function or sourced file with status n
local VAR[=val] - Variable that gets its old value back when the
function returns
shift [n] - Remove the first n positional parameters (default 1)
read [-r] [-p prompt] [VAR ...] - Read a line into variables (split
at $IFS; the last one gets the rest; default $REPLY)
help - Show this help
Features:
@@ -1899,6 +2011,9 @@ Features:
a pattern
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
using an unset variable is an error, use ${VAR:-} for an empty default
- Positional parameters: $0, $1 ... $9, ${10}, $# (count), "$@" (one
word each), "$*" (one word), $$ (process ID); set by gosh script
args, gosh -c command name args, functions, set -- and source
- Command substitution: $(command) is replaced by the output of command
(run in a subshell); unquoted it is split into words, "$(command)"
stays one word
@@ -1906,7 +2021,10 @@ Features:
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 ...,
case word in pattern|pattern) ...;; esac, { cmd; cmd; } groups;
! cmd negates the status; they can span several lines (prompt "> ")
- Functions: name() { ...; } (or function name { ...; }); the arguments
are $1, $2, ...; in pipes, & and $(...) they run in a subshell
- 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