BASIC Interpreter with Rust

Read-Eval-Print Loop (REPL) and User Interface


Learning Objectives

  • You understand what a REPL is and why it improves user experience.
  • You can build an interactive command-line interface.
  • You know how to distinguish between commands and program lines.
  • You can handle user input errors gracefully.

Read-Eval-Print Loop (REPL)

Read-Eval-Print Loop (REPL) is a common pattern for interactive programming environments. It’s an interactive program that reads user input, evaluates it, prints the result, and loops back for more input. They’re common in languages that are interpreted, like Python, Ruby, and our BASIC interpreter!

The first programming language with a REPL was LISP, developed in the late 1950s. Commodore 64, released in 1982, started in a REPL, allowing users to program in BASIC directly after powering the computer on.

Loading Exercise...

Building a REPL

Let’s start building a REPL for the BASIC interpreter. We’ll start with a simple loop that reads user input and exits when the user types QUIT. Place the following code in src/main.rs:

use basic_interpreter::interpreter::Interpreter;
use std::io::Write;
use std::io;

fn main() {
    println!("Very BASIC Interpreter");
    println!("======================");
    println!();

    let mut interpreter = Interpreter::new();

    loop {
        // prompt for input
        print!("> ");

        // flush output to ensure prompt appears before input
        io::stdout().flush().unwrap();

        // read input
        let mut input = String::new();
        if let Err(e) = io::stdin().read_line(&mut input) {
            println!("IO error: {}", e);
            break;
        }

        let input = input.trim().to_ascii_uppercase();

        match input.as_str() {
            "QUIT" => break,
            _ => println!("Unknown command: {}", input),
        }
    }
}

Now, the program will display a prompt (> ), read user input, and exit when the user types QUIT. Any other input will result in an “Unknown command” message.

We’re flushing the output after printing the prompt to ensure that it appears immediately. Without flushing, the prompt might not show up until after the user presses Enter, which would be confusing to the user.

Distinguishing Commands and Program Lines

When building a REPL, we need to distinguish between two types of input: program lines and commands. When working with BASIC, we can make the assumption that program lines start with a line number (e.g., 10 PRINT "Hello"), while commands do not (e.g., RUN, LIST, QUIT).

A simple way to differentiate them is to check if the first character of the input is a digit. If it is, we treat it as a program line; otherwise, it’s a command.

We can use is_some_and with chars().next() and a closure to check the first character:

if input.chars().next().is_some_and(|c| c.is_numeric()) {
    // starts with a digit - it's a program line
} else {
    // ...
}

Let’s adjust the default case in our REPL to handle this distinction, and load lines into the interpreter if they start with a digit:

        match input.as_str() {
            "QUIT" => break,
            _ => {
                if input
                    .chars()
                    .next()
                    .is_some_and(|c| c.is_ascii_digit())
                {
                    interpreter
                        .load_line(&input)
                        .unwrap_or_else(|_| println!("Cannot load line: {}", input));
                    continue;
                }

                println!("Unknown command: {}", input);
            }
        }
Loading Exercise...

Running the Program

Let’s next add a command for running the loaded program. Update the match statement to include a RUN command:

        match input.as_str() {
            "QUIT" => break,
            "RUN" => {
                if let Err(e) = interpreter.run() {
                    println!("Error during execution: {}", e);
                }
            }
            _ => {
                if input
                    .chars()
                    .next()
                    .is_some_and(|c| c.is_ascii_digit())
                {
                    interpreter
                        .load_line(&input)
                        .unwrap_or_else(|_| println!("Cannot load line: {}", input));
                    continue;
                }

                println!("Unknown command: {}", input);
            }
        }

Now, you can load program lines and run them:

> 10 PRINT "HELLO!"
> 20 END
> RUN
HELLO!
> QUIT

Pretty Printing the Program

Let’s add a command for listing the current program. We’ll call it LIST.

We already have a list method in the interpreter that prints the program, so let’s start with that. Modify the match statement to include a LIST command:

        match input.as_str() {
            "QUIT" => break,
            "RUN" => {
                if let Err(e) = interpreter.run() {
                    println!("Error during execution: {}", e);
                }
            }
            "LIST" => {
                interpreter.list();
            }
            _ => {
                if input.chars().next().is_some_and(|c| c.is_ascii_digit()) {
                    interpreter
                        .load_line(&input)
                        .unwrap_or_else(|_| println!("Cannot load line: {}", input));
                    continue;
                }

                println!("Unknown command: {}", input);
            }
        }

Try it out:

> 10 PRINT "HELLO!"
> LIST
10 Print(String("HELLO!"))
> QUIT

As you notice, the output of the LIST command is not very user-friendly.

