July 2024 · 7 min read

Building the REPL

A compiled language needs a REPL too. Here's how we built one that feels as responsive as Python's.

The challenge

Lateralus compiles to C99. Running each REPL line through the full compiler pipeline (parse → type-check → codegen → compile C → link → execute) would take seconds. Users expect milliseconds.

The solution: incremental compilation

The REPL maintains a running compilation context. Each new line is type-checked against the existing context, compiled to a tiny C function, dynamically loaded via dlopen, and called. Total latency: 20-50ms.

Tab completion

The REPL knows about all identifiers in scope — variables, functions, struct fields, module members. Pressing Tab triggers fuzzy matching:

>>> transactions |> fil[TAB]
    filter    filter_map    find    first

>>> transactions |> filter(|t| t.am[TAB]
    amount    amended_date

Colored output

Results are syntax-highlighted in the terminal. Strings are green, numbers are cyan, errors are red. The :type command shows the inferred type of any expression:

>>> :type [1, 2, 3] |> map(|x| x * 2.0)
Vec<Float>

Special commands

The dlopen trick

The key insight that makes the REPL fast is treating each input as a dynamically loadable plugin. Here's the pipeline for each keystroke of Enter:

// Internal REPL pipeline (simplified)
fn eval_line(line: String, ctx: &mut ReplContext) -> Result<Value, Error> {
    // 1. Parse the line into an AST fragment
    let ast = parse_incremental(line, ctx.scope)?

    // 2. Type-check against existing context
    let typed = typecheck(ast, ctx.type_env)?

    // 3. Generate a tiny C function
    let c_src = codegen_fragment(typed, ctx.symbol_table)
    // Output: void ltl_repl_42(ReplEnv* env) { ... }

    // 4. Compile to shared library
    let so_path = compile_c(c_src, flags: "-O1 -shared -fPIC")
    // Output: /tmp/ltl_repl_42.so (takes ~15ms)

    // 5. Load and execute
    let lib = dlopen(so_path)
    let func = dlsym(lib, "ltl_repl_42")
    func(ctx.env)

    // 6. Update context for next line
    ctx.update_from(typed)
    Ok(ctx.last_value())
}

The clever part: we don't recompile the entire program. Each line compiles to a standalone .so that references symbols from previous lines through the ReplEnv struct. This keeps latency at 20-50ms per evaluation.

Multi-line editing

The REPL detects incomplete expressions and switches to multi-line mode automatically. Open braces, unclosed strings, and trailing operators all trigger continuation:

>>> let users = db.query("SELECT * FROM users")
...     |> filter(|u| u.active)
...     |> sort_by(|u| u.last_login, descending)
...     |> take(10)
...
[User { name: "alice", ... }, User { name: "bob", ... }, ...]

You can also paste multi-line code blocks directly. The REPL detects bulk paste (multiple lines arriving within 10ms) and treats them as a single evaluation unit.

Notebook mode

For longer exploratory sessions, the REPL supports a notebook mode that saves all inputs and outputs to a .ltl-notebook file:

>>> :notebook start exploration.ltl-notebook

>>> let data = read_csv("sales-2025.csv")
>>> data |> group_by(|r| r.region) |> map_values(|rows| rows |> sum_by(|r| r.revenue))
{
  "North": 1_234_567,
  "South": 987_654,
  "East": 1_567_890,
  "West": 876_543,
}

>>> :notebook save
Saved 2 cells to exploration.ltl-notebook

Notebooks can be replayed later with :notebook load, and they're compatible with the VS Code Lateralus extension's notebook renderer.

Error display

REPL errors are designed to be immediately actionable. Instead of a stack trace, you get a highlighted source snippet with a caret pointing to the problem:

>>> [1, 2, "three"] |> map(|x| x * 2)

Error: type mismatch in pipeline stage 2
  [1, 2, "three"] |> map(|x| x * 2)
         ^^^^^^^
  Expected: Int (inferred from elements 0, 1)
  Found: String at index 2

  Help: Use filter_map to handle mixed types:
    [1, 2, "three"] |> filter_map(|x| x.try_as_int()) |> map(|x| x * 2)

The error messages include concrete suggestions based on the most common ways to fix each error type. These suggestions are generated by the type checker, not hardcoded strings.

Lateralus is built by bad-antics. Follow development on GitHub or try the playground.