Files
zzg 2fe43c74ac
ci / test (push) Canceled after 0s
initial commit
2026-09-25 22:02:49 -07:00

3.0 KiB

sh

A shell: a user interface for access to an operating system's services.

Supports pipelines, redirection, &&/||/; lists, background jobs, variables and expansion, globbing, quoting, and the builtins that have to run in the shell's own process.

Install

go install github.com/zg/sh/cmd/sh@latest

Use

sh                    # interactive
sh script.sh          # run a script
sh -c 'echo hello'    # run one command

Supported

feature example
pipelines ls | grep foo | wc -l
redirection cmd < in > out 2>> err, >> to append
lists a && b, a || b, a; b
background sleep 10 &, then jobs
variables FOO=bar, $FOO, ${FOO}, $?, $$
scoped assignment FOO=bar cmd sets it for cmd only
quoting 'literal', "$expanded", a\ b
globbing *.txt, ?, [abc]
tilde ~, ~/path
comments echo hi # ignored

Builtins: cd (including cd -), pwd, exit, export, unset, echo, env, jobs, type, read, true, false.

Design notes

Builtins run in-process by necessity, not as an optimisation. cd, export and exit change state that would be lost if they ran in a child — a cd in a subprocess changes nothing the shell can observe.

Working directory is per-shell, not per-process. Shell.Dir is carried explicitly and every relative path is resolved against it. That keeps a shell instance self-contained and testable: tests run a shell rooted at a temp directory without touching the process's own cwd.

Quoting decides expansion, and only the value of an assignment is quoted. FOO="a b c" is an assignment even though the word contains a quote — the quoting applies to the value, not the name. The first implementation required the whole word to be unquoted and so treated this as a command called FOO=a b c; the lexer now records whether an unquoted name prefix preceded the first quote.

Expansion order mirrors a real shell: tilde, then variables, then — for unquoted words only — field splitting and globbing. A quoted word yields exactly one field regardless of its contents, which is the entire point of quoting. An unquoted variable that expands to nothing contributes no field at all rather than an empty one.

Pipelines close their write ends. Each stage runs in a goroutine and closes its own pipe writer on exit; without that the next stage never sees EOF and the pipeline deadlocks. A pipeline's exit status is its last command's, as POSIX specifies.

An unterminated quote means "keep reading". The lexer reports it as a distinct error type rather than a generic syntax error, so the REPL can continue onto the next line instead of rejecting the input.

&& and || skip whole chains. false && a && b must not run b either, so short-circuiting advances past every item joined by the same operator rather than skipping only the next one.

Tests

go test -race ./...

Tests that need external programs (cat, tr) are skipped on Windows.