Fisher Darling | Blog | Resume

Lox In Rust Part 1 - CLI Setup and Error Handling

Welcome to the first real post of the series! In the LINK ME previous part we setup our rust project and created a simple main() function.

The portion of the book that we will be following in this series teaches us how to implement a Tree Walk Interpreter. In this post we learn how to parse command line arguments, read files, and setup easily extensible error handling (chapter link here).

Scanning

CLI Setup

Before we start dealing with any lox related code, let's create a small framework for reading files or for starting a REPL.

First we need to read and parse command line arguments. For this I like to use the crate structopt. Let's add a dependency for it in our Cargo.toml:

# Cargo.toml

[dependencies]
structopt = "*"

And inside src/main.rs add a struct that will represent the possible command line arguments for our binary:

// src/main.rs

use std::path::PathBuf;
use structopt::StructOpt;

/// A Rust interpreter for the Lox programming language. 
#[derive(Debug, Default, Clone, StructOpt)]
#[structopt(name = "lox")]
pub struct Config {
    /// The path of the .lox file to execute 
    #[structopt(short = "f", long = "file")]
    path: Option<PathBuf>,
}

All of those annotations can be a bit confusing, but here's the gist of it:

  1. We first derive the StructOpt trait for some struct.
  2. Then for each field in that struct we tell StructOpt how to parse it.
    - "short" and "long" describe the flags that receive user input.
  3. Now we can call from_args() on the type to parse the CLI Arguments.

With a struct that represents our program's config, we can parse command line arguments like so:

// src/main.rs
// ...

fn main() {
    let args = Config::from_args();
}

Try it in your terminal! To pass CLI arguments to binaries with cargo, just add a -- to the end of cargo run. E.g. To view the help message of our new binary, cargo run -- --help.

lox 0.1.0
Your Name <email@example.com>
A Rust interpreter for the Lox language.

USAGE:
    lox [OPTIONS]

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
    -f, --file <path>    The path of the .lox file to execute

You may have noticed, but something really cool about StructOpt is that the doc comments act as the description for our binary! Alright, let's quickly jump over to src/lib.rs and use that path:

// src/lib.rs

use std::path::Path;
use std::borrow::Borrow;
use std::fs;

/// A Lox program.
pub struct Lox;

impl Lox {
    /// Run Lox code in a file
    pub fn run_file<P: AsRef<Path>>(path: P) {
        let contents = fs::read_to_string(path).unwrap(); 
        Lox::run(contents);
    }

    /// Run any utf-8 str of Lox code 
    pub fn run<C: Borrow<str>>(input: C) {
        let code = input.borrow();
        
        unimplemented!()
    }
}

Here we have a great example of using Generics and traits to our advantage. By using AsRef<Path> rather than taking in a PathBuf, we can pass in a &str, String, or a PathBuf! This makes the api much more ergonomic. Also by using Borrow<str> we can allow both &str and String to be used as arguments!

In this code we had to use .unwrap() on fs::read_to_string to get the String outside of the Result. If instead of a String, there was an std::io::Error our program would crash, with no chance of recovery. Unwrapping Result and Option types makes our lives much easier, but it's considered code smell to have unwrap()s all over the place. We'll soon discover how easy it is to setup error handling in Rust, right after we finish setting up the CLI.

To use the Config, add a few lines to our main function:

// src/main.rs

// ...
fn main() {
    let args = Config::from_args();
    
    if let Some(path) = args.path {
        Lox::run_file(path);
    } else {
        Lox::run_prompt();
    }
}

Jump back over to lib.rs and implement the run_prompt() function. This will be our program's REPL.

// src/lib.rs

use std::io::{stdin, stdout, BufRead, Write};
// ...
impl Lox {
    // ...
    
    /// Start the Lox REPL
    pub fn run_prompt() {
        let stdin = stdin();
        let mut lines = stdin.lock().lines();

        print!("> ");
        stdout().flush().unwrap();

        while let Some(line) = lines.next().transpose().unwrap() {
            print!("> ");
            stdout().flush().unwrap();

            Lox::run(line);
        }
    }
}

There's a lot going on here, so let's break it down a bit:

Firstly, we import two functions, stdin and stdout, to access our program's Stdin and Stdout, respectively. We import two traits, BufRead for the lines() function, and Write for flushing stdout1.

