Skip to content

Repository files navigation

whelk

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.

CI Rust 1.85+ MIT OR Apache-2.0 340 tests Linux, macOS, BSD


$ 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> exit

What this is

whelk 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. whelk runs 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.

Features

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

Install

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/whelk

Or run it straight from the workspace:

$ cargo run --release -p whelk

Usage

Builtins

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 bindings

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.

Files and environment

~/.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

Command line

$ 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 version

Commands 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.

Performance

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 MB

A 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.

Architecture

   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.

Experiments

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.

Documentation

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

Testing

$ cargo test --workspace       # 334 tests
$ cargo bench                  # criterion benchmarks
$ cargo clippy --workspace --all-targets -- -D warnings

Twenty-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.

Not implemented

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

Principles

  • Systems first — understand OS primitives instead of hiding them behind abstractions.
  • Safe by default — lean on the type system; isolate unsafe to 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.

Contributing

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.

License

Dual-licensed under MIT or Apache-2.0, at your option.

About

A Unix shell built from first principles in Rust — processes, file descriptors, pipelines, signals, job control, terminals

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages