Vibe-Coding an Interpreter in Rust: Part I


⚠️ Warning: I am not an expert on this subject, nor do I claim to be! This is purely for my own entertainment. This series makes heavy use of AI and copy-pasted code. I make no ownership claim to the written code, it is for demonstration purposes only.

I am not a developer, but I did take an introduction to Java class in community college. I’ve spent a lot of time learning Python, and tinkered with other languages over the years. I’m always looking for opportunities to play the part.

Lately, I’ve gotten the itch to learn another programming language, and I decided to learn Rust. The goal was not to become a Rust developer, but to simply see what all the hype was about. I had learned the basics of C many years ago, but I don’t think I’m cut out for such work. Rust helps manage memory safely without a garbage collector, so I figured it might be easier than raw C.

What project should I pursue to learn Rust? Not another “To-Do” app, or anything like that. Instead I wanted to try something more ambitious. I decided to follow Crafting Interpreters by Robert Nystrom. Knowing full well I will fail, my goal was to see how far I could get. I embark on the ultimate vibe-coding project. Can I vibe-code an interpreter written in a language I’ve never used before? That is the question.

Luckily, I’m not the first person to try to build an interpreter using Rust. To help me in my quest, I’ll be referencing Andrew Davis’ rlox project. After all, I’m going to need all the help I can get.

You can view the source code for this article on my github.

Initial Setup

I created a cargo project

cargo init rox

I added the following to my Cargo.toml

[dependencies]
rustyline = "16.0.0"

[features]
default = ["with-file-history"]
with-file-history = []

A Basic REPL

In this post I just want to get a simple REPL working. The absolute bare minimum.

Here is the full code:

use rustyline::error::ReadlineError;
use rustyline::{DefaultEditor, Result};

use std::env;
use std::fs;
use std::io;

fn run(source: &str) {
    println!("{}", source);
    // interpreter logic goes here
    // let tokens = lex(&line);
    // dbg!(&tokens);
    // let ast = parse(tokens);
    // let result = eval(ast);
}


fn run_file(filename: &str) -> io::Result<()> {
    let source = fs::read_to_string(filename)?;
    println!("{}", source);
    run(&source);
    Ok(())
}


fn repl() -> Result<()> {
    let mut rl = DefaultEditor::new()?;
    #[cfg(feature = "with-file-history")]
    if rl.load_history("history.txt").is_err() {
        println!("No previous history.");
    }
    loop {
        let readline = rl.readline(">> ");
        match readline {
            Ok(line) => {
                rl.add_history_entry(line.as_str())?;
                run(&line);
                if line == "exit" {
                    break
                }
            }
            Err(ReadlineError::Interrupted) => {
                println!("CTRL-C");
                break;
            }
            Err(ReadlineError::Eof) => {
                println!("CTRL-D");
                break;
            }
            Err(err) => {
                println!("Error: {:?}", err);
                break;
            }
        }
    }
    #[cfg(feature = "with-file-history")]
    if let Err(e) = rl.save_history("history.txt") {
        eprintln!("Could not save history: {}", e);
    }

    Ok(())
}

fn main() {
    let args: Vec<String> = env::args().collect();
    if args.len() == 2 {
        if let Err(e) = run_file(&args[1]) {
            eprintln!("Error in run_file: {}", e);
        }
    } else if args.len() == 1 {
        if let Err(e) = repl() {
            eprintln!("Error in REPL: {}", e);
        }    
    } else {
        eprintln!("Usage: rox [file]");
    }
}    

main Function

This is the entry point for the program. Not much to say here. We can invoke it in two ways:

  1. cargo run by itself with no arguments drops us into a simple REPL where we can run interactively.
tim@lappy rox % cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/rox`
>> echo "Hello, world!"
echo "Hello, world!"
>> exit
exit
  1. cargo run filename will read from a file.
tim@lappy rox % cargo run Cargo.toml 
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/rox Cargo.toml`
[package]
name = "rox"
version = "0.1.0"
edition = "2024"
...

Currently, it just echos back the input source code. I added some basic error handling and changed the name to “rox” because I’m so clever.

repl function

I wanted to add some pizzazz to the REPL, so I found this cool Rust crate called rustyline which adds features commonly found in shells like BASH, such as command history. Who knows, maybe I will add more shell-like features. I basically copied the example code verbatim.

This loop grabs the line as a string and passes it to the run() function. This is where I have currently outsourced the interpreter logic.

run_file function

This function reads from a file, and passes the contents as a string to the run() function. Again, nothing to really see here.

run function

This is where I plan to implement the main interpreter logic.

fn run(source: &str) {
    println!("{}", source);
    // interpreter logic goes here
    // let tokens = lex(&line);
    // dbg!(&tokens);
    // let ast = parse(tokens);
    // let result = eval(ast);
}

The general process goes like this:

source code → [scanner] → tokens → [parser] → statements & expressions → [interpreter]

Scanner

The Scanner, also called the Lexer, reads each character (symbol) from the input and groups them into meaningful sequences called lexemes. For example:

...
... a...
... an...
... and...
... and ...

If we scan the sequence and find the lexeme “and”, we know this corresponds to the logical and operator. We match these lexemes against a list of known keywords and symbols, and convert them into tokens.

A token is a small data structure that includes:

  • the token type (IDENTIFIER, AND, NUMBER, etc.)
  • the lexeme itself (the raw text)
  • possibly a parsed literal value (e.g., 123 → Token::Number(123.0))
  • and maybe metadata like the line number for error reporting.

For example, our and Token may be written as:

Token {
    token_type: TokenType::And,
    lexeme: "and",
    literal: None,
    line: 1,
}

Parser

A Parser takes a list of Tokens and organizes them into an Abstract Syntax Tree (AST). The AST allows us to evaluate expressions, much the same way we would evaluate mathematical expressions, using order of operations, and binary/unary operators.

The Parser understands things like:

  • order of operations (* before +)
  • grouping expressions using parentheses
  • associating operators with operands

AST example

Next Time

This stuff is probably confusing to read, but I think showing instead of telling will make things much clearer.

In the next part of this series, I will be implementing a basic Scanner/Lexer.