Skip to content

Cheshulko/Shellust-rs

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

A POSIX-style shell in Rust

A POSIX-style shell in Rust

A small interactive shell written from scratch in Rust. It runs as a raw-terminal REPL: it reads the keyboard one keystroke at a time, supports line editing, history navigation, and TAB autocompletion, then parses and executes builtins, external programs, pipelines, redirections, and background jobs.

$ declare name=world
$ echo "hello, $name" | tr a-z A-Z
HELLO, WORLD
$ ls /nope 2> errors.txt
$ sleep 5 &
[1] 48213
$ jobs
[1]+  Running   sleep 5 &
$ ec<TAB>          # → completes to "echo "
  • Language: Rust 2021, MSRV 1.82
  • Dependencies: termion (raw mode), regex (variable expansion), anyhow (error handling)
  • Tests: 99 unit tests (cargo test)

Features

Interactive REPL (raw mode)

  • Character-by-character input with a live prompt.
  • History navigation with / .
  • Backspace line editing.
  • TAB completion — two-stage, bash-style:
    • First TAB completes the longest common prefix (or rings the bell if ambiguous); appends a trailing space for commands/files and / for directories.
    • Second TAB lists all candidates.
    • Completes command names (builtins + everything on $PATH) when at the start of a line, and arguments (filesystem paths, or a programmable completer) otherwise.
  • Ctrl-D exits cleanly.

Parsing & expansion

  • Variable expansion: $VAR and ${VAR}, expanded from shell variables set via declare (process environment variables are not read for expansion). Quote-aware — single quotes suppress expansion, so after declare x=hi the line echo '$x' prints the literal $x while echo "$x" expands to hi. An undefined variable expands to the empty string.
  • Quoting: single quotes (fully literal), double quotes (with \$, \`, \\, \!, \" escape rules), and backslash escapes outside quotes.

Execution

  • Builtins: echo, exit, type, pwd, cd, history, jobs, complete, declare (see Builtins below).
  • External programs resolved against $PATH.
  • Pipelines: a | b | c, mixing builtins and external programs.
  • Redirections: >/1>, >>/1>> (stdout, truncate/append), 2>, 2>> (stderr, truncate/append). Missing parent directories are created automatically.
  • Background jobs: trailing & runs a command in the background; jobs lists them and reports completion.

Persistent history

  • Loaded from / saved to the file named by $HISTFILE.
  • history -r <file> reads, -w <file> writes the full list, -a <file> appends only the entries added since the last append.

Build & run

The shell needs a real TTY for raw mode, so run it in a terminal.

cargo run            # debug build (extra diagnostics)
cargo run --release  # release build

Tests

cargo test

All tests are inline #[cfg(test)] unit tests covering the pure logic: the lexer, variable expansion, the command grammar, history index math and file I/O, job bookkeeping, and autocomplete slicing. There is no TTY or child-process dependency in the test suite (background-job tests spawn true).


Builtins

Builtin Usage Notes
echo echo [args...] Joins args with spaces + newline.
exit exit [code] Exits with code (default 0).
type type <name> Reports whether name is a builtin or a $PATH executable.
pwd pwd Prints the current working directory.
cd cd <dir> Changes directory; bare ~ maps to $HOME.
history history [n] · -r/-w/-a <file> Lists history (last n), or reads/writes/appends a file.
jobs jobs Lists background jobs with +/- markers and status.
declare declare KEY=VALUE · -p KEY Sets / prints a shell variable (used for $ expansion).
complete complete -C <prog> <cmd> · -p <cmd> · -r <cmd> Registers / inspects / removes a programmable completer.

Programmable completion (complete -C)

complete -C <program> <command> registers an external program as the argument completer for <command>. On TAB, the shell invokes that program with the bash-style COMP_LINE and COMP_POINT environment variables set, and reads whitespace-separated candidates from its stdout. See completer_script.py for a sample.


Architecture

The flow for each submitted line is:

read keys → expand variables → tokenize → split into Commands → run → flush pipeline

Module layout

src/
├── main.rs              Entry point: build a Shell, run it, exit with its code.
├── console/
│   ├── shell.rs         Shell: the raw-mode key loop and per-line orchestration.
│   ├── pipeline.rs      Pipeline: stdout/stderr routing, child chaining, jobs.
│   ├── memory.rs        Memory: shell variables + quote-aware $var expansion.
│   ├── job_ui.rs        Display formatting for `jobs` output.
│   └── mod.rs           Piped trait + InputWord (buffer-slicing for completion).
├── command/
│   ├── mod.rs           Command enum: Builtin | Executable.
│   ├── builtin.rs       Builtin structs + IDENT/IDENTS name registry.
│   ├── parser.rs        Command::parse — the grammar (tokens → one Command).
│   └── runner.rs        Command::run — executes every variant.
├── parser/mod.rs        parse_args — the lexer (raw line → Vec<String> tokens).
├── autocomplete/        TAB completion: entries, sources, and Completer scripts.
├── history.rs           History: ring of commands + $HISTFILE persistence.
└── utils.rs             $PATH lookup, directory enumeration, env helpers.

Two distinct parsers

The shell deliberately separates lexing from grammar:

  1. parser::parse_args (the lexer) splits a raw line into words, handling quotes and backslash escapes. Output is Vec<String> tokens.
  2. Command::parse (the grammar) consumes a VecDeque<String> of tokens and produces one Command, stopping at operator tokens (>, >>, 2>, 2>>, |). Called in a loop, one line yields a sequence of Commands joined by those operators.

Output goes through the Pipeline

Builtins never print to stdout directly. All output flows through the Piped trait, implemented by console::Pipeline, which routes text, spawns external children for |, applies redirections, captures stderr on worker threads, and tracks background jobs. Pipeline::finish flushes and waits at the end of each line.

Adding a builtin

  1. Add a struct + Builtin enum variant in command/builtin.rs (register its name in the register_builtins! table).
  2. Add a parse arm in command/parser.rs.
  3. Add an exec arm in command/runner.rs.

Limitations / non-goals

This is a learning project, not a daily-driver shell. Known gaps:

  • No &&, ||, or ; command sequencing, and no glob (*/?) expansion.
  • No exportdeclare variables are shell-local and not passed to children as environment variables.
  • Line editing is append/backspace only (no Left/Right cursor movement, Home/End, or word-delete).
  • No Ctrl-C / SIGINT handling.
  • & must be a separate trailing token, and only external commands can be backgrounded.

Acknowledgements

Bootstrapped from the CodeCrafters "Build Your Own Shell" challenge.

About

A small interactive shell written from scratch in Rust.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors