Building Shell in Rust (Part 1)
Learning Rust by building a shell
This is a part of a series where I attempt to build a shell in Rust by following CodeCrafters course, and this is part 1.
If you’re following the course in rust I would suggest to try it first by yourself.
A simple program that reads the input from the terminal and writes it back.
use std::io::{self, Write};
fn read_input() -> io::Result<String> {
print!("$ ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().to_owned())
}
fn main() {
match read_input() {
Ok(v) => println!("read: {v}"),
Err(e) => println!("failed to read: {e}"),
}
}
Standard Input and Output
-
io::stdout().flush()- Standard output is line-buffered in Rust, so nothing will be printed on the terminal until it gets a newline\nor the buffer gets manually cleared - which is whatflush()does. -
input- Is a mutable variable, which holds the String type. In Rust strings are sequence of Unicode characters (variable-width encoding) and it is not array that holds characters.-
String - Resizable buffer holding UTF-8 text, it is allocated on the heap.
-
&str - (known as “stir” or “string slice”) it is called as fat pointer, containing both the address of data and its length.
-
String literal (
&'static str) - Pre-allocated text that embedded into the compiled binary, typically stored in read-only memory, since it exists for the entire runtime.let literal: &'static str = "hello"; let owned: String = String::from("hello"); let borrowed: &str = &owned;
-
-
.len()of String or &str is measured in bytes, not characters. -
read_line(&mut guess)- takes in a mutable borrowed reference andread_linewhich then append read string (without overwriting). And that’s why it’s required to be mutable.// read_line returns a number of bytes read match io::stdin().read_line(&mut input) { Ok(n) => println!("{n} bytes read: {input}"), Err(e) => panic!("{e}"), }
Result and Error Handling
Result Result<T, E> is an enum with the variants, Ok(T) and Err(E). This is used to return
and propagate errors.
-
.expect("failed to read")- Result enum has an expect method which will crash the program if returned value is an error. -
unwrap- A method available for Result enum, that either return theOkor panic when it isErr. -
Pattern matching - Instead of panicking and crashing the program, this is a nice way to handle it.
enum Output { Output1, Output2 } fn random_result() -> Result<Output, &'static str> {} // panic and exit the program if result is Err let output = random_result.expect("failed to get random result"); // panic and exit the program if result is Err let output = random_result.unwrap(); // pattern matching and handling result enum match random_result() { Ok(v) => println!("output type: {v:?}"), Err(e) => println!("error: {e:?}"), } -
Operator
?- Can be used to return early when there is an error without panicking.// in rust, main can return result enum - it has special support fn main() -> Result<()> { random_result()?; // early return if it is an error Ok(()) } -
We can use if-else block to handle Result type as well, also can use
letalong with it to make it cleaner.if Ok(random_result()) { //do this }; if let Ok(output) = random_result() { //do something with output } let Ok(output) = random_result() else { //do something when it is error }
REPL
A cycle that looks like Read -> Eval -> Print -> Loop. It starts with displaying the prompt ("$ ") and
then wait for the input, then evaluate and print the response. And it continues. Basically it can be written as
a program that runs in a loop with a proper error handling.
fn main() {
loop {
match read_input() {
Ok(v) => handle_command(&v),
Err(e) => eprintln!("failed to read: {e}"),
}
}
}
Handle Command
fn handle_command(input: &str) {
let commands: [&str; 3] = ["exit", "echo", "type"];
let mut parts = input.split_whitespace();
let command: &str = parts.next().unwrap_or("");
match command {
"exit" => exit(0),
"echo" => {
let rest: Vec<&str> = parts.collect();
println!("{}", rest.join(" "));
}
"type" => {
let type_name: &str = parts.next().unwrap_or("");
if commands.contains(&type_name) {
println!("{type_name} is a shell builtin");
} else if let Ok(Some(command_path)) = find_executable_in_paths(type_name) {
println!("{type_name} is {command_path}");
} else {
println!("{type_name}: not found")
}
}
"" => {}
_ => {
println!("{command}: not found")
}
}
}
-
commandsis an array ([T; N]), holds the fixedNsize ofTtype elements. It is allocated in stack so it is best suited for predefined lists. Butrestis a vector typeVec<&str>. -
Why passing
&type_nameinstead oftype_namesince it is already&str? - Even though &str looks like borrowed string, internally it is a fat pointer. So still you’re required to pass the reference to fat pointer to satisfy function signature. -
Option lets to have either
Somevalue, orNone. And if statement let’s filter by it.
Find Executable
fn find_executable_in_paths(command_name: &str) -> io::Result<Option<String>> {
let Ok(env_path) = env::var("PATH") else {
return Ok(None);
};
let paths = env_path.split(":");
for path in paths {
if let Some(v) = find_executable(Path::new(path), command_name)? {
return Ok(Some(v));
}
}
Ok(None)
}
-
Again another way to handle errors nicely by using if/else.
let Ok(env_path) = env::var("PATH")takes theOkvalue and if itErrthenelse {return Ok(None)}block get executed. -
Since both functions returns
io::Result<Option<String>>I can do an early return and check if there is a value at the same line asif let Some(v) = find_executable(Path::new(path), command_name)?
fn find_executable(path: &Path, command_name: &str) -> io::Result<Option<String>> {
let entries = fs::read_dir(path)?;
for entry in entries {
let Ok(entry) = entry else { continue };
let path = entry.path();
let Some(path_str) = path.to_str() else {
continue;
};
if path.is_dir() {
find_executable(Path::new(&path_str), command_name)?;
} else {
let Some(file_name) = path.file_stem() else {
continue;
};
if file_name == command_name {
let Ok(metadata) = path.metadata() else {
return Ok(None);
};
// bitmask check - performs bitwise AND against
// owner execute: 0o100
if metadata.permissions().mode() & 0o100 != 0 {
return Ok(Some(path_str.to_owned()));
}
};
}
}
Ok(None)
}
- This a recursive walk directory function, where it looks for a command by the filename
Part 2 of Learning Rust by building a shell - WIP