← Back to writing

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 \n or the buffer gets manually cleared - which is what flush() 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 and read_line which 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 the Ok or panic when it is Err.

  • 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 let along 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")
      }
  }
}
  • commands is an array ([T; N]), holds the fixed N size of T type elements. It is allocated in stack so it is best suited for predefined lists. But rest is a vector type Vec<&str>.

  • Why passing &type_name instead of type_name since 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 Some value, or None. 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 the Ok value and if it Err then else {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 as if 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

References