Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Embedding Bop

Bop is designed to be embedded in Rust applications. Three ways to run a program are available; every one uses the same BopHost trait as the integration seam for print output, custom functions, module resolution, timeouts, etc.

Quick start

Add the crates you need to Cargo.toml:

[dependencies]
bop = { package = "bop-lang", version = "0.3" }
bop-sys = "0.3"          # optional — OS-backed standard host
bop-vm = "0.3"           # optional — bytecode VM (faster per-fn cost)

Run a program with the standard host:

use bop::BopLimits;
use bop_sys::StandardHost;

fn main() {
    let source = r#"
        let name = "world"
        print("Hello, {name}!")
    "#;

    let mut host = StandardHost::new();

    if let Err(e) = bop::run(source, &mut host, &BopLimits::standard()) {
        eprintln!("{}", e.render(source));
    }
}

StandardHost (aliased as StdHost) lives in bop-sys and is the OS-backed reference host. It supports print() output plus these host functions:

FunctionDescription
readline() / readline(prompt)Read one line from stdin; none at EOF
read_file(path)Read a UTF-8 file into a string
write_file(path, contents)Replace a file with string contents
append_file(path, contents)Append string contents to a file
file_exists(path)true / false
env(name)Environment variable, or none if missing
unix_time()Seconds since the epoch
unix_time_ms()Milliseconds since the epoch

Call StandardHost::new().with_module_root(<path>) to enable filesystem module resolution: use foo.bar.baz maps to <root>/foo/bar/baz.bop with path-traversal guards.

Three engines, same host

Bop ships three execution engines — all of them share the BopHost trait, BopError, BopLimits, and Value types, so the same program and host work with any of them. Pick whichever fits your workload:

EngineEntry pointWhen it’s best
Tree-walkerbop::run(src, host, limits)Lowest start-up cost. Best for one-off scripts, REPL, small inputs, no_std / wasm.
Bytecode VMbop_vm::run(src, host, limits)2–3× faster than the walker on hot loops. Same program, no compilation to disk.
AOT transpilerbop_compile::transpile(src, opts)cargo buildBop → Rust source, compiled to a native binary. Maximum throughput, at the cost of a cargo build step.

All three obey the same BopLimits and surface errors as BopError — the engine choice is an implementation detail from the host’s perspective.

The BopHost trait

The BopHost trait is the integration point between your application and Bop:

#![allow(unused)]
fn main() {
pub trait BopHost {
    /// Called for unknown function names. `None` = not handled.
    fn call(
        &mut self,
        name: &str,
        args: &[Value],
        line: u32,
    ) -> Option<Result<Value, BopError>>;

    /// Called by `print()`. Default: drops the message.
    fn on_print(&mut self, message: &str) { let _ = message; }

    /// Appended to "function not found" errors as a
    /// friendly hint (e.g. "Available functions: ...").
    fn function_hint(&self) -> &str { "" }

    /// Called at each interpreter tick (statement, loop
    /// iteration, fn entry). Return `Err` to halt.
    fn on_tick(&mut self) -> Result<(), BopError> { Ok(()) }

    /// Resolve a `use` target to Bop source.
    /// `None` = "not mine" (the runtime raises "module not
    /// found"); `Some(Err(e))` = resolver failed (propagated).
    fn resolve_module(&mut self, name: &str)
        -> Option<Result<String, BopError>>
    { let _ = name; None }
}
}

call — Custom functions

The primary extension point. When Bop encounters a function call that isn’t a built-in or user-defined fn, it asks the host. Return None if you don’t handle the name — Bop then surfaces “function not found” with the optional function_hint. Return Some(Ok(v)) on success or Some(Err(e)) to raise a runtime error.

#![allow(unused)]
fn main() {
use bop::{BopError, BopHost, Value};

struct MyHost;

impl BopHost for MyHost {
    fn call(
        &mut self,
        name: &str,
        args: &[Value],
        line: u32,
    ) -> Option<Result<Value, BopError>> {
        match name {
            "square" => match args {
                [Value::Int(n)]    => Some(Ok(Value::Int(n * n))),
                [Value::Number(n)] => Some(Ok(Value::Number(n * n))),
                _ => Some(Err(BopError::runtime(
                    "square(n) expects one number", line
                ))),
            },
            _ => None,
        }
    }

    fn function_hint(&self) -> &str {
        "Custom functions: square(n)"
    }
}
}

Bop scripts now call square(5) as if it were built-in.

on_print — Capturing output

Override on_print to redirect print() output to a buffer, log, UI widget — anywhere that isn’t stdout:

