Plan 9 shell (rc) in Go
enzv/rc uses an rc script to compile itself.
% go run . make.rc build built ./rc
rc is the part of Plan 9 I missed most when work pulled me back to Linux. Not the nostalgia. The grammar. It treats shell input like a language, not like string soup.
So I rebuilt it in Go as a standalone binary. No plan9port wrapper. No secondary shell. No CGO. One static binary you can ship, debug, and move around without dragging the rest of Plan 9 behind it.
The pipeline is the point.
ParseSource(src) prepareSource(src) Lex(prepared.Stripped) parseTokens(tokens) attachHereDocs(prog, prepared.HereDocs) RunProgram(prog, opts)
Source is evidence. The tree is the contract. Execution is what happens after the parser turns noise into structure.
First comes bookkeeping, not parsing. The source walker tracks quotes, comments, and heredoc markers before the lexer ever sees the input. That keeps the lexer from guessing where the body ends and the syntax begins.
for i := 0; i < len(src); {
ch := src[i]
if inComment {
...
}
if inQuote {
...
}
switch ch {
case '<':
if i+1 < len(src) && src[i+1] == '<' {
pending = append(pending, scanHereTag(src, i+2))
...
}
case '\n':
...
}
}
That pass exists for one reason. Heredocs need to land on the right node later. If you capture them too late, function bodies start lying about what they contain. Shells already have enough ways to lie.
The lexer is stateful because shell syntax is stateful. It remembers whether the previous token was a word. It remembers whether $ should trigger identifier mode. It remembers quotes, comments, and continuation because context changes the meaning of the next byte.
func (lx *Lexer) nextToken() (LexToken, error) {
if lx.lastWord {
...
}
lx.skipWhite()
pos := lx.position()
c := lx.advance()
switch c {
case '$':
...
case '`':
...
}
...
}
Tokens keep the facts that matter. Quoted, Glob, RType, FD0, FD1, and Pos. Later stages do not need to rediscover intent from raw bytes. They can read what the lexer already proved.
The tree stays small on purpose. A Program carries the original tokens, node array, offset table, here-doc map, and root id. That is enough to parse, print, expand, and execute without inventing a second representation for every phase.
Program{
Tokens: tokens,
Nodes: nodes,
Offsets: offsets,
HereDocs: hereDocs,
Root: root,
}
One tree. Many readers. No translation layer trying to be clever behind your back.
The evaluator walks the tree directly. No bytecode. No VM frame. No generic command executor pretending every shell construct is the same thing.
func (r *runner) exec(id int) error {
node := r.prog.Node(id)
if node == nil {
...
}
switch node.Type {
case ';':
...
case '&':
...
case tokenSimple:
...
case tokenFn:
...
}
}
That switch is the whole job. rc does not flatten control flow into one universal path. Conditionals, loops, functions, word lists, subshells, redirections, and pipelines all keep their own rules. They should. The shell is not one thing with different hats.
^ is list-aware concatenation, not casual string glue. If one side is a scalar and the other is a list, the scalar expands across the list. If both sides have the same length, they join pairwise. If they do not line up, the command fails. No made-up shape. No polite lie.
That is the whole trick. Keep the shape when the shape makes sense. Fail when it does not.
func concatWords(left, right []wordValue) ([]wordValue, error) {
if len(left) == 0 || len(right) == 0 {
return nil, fmt.Errorf("null list in concatenation")
}
switch {
case len(left) == len(right):
...
case len(left) == 1:
...
case len(right) == 1:
...
default:
return nil, fmt.Errorf("mismatched list lengths in concatenation")
}
}
That is the kind of algorithm rc wants. Shape-preserving. Strict when it has to be. Small enough that a human can still reason about it without pretending they love writing parsers.
The environment is not one big string map. It keeps raw variables, encoded word values, function bodies, a job table, a current directory, branch state, and runtime flags. That split is what keeps PATH, IFS, $*, and fn#name=... from collapsing into sludge.
func (e *shellEnv) exportEnv() []string {
var env []string
if path, ok := e.words["path"]; ok {
...
}
for name, values := range e.words {
...
}
for name, body := range e.fns {
...
}
return env
}
vars stores the plain shell view. words stores the glob-aware shell view. That means a variable can round-trip through expansion and export without losing the shape it had when the user set it.
IFS matters because tiny shell rules break big command lines. An empty IFS means no splitting. Add characters to it and each character becomes a delimiter. That behavior is explicit, local to the environment, and not buried in a helper with mysterious defaults.
func splitByIFSWords(output string, env *shellEnv) []wordValue {
ifs := " \t\n"
if v, ok := env.vars["ifs"]; ok {
...
}
if ifs == "" {
...
}
for _, ch := range output {
...
}
return words
}
Command substitution uses the same rule. Run the subtree, capture stdout, then split the result through the current IFS behavior. No special parser for captured text. No one-off exception pretending it is not part of the language.
Globbing follows the same logic. Literal markers, wildcard markers, bracket classes, and dotfile rules stay separate from command execution. Completion, expansion, and printing can all share the same semantics without inventing their own little lies.
func matchSegment(subject, pattern string, stop rune, relaxed bool) bool {
for len(pattern) != 0 {
r, size := utf8.DecodeRuneInString(pattern)
if r == stop {
...
}
switch r {
case globMark:
...
case '*':
...
case '?':
...
case '[':
...
}
}
return subject == ""
}
The useful property is that the matcher only recurses where the syntax demands it. That keeps the implementation small and the failure modes visible. No hidden behavior. No ambient search path outside the inputs.
Here-docs stay attached to the node that owns them. Quote the delimiter and the body stays literal. Leave it unquoted and it expands. The parser already decided. The evaluator just carries the decision forward.
func (r *runner) applyRedir(id int) (io.Closer, bool, error) {
node := r.prog.Node(id)
switch node.Type {
case tokenDup:
...
case tokenRedir:
...
}
return nil, false, fmt.Errorf("unsupported redirection node %s", tokenName(node.Type))
}
Redirection order is source-order sensitive. The code collects the chain first, sorts by source offset, and applies each step in sequence. >[...] , <[...] , <<, and file targets do not commute. Get that wrong and the script says something else entirely.
Process substitution is plumbing, not magic. os.Pipe creates the transport. One end is bound to the child. The other is returned as a /dev/fd/N path so the rest of the shell can treat it like ordinary redirection.
func (r *runner) execProcSub(id int) ([]string, error) {
pr, pw, err := os.Pipe()
if err != nil {
...
}
...
go func() {
sub.exec(node.Child[0])
...
}()
return []string{procSubFDPath(osFd)}, nil
}
& follows the same pattern, but the contract is different. The child gets a cloned environment and its own job bookkeeping. The parent gets a virtual pid in $apid and keeps going. That keeps background execution cheap without pretending fork semantics are free in Go.
func (r *runner) execAsync(id int) error {
sub := r.child(r.env.clone())
...
r.env.jobs.jobs[pid] = done
...
go func() {
sub.exec(node.Child[0])
...
}()
return r.exec(node.Child[1])
}
I wanted that tradeoff to be visible. This is not Plan 9 process semantics with the edges sanded off. It is a Go-native mapping that preserves the shell behavior users can see while keeping the runtime sane.
The interactive loop had to be native or it was not worth shipping. Raw TTY mode, bracketed paste, history, cursor movement, line clearing, and Ctrl-V are one feature. Usable input without an external wrapper.
oldState, err := term.MakeRaw(fd) defer term.Restore(fd, oldState) enableBracketedPaste() defer disableBracketedPaste()
The refresh path tracks rendered rows and cursor position. It clears only what it needs. It redraws from the current buffer instead of trying to patch terminal state with guesses. That matters once a command wraps or pasted text spans multiple lines.
The prompt is not a dumb string either. If prompt is a function, the shell runs it. Then it restores status so prompt logic does not leak into command semantics. Small detail. Big difference.
Signals stay literal too. SIGINT, SIGQUIT, SIGHUP, and SIGALRM dispatch to matching rc functions if they exist. Otherwise the shell falls back to its current exit code. Interrupts should be visible, not buried in runtime tricks.
rfork is where the port has to admit reality. The parser takes the combined flag string. The validator rejects combinations the host cannot represent. The executor maps e, E, F, s, n, f, and m onto what Go and the OS can actually do. Fake success is worse than a clean failure.
flags, err := parseRforkArgs(args)
if err != nil {
...
}
if err := r.applyRfork(flags); err != nil {
...
}
That is the line I wanted to keep visible. The shell should fail where the abstraction boundary is real. It should not blur the difference between supported and unsupported behavior just because the original syntax looked nicer.
The tests close the loop. We compare our rc against plan9port byte for byte, including stdout, stderr, exit status, and the rest of the observable behavior. The corpus under testdata/*.rc keeps that comparison grounded. That is enough to keep the language honest without turning the post into a checklist.
I did not rebuild rc to decorate Unix. I rebuilt it to keep the language honest while letting Go carry the process model. Explicit ASTs. Explicit expansion. Explicit status. Explicit failure. That is the point.