Let’s adjust our enums to improve the output format. First, adjust the list method to use the Display trait for pretty-printing.

    pub fn list(&self) {
        let mut lines: Vec<&usize> = self.program.keys().collect();
        lines.sort();

        for line_num in lines {
            if let Some(statement) = self.program.get(line_num) {
                println!("{} {}", line_num, statement);
            }
        }
    }

And then, modify the ast.rs file to implement the Display trait for the enums:

use std::fmt;

#[derive(Debug, Clone, Copy)]
pub enum ArithOp {
    Add,
    Sub,
    Mul,
    Div,
}

impl fmt::Display for ArithOp {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ArithOp::Add => f.write_str("+"),
            ArithOp::Sub => f.write_str("-"),
            ArithOp::Mul => f.write_str("*"),
            ArithOp::Div => f.write_str("/"),
        }
    }
}

#[derive(Debug, Clone, Copy)]
pub enum ComparisonOp {
    Equal,
    Greater,
    Less,
}

impl fmt::Display for ComparisonOp {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ComparisonOp::Equal => f.write_str("="),
            ComparisonOp::Greater => f.write_str(">"),
            ComparisonOp::Less => f.write_str("<"),
        }
    }
}

#[derive(Debug, Clone)]
pub enum Expr {
    Number(i32),
    String(String),
    Variable(String),
    BinOp(Box<Expr>, ArithOp, Box<Expr>),
}

impl fmt::Display for Expr {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Expr::Number(n) => write!(f, "{}", n),
            Expr::String(s) => write!(f, "\"{}\"", s),
            Expr::Variable(v) => write!(f, "{}", v),
            Expr::BinOp(left, op, right) => write!(f, "({} {} {})", left, op, right),
        }
    }
}

#[derive(Debug, Clone)]
pub enum Statement {
    Print(Expr),
    Let(String, Expr),
    If {
        left: Expr,
        op: ComparisonOp,
        right: Expr,
        target: usize,
    },
    Goto(usize),
    End,
}

impl fmt::Display for Statement {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Statement::Print(expr) => write!(f, "PRINT {}", expr),
            Statement::Let(var, expr) => write!(f, "LET {} = {}", var, expr),
            Statement::If {
                left,
                op,
                right,
                target,
            } => {
                write!(f, "IF {} {} {} THEN {}", left, op, right, target)
            }
            Statement::Goto(target) => write!(f, "GOTO {}", target),
            Statement::End => write!(f, "END"),
        }
    }
}

// tests

Now, when we run the LIST command, we get a more readable output:

> 10 PRINT "HELLO!"
> 20 END
> LIST
10 PRINT "HELLO!"
20 END
>

Saving and Loading Programs

It’s often nice to be able to save and load programs from files. Let’s add commands SAVE and LOAD to our REPL. For this, we’ll implement file I/O in the interpreter. For this, we’ll use the std::fs module that allows us to read and write files.

Add the following to the top of src/interpreter.rs:

use std::fs;

Saving Programs

Now, with the import in place, let’s implement the save method in the Interpreter struct. The save method iterates over the program lines, formats them as BASIC code, and writes them to a specified file. Add the following method to src/interpreter.rs:

// ...

impl Interpreter {
    // existing functionality

    pub fn save(&self, filename: &str) -> Result<(), String> {
        let mut lines: Vec<&usize> = self.program.keys().collect();
        lines.sort();

        let mut content = String::new();
        for line_num in lines {
            if let Some(statement) = self.program.get(line_num) {
                content.push_str(&format!("{} {}\n", line_num, statement));
            }
        }

        fs::write(filename, content).map_err(|e| e.to_string())
    }
}

Loading Programs

Then, let’s implement the load method in the Interpreter struct. The load method reads a file line by line, loading each line to the interpreter. Add the following method to src/interpreter.rs:

// ...

impl Interpreter {
    // existing functionality

    pub fn load(&mut self, filename: &str) -> Result<(), String> {
        let content = fs::read_to_string(filename).map_err(|e| e.to_string())?;
        for line in content.lines() {
            self.load_line(line)?;
        }
        Ok(())
    }
}

Adding to REPL

Next, let’s add SAVE and LOAD commands to our REPL. As we need to be able to specify the filename, we need to adjust the logic a bit. Instead of matching the entire input string, we’ll split the input, and then, when looking at the command, we’ll check only the first part.

        // ...
        let input = input.trim().to_ascii_uppercase();
        let input_parts: Vec<&str> = input.split(" ").collect();

        match input_parts[0] {
            "QUIT" => break,
            "RUN" => {
                if let Err(e) = interpreter.run() {
                    println!("Error during execution: {}", e);
                }
            }
            "LIST" => {
                interpreter.list();
            }
            _ => {
                if input_parts[0].chars().next().is_some_and(|c| c.is_ascii_digit()) {
                    interpreter
                        .load_line(&input)
                        .unwrap_or_else(|_| println!("Cannot load line: {}", input));
                    continue;
                }

                println!("Unknown command: {}", input);
            }
        }
        // ...