Inside of run_prompt(), we first get a handle to the program's Stdout, and then acquire a lock on it. The lines() function returns an iterator over the lines from stdin, so whenever the user presses enter, our iterator will produce a line.

Then, to display a "> " before the user's input, we use the print! macro, rather than the println! macro. This unfortunately requires us to flush stdout to display the characters (println! would do this for you).

Finally, a while let block makes working with the lines iterator a breeze; we can easily pattern match on the returned Option of a .next() call2.

Now just to make sure, run your binary without any arguments and you should be dropped into the "REPL", crashing after the input. If you pass in a file using -f or --file then the program should simply exit. A non-existent file will crash the program.

So that's it for setting up the CLI and execution framework! Let's get started on proper error handling.

Errors

We have many options for handling errors in Rust. We could create an error enum and then manually derive Error and Display for it and any new errors we add, or we could use one of the many Error handling crates, such as failure, and make our lives easier. Let's make our lives easier.

Add a dependency for failure in our Cargo.toml:

[dependencies]
# ...
failure = "*"

And then create the file src/error.rs:

use failure::Fail;

pub enum Error {
    Dummy
}

Declare the module under src/lib.rs, after the imports, but before any code3:

// ...
pub mod error;
// ...

Now we can derive the trait Fail for our Error enum. To do this, we need to add a couple of annotations for each error variant. Our first error variant will be an error that wraps std::io::Error. We will be following the recommended naming convention of naming errors, verb-object-error. Since we're wrapping an error, I've chosen to omit a verb.

// src/error.rs

use failure::Fail;
use std::io::Error as IOError;

#[derive(Debug, Fail)]
pub enum Error {
    #[fail(display = "An IO Error was encountered: {:?}", 0)]
    IOError(#[cause] IOError)
}

First import the error we want to wrap, and then rename it to remove any ambiguities with what Error we're talking about; this is done with the as keyword. Then we describe how we want the error to be displayed, done with an annotation on the new variant. Then we tell failure that an IOError is the cause of this particular error.

Quickly implement From<IOError> for Error to make error bubbling even cleaner:

// src/error.rs

impl From<IOError> for Error {
    fn from(error: IOError) -> Error {
        Error::IOError(error)
    }
}

Now the next step is to refactor our different functions that use .unwrap(), and start properly handling errors.

// src/lib.rs

impl Lox {
    /// Run Lox code in a file
    pub fn run_file<P: AsRef<Path>>(path: P) -> Result<(), Error> {
        let contents = fs::read_to_string(path)?;
        Lox::run(contents)
    }

    /// Run any utf-8 str of Lox code 
    pub fn run<C: Borrow<str>>(input: C) -> Result<(), Error> {
        // ...
    }

    /// Start the Lox REPL
    pub fn run_prompt() -> Result<(), Error> {
        // ...

        print!("> ");
        stdout().flush()?;

        while let Some(line) = lines.next().transpose()? {
            print!("> ");
            stdout().flush()?;

            Lox::run(line)?;
        }

        Ok(()) // So the function actually returns a result
    }
}

We've imported our error type, and any function that could error now returns a Result<(), Error>. Anything that can possibly error is now followed by the question mark operator: ?.

Finally, modify src/main.rs to handle any resulting error.

use lox::{Lox, error::Error};
// ...

fn main() {
    // ...

    let result = if let Some(path) = args.path {
        Lox::run_file(path)
    } else {
        Lox::run_prompt()
    };

    result.map_err(|e| eprintln!("{}", e));
}

The map_err trick is can be used to print an error using the display formatting, "{}", rather than the debug "{:?}" formatting for the error. Notice the nested import syntax used to import both Lox and error::Error under the same namespace.

Now try to pass in a file that does exist and look at the pretty error message!

cargo run -- --file foo
An IO Error was encountered: Os { code: 2, kind: NotFound, message: "No such file or directory" }

Conclusion

That was a lot of setup and boiler plate, but now adding new errors and CLI options is just two lines! In the next part I promise we'll be writing actual Lox specific code and slowly begin crafting our Rust interpreter.


Footnotes

1

Remember, to make use of a trait that a type has implemented, it must be in scope.

2

.transpose() converts an Option<Result<T, E>> into a Result<Option<T>, E> and vise-versa, allowing us to unwrap the result on the same line it's created.

3

This is just convention, you could declare the module literally anywhere in src/lib.rs.