#![allow(unused)]
fn main() {
struct Buffered { output: Vec<String> }

impl BopHost for Buffered {
    fn call(&mut self, _: &str, _: &[Value], _: u32)
        -> Option<Result<Value, BopError>> { None }
    fn on_print(&mut self, msg: &str) { self.output.push(msg.into()); }
}
}

resolve_module — Custom use resolution

Supply module source for use path.to.module statements. Return:

  • Some(Ok(source)) — the module’s source text (Bop parses and executes it).
  • Some(Err(err)) — resolver error; propagated to the user.
  • None — “not my module”; Bop raises “module foo not found”.
#![allow(unused)]
fn main() {
impl BopHost for MyHost {
    fn resolve_module(&mut self, name: &str)
        -> Option<Result<String, BopError>>
    {
        match name {
            "greetings" => Some(Ok(r#"
                fn hello(who) { return "hi " + who }
            "#.into())),
            _ => None,
        }
    }
    // ... call, etc.
}
}

bop::host::resolve_from_map and bop::host::StringModuleHost (below) are ready-made helpers that cover the common “in-memory module table” pattern.

on_tick — Execution control

Called on every tick — fn entry, loop iteration, most statements. Use it for:

  • Timeouts — check elapsed time and halt.
  • Cancellation — read a &AtomicBool set by another thread.
  • Progress tracking — increment a counter or refresh a progress bar.
#![allow(unused)]
fn main() {
use std::time::{Duration, Instant};

struct Timed { start: Instant, budget: Duration }

impl BopHost for Timed {
    fn call(&mut self, _: &str, _: &[Value], _: u32)
        -> Option<Result<Value, BopError>> { None }
    fn on_tick(&mut self) -> Result<(), BopError> {
        if self.start.elapsed() > self.budget {
            Err(BopError::runtime("execution timed out", 0))
        } else {
            Ok(())
        }
    }
}
}

on_tick errors count as runtime errors — they can be caught by try_call inside Bop. Use BopError::fatal instead if you need the halt to be uncatchable:

#![allow(unused)]
fn main() {
fn on_tick(&mut self) -> Result<(), BopError> {
    if cancel_flag.load(Ordering::Relaxed) {
        Err(BopError::fatal("cancelled", 0))   // `try_call` won't swallow this
    } else {
        Ok(())
    }
}
}

Resource limits

BopLimits controls how much work a program can do before it’s killed with a fatal error:

#![allow(unused)]
fn main() {
pub struct BopLimits {
    pub max_steps: u64,      // tick budget
    pub max_memory: usize,   // bytes for strings + arrays + structs
}
}

Two presets:

Presetmax_stepsmax_memory
BopLimits::standard()10,00010 MB
BopLimits::demo()1,0001 MB

Custom:

#![allow(unused)]
fn main() {
let limits = BopLimits {
    max_steps: 50_000,
    max_memory: 32 * 1024 * 1024,
};
}

Limit violations are fataltry_call in user code can’t swallow them.

Ready-made host helpers

bop::host bundles the two most common host shapes so embedders don’t have to hand-roll them.

bop::host::resolve_from_map(entries)

Build a resolve_module-compatible closure from any iterable of (name, source) pairs. Drop it inside your own BopHost impl:

#![allow(unused)]
fn main() {
use bop::host::resolve_from_map;

struct MyHost { resolve: Box<dyn Fn(&str) -> Option<Result<String, BopError>>> }

impl MyHost {
    fn new() -> Self {
        let resolve = resolve_from_map([
            ("greetings", "fn hello() { return \"hi\" }"),
            ("math_ext",  "fn sq(n) { return n * n }"),
        ]);
        Self { resolve: Box::new(resolve) }
    }
}

impl BopHost for MyHost {
    // ...
    fn resolve_module(&mut self, name: &str)
        -> Option<Result<String, BopError>>
    { (self.resolve)(name) }
}
}

bop::host::StringModuleHost

A minimal full BopHost implementation — captures prints to an in-memory vec and resolves modules from a string map. Useful for tests and playgrounds:

#![allow(unused)]
fn main() {
use bop::host::StringModuleHost;
use bop::BopLimits;

let mut host = StringModuleHost::new([
    ("greetings", "fn hello(who) { return \"hi \" + who }"),
]);

bop::run(
    r#"use greetings
print(hello("Bop"))"#,
    &mut host,
    &BopLimits::standard(),
).unwrap();

assert_eq!(host.output(), "hi Bop");
}

Stateful REPL sessions

bop::ReplSession carries let bindings, fn declarations, user types, methods, module aliases, and the import cache across eval calls. Use it when you want “one Bop interpreter, many user inputs” — interactive REPLs, notebook cells, per-request scripting.

#![allow(unused)]
fn main() {
use bop::{BopLimits, ReplSession};
use bop_sys::StandardHost;

let mut session = ReplSession::new();
let mut host = StandardHost::new();

session.eval("let x = 5", &mut host, &BopLimits::standard()).unwrap();
session.eval("let y = x + 3", &mut host, &BopLimits::standard()).unwrap();

// `eval` returns `Ok(Some(v))` when the last statement is a bare
// expression; `Ok(None)` for `let` / `fn` / `use` / etc.
let r = session.eval("y * 2", &mut host, &BopLimits::standard()).unwrap();
assert!(matches!(r, Some(bop::Value::Int(16))));

// Introspection.
assert!(session.get("x").is_some());
assert_eq!(session.binding_names(), vec!["x".to_string(), "y".to_string()]);
}

Each eval still respects the BopLimits you pass — useful if you want to allow a higher step budget per cell than for a batch-run program. The built-in bop CLI’s repl subcommand is the canonical consumer: it adds rustyline, multi-line input, :help / :vars / :reset / :quit meta-commands, tab completion, and a persistent history file. See REPL for the user-facing view.

Error rendering

BopError::render(source) produces a terminal-friendly error with a source snippet and a ^ carat under the offending column (when the error carries column info). Parse errors always have columns; runtime errors do when the failing expression was parsed from source.

#![allow(unused)]
fn main() {
match bop::run(src, &mut host, &BopLimits::standard()) {
    Ok(()) => {}
    Err(e) => eprintln!("{}", e.render(src)),
}
}

Typical output:

error: Variable `undefined` not found
  --> line 2:7
  |
2 | print(undefined)
  |       ^
hint: Did you forget to create it with `let`?

Putting it all together

A complete host that provides domain-specific functions, captures output, resolves modules from memory, and enforces a timeout:

use bop::{BopError, BopHost, BopLimits, Value};
use bop::host::resolve_from_map;
use std::time::{Duration, Instant};

struct AppHost {
    output: Vec<String>,
    start: Instant,
    data: Vec<f64>,
    resolve: Box<dyn Fn(&str) -> Option<Result<String, BopError>>>,
}

impl AppHost {
    fn new() -> Self {
        let resolve = resolve_from_map([
            ("stats_helpers", r#"
                fn median(xs) {
                    let sorted = xs
                    sorted.sort()
                    let mid = (sorted.len() / 2).to_int()
                    return sorted[mid]
                }
            "#),
        ]);
        Self {
            output: vec![],
            start: Instant::now(),
            data: vec![],
            resolve: Box::new(resolve),
        }
    }
}

impl BopHost for AppHost {
    fn call(
        &mut self,
        name: &str,
        args: &[Value],
        line: u32,
    ) -> Option<Result<Value, BopError>> {
        match name {
            "add_data" => match args {
                [Value::Int(n)]    => { self.data.push(*n as f64); Some(Ok(Value::None)) }
                [Value::Number(n)] => { self.data.push(*n);       Some(Ok(Value::None)) }
                _ => Some(Err(BopError::runtime("add_data(n) expects a number", line))),
            },
            "average" => {
                if self.data.is_empty() {
                    Some(Ok(Value::Number(0.0)))
                } else {
                    let sum: f64 = self.data.iter().sum();
                    Some(Ok(Value::Number(sum / self.data.len() as f64)))
                }
            }
            _ => None,
        }
    }

    fn on_print(&mut self, message: &str) {
        self.output.push(message.to_string());
    }

    fn on_tick(&mut self) -> Result<(), BopError> {
        if self.start.elapsed() > Duration::from_secs(5) {
            Err(BopError::fatal("timed out", 0))
        } else {
            Ok(())
        }
    }

    fn function_hint(&self) -> &str {
        "Custom host: add_data(n), average()"
    }

    fn resolve_module(&mut self, name: &str)
        -> Option<Result<String, BopError>>
    { (self.resolve)(name) }
}

fn main() {
    let source = r#"
        use stats_helpers
        for n in [10, 20, 30, 40, 50] {
            add_data(n)
        }
        let avg = average()
        let mid = median([10, 20, 30, 40, 50])
        print("Average: " + avg.to_str() + ", median: " + mid.to_str())
    "#;

    let mut host = AppHost::new();
    match bop::run(source, &mut host, &BopLimits::standard()) {
        Ok(()) => {
            for line in &host.output { println!("{}", line); }
        }
        Err(e) => eprintln!("{}", e.render(source)),
    }
}