Now, we can easily add the SAVE and LOAD commands. The idea is the same in both — we check if the command is SAVE or LOAD, then we check if a filename was provided, and finally, we call the respective method on the interpreter. Adjust the match statement as follows:

        match input_parts[0] {
            "QUIT" => break,
            "RUN" => {
                if let Err(e) = interpreter.run() {
                    println!("Error during execution: {}", e);
                }
            }
            "LIST" => {
                interpreter.list();
            }
            "SAVE" => {
                if input_parts.len() < 2 {
                    println!("Usage: SAVE <filename>");
                    continue;
                }
                let filename = input_parts[1];
                match interpreter.save(filename) {
                    Ok(_) => println!("Program saved to '{}'", filename),
                    Err(e) => println!("Error saving program: {}", e),
                }
            }
            "LOAD" => {
                if input_parts.len() < 2 {
                    println!("Usage: LOAD <filename>");
                    continue;
                }
                let filename = input_parts[1];
                match interpreter.load(filename) {
                    Ok(_) => println!("Program loaded from '{}'", filename),
                    Err(e) => println!("Error loading program: {}", e),
                }
            }
            _ => {
                if input_parts[0].chars().next().is_some_and(|c| c.is_ascii_digit()) {
                    interpreter
                        .load_line(&input)
                        .unwrap_or_else(|_| println!("Cannot load line: {}", input));
                    continue;
                }

                println!("Unknown command: {}", input);
            }
        }

Now, our REPL can save and load programs:

> 10 PRINT "HELLO!"
> 20 PRINT "WORLD!"
> LIST
10 PRINT "HELLO!"
20 PRINT "WORLD!"
> SAVE HELLO.BAS
Program saved to HELLO.BAS
> QUIT

After running the above commands, a file named HELLO.BAS will be created with the following content:

10 PRINT "HELLO!"
20 PRINT "WORLD!"

The file can then be loaded back into the interpreter using the LOAD command:

> LOAD HELLO.BAS
Program loaded from HELLO.BAS
> LIST
10 PRINT "HELLO!"
20 PRINT "WORLD!"
Loading Exercise...

Full REPL Code

Here’s the full REPL code from main.rs for reference (note, however, that changes were made to other files as well, as shown above):

use basic_interpreter::interpreter::Interpreter;
use std::io;
use std::io::Write;

fn main() {
    println!("BASIC Interpreter v0.1.0");
    println!("========================");
    println!();

    let mut interpreter = Interpreter::new();

    loop {
        // prompt for input
        print!("> ");

        // flush output to ensure prompt appears before input
        io::stdout().flush().unwrap();

        // read input
        let mut input = String::new();
        if let Err(e) = io::stdin().read_line(&mut input) {
            println!("IO error: {}", e);
            break;
        }

        let input = input.trim().to_ascii_uppercase();
        let input_parts: Vec<&str> = input.split(" ").collect();

        match input_parts[0] {
            "QUIT" => break,
            "RUN" => {
                if let Err(e) = interpreter.run() {
                    println!("Error during execution: {}", e);
                }
            }
            "LIST" => {
                interpreter.list();
            }
            "SAVE" => {
                if input_parts.len() < 2 {
                    println!("Usage: SAVE <filename>");
                    continue;
                }

                let filename = input_parts[1];
                if let Err(e) = interpreter.save(filename) {
                    println!("Error saving program: {}", e);
                } else {
                    println!("Program saved to {}", filename);
                }
            }
            "LOAD" => {
                if input_parts.len() < 2 {
                    println!("Usage: LOAD <filename>");
                    continue;
                }

                let filename = input_parts[1];
                if let Err(e) = interpreter.load(filename) {
                    println!("Error loading program: {}", e);
                } else {
                    println!("Program loaded from {}", filename);
                }
            }
            _ => {
                if input_parts[0]
                    .chars()
                    .next()
                    .is_some_and(|c| c.is_ascii_digit())
                {
                    interpreter
                        .load_line(&input)
                        .unwrap_or_else(|_| println!("Cannot load line: {}", input));
                    continue;
                }

                println!("Unknown command: {}", input);
            }
        }
    }
}

Loading Exercise...

Summary

In this chapter, we built a REPL for our BASIC interpreter. To summarize:

  • A Read-Eval-Print Loop (REPL) provides an interactive shell for user interaction. It follows a simple pattern: read user input, evaluate it, print the result, and loop back for more input.
  • In our REPL, we distinguished between commands (e.g., RUN, LIST, QUIT) and program lines (starting with a line number).
  • We added functionality for pretty printing the program using the Display trait, which we also benefited from when implementing functionality for saving and loading programs to and from files.
Loading Exercise...

In the next chapter, we’ll very briefly discuss testing and test coverage for our interpreter.