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
:type <expr>— show inferred type:ast <expr>— show parse tree:time <expr>— benchmark expression:clear— reset REPL state:load <file>— load a.ltlfile into scope
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.