Introduction
Building a custom REPL (Read-Eval-Print Loop) in Rust is a great way to deepen your understanding of system programming, async I/O, and command parsing. In this article, we'll walk through the process of creating a simple shell that can execute basic commands like echo
, ls
, and cd
.
A REPL (Read-Eval-Print Loop) is an interactive programming environment that continuously reads user input, evaluates it, prints the result, and loops back for more input. Our shell will follow this same structure:
- Read – Capture user input from standard input (stdin).
-
Evaluate – Convert input into a structured
Command
type and execute it. - Print – Display relevant output to the user via standard output (stdout).
- Loop – Wait for the next command and repeat the cycle.
To keep things simple, our shell will support the following commands:
-
echo
– Print text to the console. -
ls
– List files in the current directory. -
pwd
– Show the present working directory. -
cd
– Change directories. -
touch
– Create new files. -
rm
– Remove files. -
cat
– Read and display file contents.
Along with this guide, I've also created a YouTube video demonstrating the entire build process from start to finish, which you can check out here:
And the complete source code can be found here
Project setup
Before diving into implementation, let’s set up our Rust project and add the necessary dependencies. We'll use cargo
, Rust’s package manager, to create a new project.
cargo new shell
This will create a new Rust project inside a shell/
directory with the usual Cargo.toml
and src/main.rs
files.
Dependencies
For this project we only need 2 dependencies:
- Tokio – A powerful asynchronous runtime that allows us to handle user input and I/O operations efficiently.
-
Anyhow – A lightweight error-handling library that simplifies working with
Result
types.
Add them to your Cargo.toml
file:
[dependencies]
anyhow = "1.0.95"
tokio = { version = "1.43.0", features = ["full"] }
Error handling
Error handling is a critical part of any shell, as we need to gracefully handle issues like missing files, invalid commands, and permission errors. Instead of manually defining a complex error-handling system, we’ll use the anyhow
crate to simplify the process.
Why anyhow
?
Rust’s standard error handling requires defining detailed Result<T, E>
types for every function that can fail. While powerful, this can become cumbersome. anyhow
provides a streamlined approach by allowing us to return a generic error type (anyhow::Error
) that captures various failure cases without extra boilerplate.
Setting Up Error Handling
To integrate anyhow
, create a new file called src/errors.rs
and add:
pub type CrateResult<T> = anyhow::Result<T>;
This creates a reusable type alias, CrateResult<T>
, which we’ll use throughout our code for functions that may return errors.
Now, import this module in main.rs
so we can use it across our project:
mod errors;
With this in place, we can now handle errors more cleanly in our shell’s command execution and I/O operations.
I/O operations
Now that we’ve set up error handling, it’s time to implement I/O operations—the backbone of our REPL loop.
Setting Up the REPL Loop
We’ll run our REPL (Read-Eval-Print Loop) in a dedicated asynchronous task using tokio::spawn
. While this isn’t strictly necessary, it’s a great example of managing async tasks in Rust.
Add the following code to main.rs
:
use errors::CrateResult;
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt},
task::JoinHandle,
};
fn spawn_user_input_handler() -> JoinHandle<CrateResult<()>> {
tokio::spawn(async {
// Initialize stdin and stdout
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let mut reader = tokio::io::BufReader::new(stdin).lines();
let mut stdout = tokio::io::BufWriter::new(stdout);
stdout.write(b"Welcome to the shell!\n").await?;
while let Ok(Some(line)) = reader.next_line().await {
// Log user input for now (we'll process commands later)
println!("User entered: {}", line);
}
Ok(())
})
}
Breaking It Down:
-
tokio::spawn(async { ... })
→ Runs the REPL loop in an async task. -
BufReader::new(stdin).lines()
→ Reads user input line by line. -
BufWriter::new(stdout)
→ Buffers output for improved performance. -
Looping with
reader.next_line().await
→ Continuously reads user input. -
Logging input (
println!
) → Right now, we just print what the user types (we’ll process commands later).
Hooking It Up to main
Now, update main.rs
to start the REPL loop and handle potential errors:
#[tokio::main]
async fn main() {
let user_input_handler = spawn_user_input_handler().await;
if let Ok(Err(e)) = user_input_handler {
eprintln!("Error: {}", e);
}
}
This ensures that if anything goes wrong during execution, we’ll log the error and exit cleanly.
Command handling
Now that our shell can read user input, we need a way to interpret and execute commands.
Since our shell supports a fixed set of commands, we’ll use an enum to represent them. Enums in Rust provide a safe, type-checked way to handle different command variants without relying on fragile string comparisons.
Defining the Command Enum
Create a new file src/command.rs
and define our Command
enum:
use anyhow::anyhow;
#[derive(Clone, Debug)]
pub enum Command {
Exit,
Echo(String),
Ls,
Pwd,
Cd(String),
Touch(String),
Rm(String),
Cat(String),
}
Each variant represents a command, and those requiring an argument (like echo
, cd
, touch
, rm
, and cat
) store a String
inside them.
Parsing User Input into Commands
To convert user input into a Command
, we’ll implement TryFrom<&str>
for Command
:
impl TryFrom<&str> for Command {
type Error = anyhow::Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
let split_value: Vec<&str> = value.split_whitespace().collect();
match split_value[0] {
"exit" => Ok(Command::Exit),
"ls" => Ok(Command::Ls),
"echo" => {
if split_value.len() < 2 {
Err(anyhow!("echo command requires an argument"))
} else {
Ok(Command::Echo(split_value[1..].join(" ")))
}
}
"pwd" => Ok(Command::Pwd),
"cd" => {
if split_value.len() < 2 {
Err(anyhow!("cd command requires an argument"))
} else {
Ok(Command::Cd(split_value[1..].join(" ")))
}
}
"touch" => {
if split_value.len() < 2 {
Err(anyhow!("touch command requires an argument"))
} else {
Ok(Command::Touch(split_value[1..].join(" ")))
}
}
"rm" => {
if split_value.len() < 2 {
Err(anyhow!("rm command requires an argument"))
} else {
Ok(Command::Rm(split_value[1..].join(" ")))
}
}
"cat" => {
println!("{}", split_value[1..].join(" "));
if split_value.len() < 2 {
Err(anyhow!("cat command requires an argument"))
} else {
Ok(Command::Cat(split_value[1..].join(" ")))
}
}
_ => Err(anyhow!("Unknown command")),
}
}
}
Breaking It Down:
-
Splitting the input → We use
.split_whitespace()
to break the input into words. -
Matching commands → We check the first word (
split_value[0]
) to extract the user’s command. -
Handling arguments → Commands like
echo
,cd
, etc., require additional arguments, so we checksplit_value.len() < 2
. -
Error handling → We return
Err(anyhow!(...))
if the command is unknown or missing.
Integrating Command Parsing into main.rs
Now, import the Command
module at the top of main.rs
:
use command::Command;
mod command;
Let’s integrate this new type into src/main.rs
to provide uniform handling for user inputs. Create the below function:
async fn handle_new_line(line: &str) -> CrateResult<Command> {
// Leverages the TryFrom trait implemented above
let command: Command = line.try_into()?;
match command.clone() {
// Placeholder
_ => {}
}
Ok(command)
}
Now update the REPL loop in spawn_user_input_handler
to call this function on user’s inputs:
while let Ok(Some(line)) = reader.next_line().await {
let command = handle_new_line(&line).await;
if let Ok(command) = &command {
match command {
_ => {}
}
} else {
eprintln!("Error parsing command: {}", command.err().unwrap());
}
}
At this point, our shell can parse user input’s and match commands, but the command execution is incomplete. Next, we’ll improve logging, then implement the command logic for each, starting with filesystem operations like ls
, pwd
, cd
, and file manipulation.
Improve logging
Let’s improve the logging for our shell so that using it feels more familiar. To do this we should first create a helper method that returns the Shell’s current directory, we will need to log this after each command to emulate a normal Shell’s behaviour.
Create the file src/helpers.rs
and add this method:
use crate::errors::CrateResult;
pub fn pwd() -> CrateResult<String> {
let current_dir = std::env::current_dir()?;
Ok(current_dir.display().to_string())
}
And import this new file into our module:
mod helpers;
Now lets modify the logging inside our REPL loop to print “>” in the terminal before users enter their commands and also to log the users present working directory after each command. Update your spawn_user_input_handler
to match the following:
fn spawn_user_input_handler() -> JoinHandle<CrateResult<()>> {
tokio::spawn(async {
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let mut reader = tokio::io::BufReader::new(stdin).lines();
let mut stdout = tokio::io::BufWriter::new(stdout);
stdout.write(b"Welcome to the shell!\n").await?;
stdout.write(pwd()?.as_bytes()).await?;
stdout.write(b"\n>").await?;
stdout.flush().await?;
while let Ok(Some(line)) = reader.next_line().await {
let command = handle_new_line(&line).await;
if let Ok(command) = &command {
match command {
_ => {}
}
} else {
eprintln!("Error parsing command: {}", command.err().unwrap());
}
stdout.write(pwd()?.as_bytes()).await?;
stdout.write(b"\n>").await?;
stdout.flush().await?;
}
Ok(())
})
}
Commands
Now we have built a system to interpret and execute commands, now we need to implement the logic for each.
Exit
Let’s first add the exit command so user’s can close the terminal, to do this modify the while loop in src/main.rs
to break when this command is detected:
if let Ok(command) = &command {
match command {
Command::Exit => {
println!("Exiting...");
break;
}
_ => {}
}
}
Echo
The echo
command simply prints out the arguments given to it, which is useful for displaying messages or testing output. Handle the Echo
command in handle_new_line
:
Command::Echo(s) => {
println!("{}", s);
}
Ls
The ls
command lists files in the current directory.
- Create a helper function in
src/helpers.rs
:
pub fn ls() -> CrateResult<()> {
let entries = fs::read_dir(".")?;
for entry in entries {
let entry = entry?;
println!("{}", entry.file_name().to_string_lossy());
}
Ok(())
}
- Handle
Ls
inhandle_new_line
:
Command::Ls => {
helpers::ls()?;
}
Pwd
The pwd
command prints the current working directory, we’ve already defined this function so just add the relevant match case in handle_new_line
:
Command::Pwd => {
println!("{}", helpers::pwd()?);
}
Cd
The cd
command changes the current directory.
- Create a helper function in
src/helpers.rs
:
pub fn cd(path: &str) -> CrateResult<()> {
std::env::set_current_dir(path)?;
Ok(())
}
- Handle
Cd
inhandle_new_line
:
Command::Cd(s) => {
helpers::cd(&s)?;
}
Touch
The touch
command creates an empty file.
- Create a helper function in
src/helpers.rs
:
pub fn touch(path: &str) -> CrateResult<()> {
fs::File::create(path)?;
Ok(())
}
- Handle
Touch
inhandle_new_line
:
Command::Touch(s) => {
helpers::touch(&s)?;
}
Rm
The rm
command removes a file.
- Create a helper function in
src/helpers.rs
pub fn rm(path: &str) -> CrateResult<()> {
fs::remove_file(path)?;
Ok(())
}
- Handle
Rm
inhandle_new_line
:
Command::Rm(s) => {
helpers::rm(&s)?;
}
Cat
The cat
command reads and prints the contents of a file.
- Create a helper function in
src/helpers.rs
:
pub fn cat(path: &str) -> CrateResult<String> {
let pwd = pwd()?;
let joined_path = std::path::Path::new(&pwd).join(path);
let contents = fs::read_to_string(joined_path)?;
Ok(contents)
}
- Handle
Cat
inhandle_new_line
:
Command::Cat(s) => {
let contents = helpers::cat(&s)?;
println!("{}", contents);
}
Conclusion
We’ve successfully built a simple REPL/Shell in Rust that supports basic commands like ls
, pwd
, cd
, touch
, rm
, and cat
. The architecture is modular, making it easy to extend with additional commands.
This is just the beginning! You can add more features, such as:
- Command history
- Autocompletion
- Piping commands
- Background execution
If you want to dive deeper, check out the accompanying YouTube video that walks through the build process step by step
ASMR Programming - REPL Shell in Rust 🦀 (Outside - NO TALKING) - YouTube
Happy coding! 🚀
Top comments (0)