How to Write a REPL in Rust

Cli
Build a Rust REPL by creating a loop that reads stdin, processes input, and prints output until the user types quit.

How to Write a REPL in Rust

You're building a command-line calculator. You want to type 2 + 2, hit Enter, and see 4 immediately. You want to keep typing expressions until you decide to stop. That interactive loop is a REPL: Read, Evaluate, Print, Loop. It's the backbone of shells, database clients, and language interpreters.

A REPL is just a loop that talks to the terminal. It reads a line from standard input, does something with that line, writes a result to standard output, and repeats. The tricky part in Rust isn't the loop. It's the buffering. The terminal doesn't send data byte-by-byte instantly. It buffers input until you press Enter. And the program doesn't always send output instantly. You have to flush the output buffer so the prompt appears before the user types.

Think of a waiter taking orders. You speak, they write it down, they process it, they bring back the result, then they wait for the next order. The waiter doesn't shout the menu before you're ready to order. They wait for you to signal you're done speaking. That signal is the Enter key.

The minimal loop

The core of a REPL is a loop that reads from stdin and writes to stdout. You need a mutable String to hold the input because read_line appends to the buffer rather than replacing it.

use std::io::{self, Write};

/// A minimal REPL that echoes input until "quit".
fn main() {
    // Allocate the string once outside the loop to reuse memory.
    let mut input = String::new();

    loop {
        // Print the prompt to stdout.
        print!("> ");
        // Force the buffer to the terminal so the prompt appears immediately.
        io::stdout().flush().unwrap();

        // Clear the string for the next iteration.
        input.clear();
        // Read a line from stdin, appending to input.
        io::stdin().read_line(&mut input).unwrap();

        // Remove the trailing newline and any surrounding whitespace.
        let command = input.trim();

        if command == "quit" {
            break;
        }

        println!("You said: {}", command);
    }
}

Convention aside: input.clear() keeps the allocated memory. Creating a new String every loop iteration allocates and frees memory repeatedly. The community prefers clear() to reuse the buffer. It's a small optimization that pays off in tight loops.

What happens under the hood

The loop starts. print! writes > to the internal buffer of stdout. flush() pushes that buffer to the terminal. Without flush, the prompt might appear after you type, which looks broken. The user sees a blank line, types, hits Enter, and then the prompt appears. That's a confusing experience.

read_line blocks until the user presses Enter. It reads bytes from stdin, decodes them as UTF-8, and appends the result to input. Note that read_line keeps the newline character. If you type quit and hit Enter, input contains "quit\n".

trim() strips whitespace from both ends, removing the newline. If you skip trim, your command is "quit\n", not "quit", and the check fails. The loop never breaks.

If you forget to clear input, the string grows forever. Every iteration appends to the previous content. Your memory usage climbs until the process crashes. clear() resets the length to zero without deallocating.

Flush or your prompt vanishes. The terminal buffer is not your friend.

A realistic structure

Real REPLs parse commands, handle errors, and manage state. You should separate the reading logic from the processing logic. This keeps the loop clean and makes testing easier.

use std::io::{self, Write};

/// Process a single command and return the result string.
/// Returns an empty string to signal exit.
fn process_command(cmd: &str) -> String {
    match cmd {
        "help" => "Available commands: help, status, quit".to_string(),
        "status" => "System operational.".to_string(),
        "quit" => String::new(),
        _ => format!("Unknown command: {}", cmd),
    }
}

fn main() {
    let mut input = String::new();

    loop {
        print!("repl> ");
        io::stdout().flush().unwrap();

        input.clear();

        // Handle the Result from read_line.
        match io::stdin().read_line(&mut input) {
            Ok(_) => {
                let cmd = input.trim();

                // Skip empty lines so users can press Enter without errors.
                if cmd.is_empty() {
                    continue;
                }

                let response = process_command(cmd);

                // Empty response signals exit.
                if response.is_empty() {
                    break;
                }

                println!("{}", response);
            }
            Err(e) => {
                // Print errors to stderr.
                eprintln!("Error reading input: {}", e);
                break;
            }
        }
    }
}

Convention aside: Use eprintln! for error messages. Standard error is separate from standard output. This lets users redirect output without losing error messages. Also, continue on empty input is a standard REPL pattern so users can press Enter without triggering errors.

Managing state

A REPL without state is just a function call in a loop. Real tools remember context. A calculator remembers variables. A shell remembers the current directory. You need a struct to hold state.

use std::io::{self, Write};

/// Holds the REPL's persistent data.
struct ReplState {
    count: u32,
}

impl ReplState {
    fn new() -> Self {
        Self { count: 0 }
    }

    fn handle_command(&mut self, cmd: &str) -> Option<String> {
        match cmd {
            "count" => Some(self.count.to_string()),
            "inc" => {
                self.count += 1;
                Some(self.count.to_string())
            }
            "quit" => None,
            _ => Some(format!("Unknown: {}", cmd)),
        }
    }
}

fn main() {
    let mut state = ReplState::new();
    let mut input = String::new();

    loop {
        print!("state> ");
        io::stdout().flush().unwrap();

        input.clear();
        if io::stdin().read_line(&mut input).is_err() {
            break;
        }

        let cmd = input.trim();
        if cmd.is_empty() {
            continue;
        }

        match state.handle_command(cmd) {
            Some(response) => println!("{}", response),
            None => break,
        }
    }
}

State lives outside the loop. Pass it to your handler. Update it based on commands. This pattern scales to complex tools.

Pitfalls and errors

Buffering is the most common trap. Forgetting flush makes the prompt appear late. This happens on all platforms. Don't assume the OS handles it.

The newline trap is next. read_line keeps the \n. If you compare input == "quit", it fails. You must trim. trim() removes whitespace from both ends. trim_end_matches('\n') is more precise but trim() is the convention for command parsing.

Encoding errors can crash your REPL. read_line assumes UTF-8. If the user pastes binary garbage, read_line returns an error. You must handle the Err variant. If you unwrap, the REPL crashes. A robust REPL catches the error, prints a warning, and continues the loop.

The compiler warns about unused Result if you drop the return value of read_line. You'll see a warning like "unused Result that must be used". Fix it by matching or using expect with a clear message.

Handle the Result. A REPL that panics on a broken pipe is a REPL that crashes in production.

When to use what

Use std::io for simple scripts when you just need a loop and don't care about history or arrow keys.

Use dialoguer for interactive prompts when you want styled menus, confirmations, and text inputs with minimal code.

Use rustyline for full-featured REPLs when you need command history, tab completion, and line editing.

Use repl.rs when you want a batteries-included framework with built-in help and command registration.

Start with std::io. Add complexity only when the user complains about missing arrow keys.

Where to go next