A Unix shell built from first principles in Rust.
Processes, file descriptors, pipelines, signals, job control, terminals, and an event loop — implemented, measured, and explained.
$ whelk
whelk> printf '3\n1\n2\n' | sort | head -2 > out.txt
whelk> cat < out.txt
1
2
whelk> sleep 30 &
[1] 4242
whelk> sleep 30
^Z
[2]+ Stopped sleep 30
whelk> jobs
[1]- Running sleep 30
[2]+ Stopped sleep 30
whelk> grepp pattern
whelk: grepp: command not found
did you mean `grep`?
whelk> exit
whelk: there are stopped jobs
whelk> exitwhelk is a systems-engineering project, not a Bash replacement. Its purpose
is to make the operating system's process model visible instead of hiding it:
every feature is built on primitives that were understood first, and the
reasoning is written down beside the code.
The goal is a shell small enough to understand and deep enough to expose how Unix actually works.
Scope.
whelkruns real command lines and is pleasant to use, but it is not a drop-in shell. It has no&&,||,;, globbing, command substitution, or multi-line input. See Not implemented — the list is deliberate and complete.
| Parsing | Lexer, AST, quoting, escaping, errors that point at the offending column |
| Expansion | $VAR, ${VAR}, $?, $$, ~, IFS field splitting |
| Redirection | >, >>, <, 2>, 2>&1, arbitrary descriptors, for builtins too |
| Pipelines | Concurrent processes, correct exit status, broken-pipe handling |
| Signals | SIGINT, SIGTERM, SIGHUP, SIGCHLD, SIGWINCH, graceful shutdown |
| Job control | jobs, fg, bg, Ctrl-Z, process groups, terminal ownership |
| Terminal | Mode save/restore, resize, raw mode — vim and top work inside it |
| Line editing | History with prefix search, Tab completion, ~/.whelkrc |
| Event loop | epoll on Linux, kqueue on BSD and macOS |
| Observability | Criterion benchmarks, WHELK_TRACE, allocation budgets |
Requires Rust 1.85 or newer. No system dependencies beyond a Unix kernel.
$ git clone https://github.com/ferris007/whelk
$ cd whelk
$ cargo build --release
$ ./target/release/whelkOr run it straight from the workspace:
$ cargo run --release -p whelk| Command | |
|---|---|
cd [dir|-|~] |
Change directory; - returns to the previous one |
exit [status] |
Leave, defaulting to the last command's status |
jobs |
List jobs with their state |
fg [%n] |
Resume a job in the foreground |
bg [%n] |
Resume a job in the background |
Job specifiers accept %1, %%, %+, %-, or a bare 1. With no argument,
the current job.
| Key | Key | ||
|---|---|---|---|
← → |
Move by character | Ctrl-A / Home |
Start of line |
↑ ↓ |
History, filtered by what you typed | Ctrl-E / End |
End of line |
Tab |
Complete command, path, or $VAR |
Ctrl-U |
Delete to start |
Ctrl-C |
Abandon the line | Ctrl-K |
Delete to end |
Ctrl-D |
End of input, or delete forward | Ctrl-W |
Delete previous word |
Ctrl-Z |
Suspend the running job | Ctrl-L |
Clear the screen |
Typing git and pressing ↑ finds the last git command rather than the
last command — the empty prefix matches everything, so the familiar case is
unchanged.
~/.whelkrc |
Run at startup, one command per line, interactive sessions only |
~/.whelk_history |
Persisted history, capped at 5,000 entries |
WHELK_TRACE=1 |
Print timed, structured diagnostics to stderr |
COLUMNS, LINES |
Set by the shell and kept current on resize |
$ whelk # interactive
$ whelk < script.sh # non-interactive; no prompt, no job control
$ whelk --benchmark # measure this machine
$ whelk --help # usage, and the list of what is missing
$ whelk --version # the versionCommands come from standard input; there is no run-this-file form. An option the shell does not have, or a filename where it expected none, is reported and exits 2 rather than being quietly ignored.
Measured with cargo bench. The ratios are the point:
parse a pipeline 1.3 µs
expand its words 0.3 µs
resolve a command 4.9 µs
─────────────────────────────
start one process 492.0 µs ← 75× everything above, combined
A shell spends its life waiting for fork and exec. Optimising the parser
would be polishing a rounding error — which is why the regression tests count
allocations rather than time: CI wall-clock varies several-fold, and a
threshold loose enough to pass reliably is too loose to catch anything.
$ whelk --benchmark
whelk benchmark
────────────────────────
startup 0.48 ms
echo 1.01 ms
pipeline 1.18 ms
memory 2.3 MBA release build; a debug one is roughly twice as slow, which is worth knowing before comparing these against another shell.
Full numbers, and the 61 ms cost the benchmarks found in a feature nobody had timed, are in docs/performance.md.
user input → whelk REPL, prompt, line editing
│
▼
whelk-parser text → AST; never touches the OS
│
▼
whelk-executor expansion, builtins, dispatch, shell state
│
▼
whelk-process fork / exec / wait, pipes, signals — all the `unsafe`
│
▼
kernel
alongside: whelk-job the job table and process groups
whelk-terminal modes, size, ownership
whelk-line editing, history, completion
whelk-event readiness over epoll and kqueue
whelk-trace timed diagnostics
The split is not decoration. whelk-parser turns a string into a tree and can be
tested exhaustively without spawning anything; whelk-process holds every
unsafe block that touches the process table, so the argument for the
fork/exec window fits in one file.
See docs/architecture.md.
Each directory in experiments/ answers one concrete
question with a runnable program and an observation pinned as a test.
| Experiment | Question |
|---|---|
fork_exec |
A child terminates with exit() instead of _exit(). What does it take with it? |
file_descriptors |
dup2 clears close-on-exec. What if source and target are the same? |
pipes |
Rust ignores SIGPIPE before main. What happens to the programs it execs? |
signals |
You press Ctrl-C. Who actually gets the signal? |
process_groups |
Two processes, one terminal, both blocked in read. What happens to the one that does not own it? |
pty |
A program prints progress. Pipe it and the progress stops. Where did it go? |
epoll |
A handler sets a flag and the loop checks the flag. What is missing? |
namespaces |
You call unshare(CLONE_NEWPID). What is your process id now? |
Seven of the eight came out of a bug found while building the phase they document. That was not the plan; it is what happened every time.
| Architecture | How the crates split, and why |
| Parsing | Text to tree, and where meaning gets decided |
| Process model | fork, exec, and the window between |
| Redirection | dup2, ordering, and close-on-exec |
| Pipelines | Concurrency, backpressure, and broken pipes |
| Signals | What a handler may do, and who receives Ctrl-C |
| Job control | Process groups and the foreground slot |
| Terminal | Modes, size, and state that outlives a process |
| Line editing | Raw mode, history, and completion |
| Events | epoll, kqueue, and waiting for several things |
| Performance | Measured numbers, and one they found |
| Roadmap | All eleven phases, and what is deliberately unfinished |
$ cargo test --workspace # 334 tests
$ cargo bench # criterion benchmarks
$ cargo clippy --workspace --all-targets -- -D warningsTwenty-nine of those tests drive the shell through a real pseudoterminal. Job control and terminal handling cannot be observed any other way: Ctrl-Z only exists because a terminal driver turns a keystroke into a signal for the foreground process group, and a shell reading from a pipe has none of that.
Deliberate, and complete. Syntax in the first group is parsed and refused by name — never silently treated as an argument.
&&, ||, ; |
Need a list grammar above the pipeline |
Here-documents (<<) |
Needs multi-line input, which the editor lacks too |
| Command substitution | $(...) and backticks |
${X:-default}, $1 |
Only plain ${name} is supported |
Subshells ( ) |
Also what builtins in a pipeline would need |
| Globbing | *.txt passes through unchanged, as a POSIX shell does when nothing matches |
set -e, -u, pipefail |
Each changes the meaning of code already written |
| Long-line redraw | The editor draws one row; doing better needs character widths |
- Systems first — understand OS primitives instead of hiding them behind abstractions.
- Safe by default — lean on the type system; isolate
unsafeto the places where the semantics are genuinely unsafe. - Observable — behaviour is exposed through tests and diagnostics, not asserted in prose.
- Incremental complexity — every feature builds on a previously understood primitive.
- Portable where practical — Unix-like systems, Linux first.
- Performance-aware — measure before optimizing, and be willing to conclude don't.
Bug reports, corrections, portability fixes, and new experiments are all welcome. One unusual rule, stated up front:
Please do not send a pull request that implements a future roadmap phase.
See CONTRIBUTING.md for why, and for what the project genuinely needs.
Dual-licensed under MIT or Apache-2.0, at your option.