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

Welcome to Bop

Bop is a small, dynamically-typed programming language built to be embedded inside other Rust programs and run untrusted code safely. It ships with zero runtime dependencies, three interchangeable execution engines, first-class no_std and wasm support, and a resource-bounded sandbox that treats step count, memory, and call depth as first-class invariants — not features bolted on after the fact.

If you’re here to embed Bop in a host application, jump to Embedding Bop and start there. The rest of this book is the language guide and reference.

Bop can be compiled to Rust as well (without the sandbox) if you’re not running untrusted code and need more performance. Or, you could just use Rust ;)

Quick example

// Sum the numbers from 1 to 10
let total = 0
for i in range(1, 11) {
  total += i
}
print("Sum: {total}")

That’s a complete Bop program — no imports, no boilerplate, no semicolons. The syntax is intentionally familiar: curly braces, let, fn, pattern matching, modules. Skills transfer directly to Rust, JavaScript, Python.

Why Bop?

  • Embedded-first design. The entire language core lives in one crate (bop-lang) with no runtime deps and a minimal API surface. Host interaction goes through a single BopHost trait — you wire up exactly the functions and state you want exposed; Bop can’t reach anything else.

  • Sandboxed by default. No filesystem, no network, no clock, no ambient I/O of any kind. Every side-effecting operation is host-mediated. The sandbox also caps three things the language itself can’t escape: steps executed (against runaway loops), bytes allocated (against memory bombs), and fn-call depth (against deep recursion). See Error Handling → Fatal vs non-fatal.

  • Three engines, one language. Same parser, same semantics, pick the right executor per workload:

    • Tree-walker — fast to start, great diagnostics, zero build step. Ideal for short-lived scripts and REPLs.
    • Bytecode VM — compiles once, runs many times. Best for programs that loop or re-enter a hot path.
    • AOT Rust transpiler — emits plain Rust source that links against bop-lang’s runtime. Closest to native speed; useful when you want compiled artifacts or you’re already shipping a Rust build pipeline.

    All three are wire-compatible on Value and BopError, and the test suite pins them to byte-for-byte output agreement via a three-way differential harness.

  • no_std and wasm. The core crate compiles unchanged for wasm32-unknown-unknown and bare-metal targets. Enable the no_std feature for a libm-backed math facade; the rest of the language is already #![cfg_attr(no_std)].

  • Friendly errors. Parse and runtime errors both include the source snippet, a carat under the offending column, and a hint: line when the parser or runtime can guess what you meant ("I don't know what 'pritn' is — did you mean 'print'?"). Designed to be read by humans and by automated callers that need to correct themselves.

  • Small, stable grammar. Variables, loops, functions, arrays, dicts, structs, enums, pattern matching, modules, Result / Iter built-ins — that’s close to the whole surface. Everything else (math, JSON, iteration helpers, string utilities, test assertions) lives in the bundled std.* modules or as methods on the value types. The shape is deliberately small so it stays consistent across versions.

Where to start

  • Embedding Bop in a hostEmbedding Bop walks through the BopHost trait, resource limits, module resolution, and picking an engine.
  • Learning the language — start with the Language Guide and work through BasicsControl FlowDataFunctionsModulesError Handling.
  • Looking up a specific thing — the Reference covers Operators, Built-in Functions, Methods, and the Grammar. The Standard Library section documents every std.* module.
  • Trying it interactivelybop repl opens a persistent REPL with multi-line input, history, and tab completion.

Syntax

Bop’s syntax is deliberately simple. If you’ve seen Python or a C-family language, most of it will look familiar — curly braces for blocks, # for comments, newlines (rather than semicolons) terminating statements.

Blocks

Code blocks use curly braces { } and only appear after control-flow or declaration keywords (if, else, while, for, repeat, fn, struct, enum, match):

if count > 3 {
  print("That's a lot!")
}

Important: The opening { must be on the same line as its keyword. Bop automatically inserts semicolons at the end of lines, so putting { on the next line would cause a parse error.

// Good
if count > 3 {
  print("Nice!")
}

// Bad — will cause an error
if count > 3
{
  print("Nice!")
}

Statements

Statements end with a newline. Bop automatically inserts semicolons after lines ending in:

  • An identifier or literal
  • true, false, none
  • break, continue, return
  • ), ], }

You can put multiple statements on one line with an explicit semicolon:

let x = 1; let y = 2

Comments

Line comments start with //. Everything after // on that line is ignored:

// This is a comment
let x = 5   // So is this

There is no block-comment syntax. # is not a comment leader — a stray # produces a lexer error.

Identifiers

Variable and function names start with a letter or underscore and can contain letters, digits, and underscores:

let my_var = 5
let _count = 0
let item3 = "hello"

Case conventions are enforced

Bop checks the shape of every declared name at parse time and rejects mismatches with a suggestion:

DeclarationRequired shapeExamples
let, fn, parameters, fields, aliases, for-loop vars, match bindingsstarts with a lowercase letter or _let x, fn double(n), for i in …
constALL_CAPS (+ digits / _)const PI = 3.14, const MAX_SIZE = 100
struct, enum, variant namesstarts with an uppercase letterstruct Point, enum Shape { Circle, Rect }

Single-letter types like enum Dir { N, E, S, W } are fine — the rule is “starts with an uppercase letter”, not “must have a lowercase character somewhere.”

A leading underscore (_foo, _Internal, _DEBUG) marks a name as “private by convention” — glob use imports skip them. The wildcard _ on its own is used in let _ = foo() (explicitly ignore) and in patterns (match anything).

Whitespace

Spaces and tabs are insignificant — use whatever indentation style you like. Only newlines matter (they end statements).

Keywords

These words are reserved and can’t be used as identifiers:

let const fn return
if else while for in repeat break continue
use as struct enum match try
true false none

There are no word-spelled logical operators — use &&, ||, ! rather than and, or, not.

Types

Bop is dynamically typed — variables can hold any type, and types are checked at runtime. Every value has a .type() method that returns its type name:

.type() returnsDescriptionLiteral examples
"int"64-bit signed integer0, 42, -7
"number"64-bit floating point3.14, -0.5, 4.0
"string"UTF-8 text"hello", "got {n} items"
"bool"Booleantrue, false
"none"Absence of a valuenone
"array"Ordered, mutable collection[1, 2, 3], []
"dict"String-keyed map{"x": 10, "name": "Alice"}
"fn"First-class function / closurefn(x) { return x + 1 }
"struct"User-defined struct instancePoint { x: 3, y: 4 }
"enum"User-defined enum variantColor::Red
"module"Aliased module namespaceresult of use foo as m
"iter"Lazy iterator[1, 2, 3].iter(), "abc".iter()
let x = 42
print(x.type())      // "int"

let y = 3.14
print(y.type())      // "number"

let s = "hello"
print(s.type())      // "string"

Integers and floats

Integer literals (42, -7) produce int values; anything with a decimal point (3.14, 4.0) produces number. There’s no exponent-shaped literal — write the full decimal or build large values by multiplication. The two numeric types coexist: arithmetic widens to number on mixed operands, and == compares numerically across the split:

print(1 + 1)         // 2       (int + int → int)
print(1 + 1.0)       // 2       (int + number → number, prints as whole)
print(1 == 1.0)      // true    (cross-type numeric equality)

Use .to_int() to truncate a number to an integer (toward zero), and .to_float() to widen an int to a number:

print((3.7).to_int())      // 3
print((-2.7).to_int())     // -2
print((5).to_float())      // 5       (now a number internally)

Number literals need parens before a method call (otherwise 3.7.to_int() looks like a decimal followed by a field). Variables don’t: let x = 3.7; print(x.to_int()).

Division

/ always produces a number, even for int / int:

print(7 / 2)              // 3.5
print(6 / 2)              // 3        (whole value — still a number)
print((6 / 2).type())     // "number"

This sidesteps the classic “1 / 2 == 0” footgun that trips beginners in C / Rust / Java.

When you do want an integer result (index math, bucketing, etc.), coerce the quotient back with .to_int():

print((7 / 2).to_int())           // 3
print((-7 / 2).to_int())          // -3
print((7 / 2).to_int().type())    // "int"

.to_int() truncates toward zero. / raises a runtime error on division by zero.

Strings

Strings use double quotes only. Supported escape sequences: \", \\, \n, \t, \r, \{, \}.

let greeting = "Hello, world!"
let with_newline = "Line 1\nLine 2"

Strings are indexable and iterable, but immutable — you can read characters but not change them in place:

let s = "hello"
print(s[0])          // "h"
print(s[-1])         // "o"

for ch in s {
  print(ch)          // "h", "e", "l", "l", "o"
}

String interpolation

Use {variable} inside a string to insert a variable’s value. Only variable names are allowed inside {} — not expressions:

let name = "Alice"
let count = 5
print("Hello, {name}! You have {count} items.")

For computed values, store the result in a variable first:

let doubled = count * 2
print("Double: {doubled}")

// Or use concatenation:
print("Double: " + (count * 2).to_str())

To include a literal { or } in a string, escape it with a backslash:

print("Use \{name\} for interpolation")
// prints: Use {name} for interpolation

String concatenation

+ joins two strings, or a string and a number (the number is converted first):

print("Score: " + (42).to_str())    // "Score: 42"
print("n=" + 7)                      // "n=7"   (int auto-stringified)

Booleans

true and false. Used in conditions and comparisons:

let found = true

if found {
  print("Got it!")
}

None

none represents the absence of a value. Functions that don’t explicitly return a value return none. It’s also what you get from any operation designed to signal “no value here” (e.g. first_or_none helpers, optional return values):

fn first_or_none(arr) {
  if arr.len() == 0 { return none }
  return arr[0]
}

let r = first_or_none([])
print(r)           // none

Checking for none

Two equivalent ways:

if r.is_none() { print("empty") }
if r == none    { print("empty") }      // same thing

if r.is_some() { print("got one") }     // inverse — every non-none value is "some"

.is_none() / .is_some() are universal methods — they work on any value, not just optional-shaped ones. In a dynamically-typed language every variable can hold none, so the check is always available.

Don’t confuse falsy-ness with none-ness: false, 0, "", [], and {} are all falsy in if conditions but they’re not none. none.is_none() is true; false.is_none() is false.

User-defined types

Bop also lets you declare your own struct and enum types with methods — see Structs & Enums.

Variables

Variables store values that you can use and change throughout your program.

Declaring variables

Use let to create a new variable:

let x = 5
let name = "Alice"
let found = true
let items = [1, 2, 3]
let config = {"width": 10, "height": 5}

let is required the first time — using an undeclared variable is an error. This catches typos early:

let count = 5
conut = 10    // Error: I don't know what 'conut' is — did you mean 'count'?

Constants

Use const for values that won’t change:

const PI = 3.14
const MAX_SIZE = 100

Reassigning a constant is rejected at parse time — the compiler sees that the left-hand side is an all-caps identifier and refuses:

const MAX_SIZE = 100
MAX_SIZE = 200      // Error: can't reassign a constant

Constants must be all-caps (with digits / underscores allowed). const Pi = 3.14 is rejected: the parser will suggest const PI = 3.14 instead.

Reassignment

After declaration, reassign with just =:

let score = 0
score = 10
score += 5     // score is now 15

Compound assignment operators: +=, -=, *=, /=, %=.

let x = 10
x += 3    // x = x + 3 → 13
x -= 1    // x = x - 1 → 12
x *= 2    // x = x * 2 → 24

Name shapes are checked

Bop enforces case conventions at declaration sites so intent is visible at a glance:

DeclarationRequired shape
let x, fn foo(param), struct fields, match bindings, for variables, aliasesstarts with lowercase or _
const FOOall caps (+ digits / _)
struct Point, enum Shape, enum variantsstarts with uppercase

Mis-shaped declarations parse-error with a suggestion:

let Count = 5        // Error: names bound by `let` start with a lowercase letter. Try `count`?
const pi = 3.14      // Error: `const` names are SCREAMING_SNAKE_CASE. Try `PI`?
struct point {}      // Error: type names start with an uppercase letter. Try `Point`?

A leading underscore marks a name as “private by convention.” It doesn’t change the shape check — _count is still a lowercase-starting name — but glob use imports skip names that start with _ (see Modules).

Block scoping

Variables are block-scoped — a variable declared inside { } is not visible outside:

let x = 1
if true {
  let y = 2       // y only exists inside this block
  print(y)        // 2
}
// print(y)       // Error: I don't know what 'y' is

Shadowing

You can re-declare a variable with let in an inner block. The inner variable “shadows” the outer one:

let x = 1
if true {
  let x = 2     // shadows outer x
  x = 3         // reassigns inner x
  print(x)      // 3
}
print(x)         // 1 — outer x is unchanged

Copying and passing values

Every value in Bop is a copy. When you assign a variable, pass an argument to a function, or return a value, you get an independent copy — even for arrays, dicts, and structs. Changing the copy never affects the original.

Assignment copies

let a = [1, 2, 3]
let b = a           // b is a separate copy
b.push(4)
print(a)            // [1, 2, 3] — unchanged
print(b)            // [1, 2, 3, 4]

Function arguments are copies

When you pass a value to a function, the function gets its own copy. Modifying it inside the function has no effect on the caller’s variable:

fn try_to_modify(items) {
  items.push(99)
  print(items)       // [1, 2, 3, 99]
}

let original = [1, 2, 3]
try_to_modify(original)
print(original)      // [1, 2, 3] — unchanged

To get a modified value out of a function, return it:

fn add_item(items, val) {
  items.push(val)
  return items
}

let original = [1, 2, 3]
original = add_item(original, 99)
print(original)      // [1, 2, 3, 99]

This applies to every value type — numbers, strings, bools, arrays, dicts, structs, enum variants. Closures (Value::Fn) and modules (Value::Module) are reference-counted, so passing one of those around is cheap and the shared state is visible from both handles.

Dynamic typing

Variables can hold any type. You can even change the type of a variable by reassigning it:

let val = 42
print(val.type())   // "int"

val = "hello"
print(val.type())   // "string"

This flexibility is useful but can be surprising — the error only surfaces when some later operation expects the original type. Case conventions help: a count-like variable holding a string usually means the wrong thing landed in it upstream.

if / else

The if statement lets your program make decisions based on conditions.

Basic if

if count > 3 {
  print("That's a lot!")
}

The condition does not need parentheses, but they’re allowed: if (x > 3) { ... }. Braces are always required.

if / else

if temperature > 30 {
  print("It's hot!")
} else {
  print("Not too bad.")
}

if / else if / else

Chain multiple conditions with else if:

if score > 90 {
  print("Excellent!")
} else if score > 70 {
  print("Good job!")
} else if score > 50 {
  print("Not bad!")
} else {
  print("Keep trying!")
}

Only the first matching branch runs. If none match and there’s an else, that branch runs.

if as an expression

if/else can produce a value when used in expression position (e.g., after =):

let label = if count > 3 { "lots" } else { "few" }
print("You have {label} of items")

When used as an expression, both if and else branches are required. The last expression in each branch is the value.

let message = if x > 0 {
  "positive"
} else {
  "non-positive"
}
print(message)

Common patterns

Guard clause

fn process(value) {
  if value == none {
    return
  }
  print("Processing: " + value.to_str())
}

Classify a value

fn classify(n) {
  if n > 0 {
    return "positive"
  } else if n < 0 {
    return "negative"
  } else {
    return "zero"
  }
}

Combine conditions with && and ||

if age >= 18 && has_ticket {
  print("Welcome in!")
}

if x < 0 || x > 100 {
  print("Out of range!")
}

Loops

Loops let you repeat actions. Bop has three kinds: repeat, while, and for...in.

repeat

The simplest loop — just “do this N times.” No loop variable, no fuss:

repeat 4 {
  print("Hello!")
}

The count can be any expression:

let times = 3
repeat times {
  print("Again!")
}

repeat is perfect when you just need repetition without tracking a counter.

while

Loops as long as a condition is true:

let n = 1
while n <= 100 {
  n *= 2
}
print(n)    // 128

A while true loop runs forever (until you break out of it or hit the step limit):

let total = 0
let i = 1
while true {
  total += i
  if total > 100 {
    break
  }
  i += 1
}
print("Sum exceeded 100 at i=" + i.to_str())

Counting example

// Count how many numbers under 50 are divisible by 7
let count = 0
let n = 1
while n < 50 {
  if n % 7 == 0 {
    count += 1
  }
  n += 1
}
print("Found " + count.to_str())

for…in

Iterates over ranges, arrays, or dictionary keys.

Ranges

for i in range(5) {
  print(i.to_str())     // 0, 1, 2, 3, 4
}

With a start value:

for i in range(2, 8) {
  print(i.to_str())     // 2, 3, 4, 5, 6, 7
}

Arrays

let fruits = ["apple", "banana", "cherry"]
for fruit in fruits {
  print(fruit)
}

Dictionary keys

let scores = {"Alice": 95, "Bob": 87, "Charlie": 92}
for name in scores {
  let s = scores[name].to_str()
  print(name + ": " + s)
}

Iterators and user-defined containers

for x in v works on anything that participates in the iterator protocol: arrays, strings, dicts, explicit iterators (arr.iter()), and user types that implement .iter():

struct Bag { items }
fn bag_of(arr) { return Bag { items: arr } }
fn Bag.iter(self) { return self.items.iter() }

let b = bag_of([10, 20, 30])
for v in b { print(v) }         // 10  20  30

That’s the structural-typing story: no trait declaration, no ceremony — if v.iter() returns something iterable, for x in v just works.

Strings

You can iterate over the characters of a string:

let word = "hello"
for ch in word {
  print(ch)    // "h", "e", "l", "l", "o"
}

Nesting loops

Loops can be nested. This is useful for working with grids or combinations:

for row in range(3) {
  for col in range(4) {
    print("(" + row.to_str() + ", " + col.to_str() + ")")
  }
}

break & continue

break and continue give you finer control over loops.

break

Exits the loop immediately. Execution continues after the loop:

let i = 0
while true {
  if i >= 10 {
    break
  }
  i += 1
}
print("Stopped at " + i.to_str())

Searching for something

let numbers = [4, 8, 15, 16, 23, 42]
let found = false
for n in numbers {
  if n > 20 {
    found = true
    break
  }
}

if found {
  print("Found a number greater than 20!")
} else {
  print("No number greater than 20.")
}

continue

Skips the rest of the current iteration and jumps to the next one:

for i in range(10) {
  if i % 2 == 0 {
    continue
  }
  print(i.to_str())    // 1, 3, 5, 7, 9
}

Filter and process

let words = ["hello", "", "world", "", "bop"]
for word in words {
  if word == "" {
    continue
  }
  print(word.upper())
}

Which loops support break and continue?

All three loop types — while, for...in, and repeat — support both break and continue.

repeat 10 {
  let n = rand(100)
  if n < 10 {
    break
  }
  print(n.to_str())
}

Note: break and continue only affect the innermost loop. If you have nested loops and want to exit the outer one, use a variable flag or a function with return.

Pattern Matching

match is an expression: it evaluates a value and runs the first arm whose pattern matches, binding any captured names along the way.

let shape = Shape::Circle(3)

let label = match shape {
  Shape::Circle(r)          => "circle with radius {r}",
  Shape::Rectangle { w, h } => "rectangle {w}x{h}",
  Shape::Empty              => "empty",
}
print(label)

Arms are tried top to bottom; the first matching arm wins. A match with no matching arm at runtime raises an error — the checker warns at parse time when an enum match misses variants (see Exhaustiveness).

Patterns

Literal

Matches a specific value:

match n {
  0    => "zero",
  1    => "one",
  42   => "the answer",
  _    => "something else",
}

Literal patterns use the same cross-type numeric rule as ==1 matches both Int(1) and Number(1.0).

Wildcard _

Matches anything, binds nothing. Typical “default” arm.

Bindings

A lowercase identifier captures the scrutinee value under that name for the arm’s guard and body:

match request {
  request => handle(request),   // `request` is bound here
}

Inside nested patterns, bindings capture the piece they sit at:

match pair {
  [first, second] => first + second,
}

Struct patterns

Match a user struct with an exact type identity. Each field pattern runs against the corresponding field’s value:

struct Point { x, y }
let p = Point { x: 3, y: 4 }

match p {
  Point { x: 0, y: 0 } => "origin",
  Point { x, y }       => "at ({x}, {y})",
}

Field patterns can be bindings (x), literals (x: 0), or any nested pattern.

Enum variant patterns

match shape {
  Shape::Circle(r)              => r * r,
  Shape::Rectangle { w, h }     => w * h,
  Shape::Empty                  => 0,
}

Unit, tuple, and struct variants all work. Like struct patterns, field / tuple entries can themselves be patterns.

Namespaced patterns

Types imported through an aliased use must be matched through the same namespace:

use paint as p

match c {
  p.Color::Red   => "stop",
  p.Color::Green => "go",
  _              => "?",
}

The matcher compares the value’s full (module_path, type_name) identity against what the alias resolves to — p.Color::Red only matches values that came from the paint module.

Array patterns

match items {
  []           => "empty",
  [only]       => "one: {only}",
  [a, b]       => "two: {a} and {b}",
  [head, ..]   => "starts with {head}",
  [head, ..tail] => "{head} then {tail}",
}
  • [..] — matches any array, captures nothing.
  • [..name] — captures the trailing elements as an array.
  • Patterns before the rest must all match; the rest is optional.

Or-patterns

p1 | p2 | p3 — matches if any of the alternatives matches. Each alternative must bind the same set of names so the arm body has a consistent view:

match day {
  "Sat" | "Sun" => "weekend",
  _             => "weekday",
}

Guards

An arm can add a boolean guard after if. The arm only fires when the pattern matches and the guard is true:

match n {
  x if x < 0  => "negative",
  0           => "zero",
  x if x < 10 => "small positive",
  _           => "big positive",
}

Guards can see any names the pattern bound.

As an expression

match is an expression — every arm’s body is an expression, and the whole thing evaluates to the winning arm’s body:

let grade = match score {
  s if s >= 90 => "A",
  s if s >= 80 => "B",
  s if s >= 70 => "C",
  _            => "F",
}

All arms should produce compatible types if you rely on the result — Bop is dynamically typed, so heterogeneous arms aren’t a parse error, but they usually signal a bug.

Exhaustiveness

Bop’s static checker warns when a match over an enum misses variants:

enum Color { Red, Green, Blue }

let _ = match Color::Red {
  Color::Red   => "r",
  Color::Green => "g",
  // warning: missing variant `Color::Blue`
}

A wildcard or a bare-name catch-all (_ or other) marks the match as exhaustive. Guards don’t count toward coverage — a guarded arm covers only the guarded subset, so a partially-guarded match still needs a catch-all.

The checker follows use statements when the embedder supplies a module resolver, so imported enums aren’t opaque — missing-variant warnings fire on them too.

Arrays

Arrays are ordered, mutable collections that can hold any mix of types.

Creating arrays

let items = [1, 2, 3]
let empty = []
let mixed = [1, "two", true, none]

Accessing elements

Arrays are 0-indexed. Negative indices count from the end:

let items = [10, 20, 30]
print(items[0])     // 10
print(items[2])     // 30
print(items[-1])    // 30 (last element)
print(items[-2])    // 20

Out-of-bounds access produces an error.

Modifying elements

let items = [10, 20, 30]
items[0] = 99
print(items)    // [99, 20, 30]

Methods

MethodReturnsDescription
arr.len()intNumber of elements
arr.push(val)noneAppend to end
arr.pop()valueRemove and return last element
arr.has(val)boolWhether the array contains the value
arr.index_of(val)intIndex of first occurrence, or -1
arr.insert(i, val)noneInsert at index, shifting elements right
arr.remove(i)valueRemove at index, shifting elements left
arr.slice(start, end)arrayNew sub-array
arr.reverse()noneReverse in place
arr.sort()noneSort in place
arr.join(sep)stringJoin elements into a string

Plus the universal arr.type(), arr.to_str(), arr.inspect().

Practical examples

Building a list

let squares = []
for i in range(1, 6) {
  squares.push(i * i)
}
print(squares)    // [1, 4, 9, 16, 25]

Filtering values

let numbers = [3, 7, 1, 9, 4, 6, 2, 8]
let big = []
for n in numbers {
  if n > 5 {
    big.push(n)
  }
}
print(big)    // [7, 9, 6, 8]

Checking membership

let allowed = ["admin", "editor", "viewer"]
let role = "editor"
if allowed.has(role) {
  print("Access granted")
} else {
  print("Access denied")
}

Sorting and joining

let scores = [42, 17, 85, 3]
scores.sort()
print(scores)    // [3, 17, 42, 85]

let names = ["Charlie", "Alice", "Bob"]
names.sort()
print(names.join(", "))    // "Alice, Bob, Charlie"

Strings

Strings are immutable sequences of characters. All string methods return new strings — the original is never modified.

Creating strings

let s = "hello world"
let empty = ""
let escaped = "Line 1\nLine 2"

Supported escape sequences: \", \\, \n, \t, \r, \{, \}. Any other \x escape is a lexer error.

Indexing

let s = "hello"
print(s[0])      // "h"
print(s[-1])     // "o"

Each index returns a single-character string (there’s no separate character type).

String interpolation

Insert variable values with {name} inside a string:

let name = "Alice"
let count = 5
print("Hello, {name}! You have {count} items.")

Only variable names are allowed inside {}. For expressions, use a temporary variable:

let total = (count * 2).to_str()
print("Double: {total}")

Or use concatenation:

print("Double: " + (count * 2).to_str())

To include a literal { or } in a string, escape it with \{ and \}:

print("Use \{name\} for interpolation")
// prints: Use {name} for interpolation

Concatenation

Use + to join strings:

let full = "Hello" + ", " + "world!"
print(full)    // "Hello, world!"

Numbers must be converted with .to_str() first:

let msg = "Score: " + (42).to_str()

Methods

MethodReturnsDescription
s.len()intNumber of characters
s.contains(sub)boolWhether the string contains sub
s.starts_with(prefix)boolWhether it starts with prefix
s.ends_with(suffix)boolWhether it ends with suffix
s.index_of(sub)intIndex of first occurrence, or -1
s.split(sep)arraySplit into array of strings on sep
s.replace(old, new)stringReplace all occurrences
s.upper()stringUppercase copy
s.lower()stringLowercase copy
s.trim()stringCopy with leading/trailing whitespace removed
s.slice(start, end)stringSubstring
s.to_int()intParse. "3.7".to_int()3 (float-then-truncate). Raises on junk.
s.to_float()numberParse. Raises on junk.

Plus the universal s.type(), s.to_str(), s.inspect().

Practical examples

Parsing CSV data

let input = "Alice,95,A"
let parts = input.split(",")
print(parts[0])    // "Alice"
print(parts[1])    // "95"

Checking prefixes

let filename = "report.csv"
if filename.ends_with(".csv") {
  print("CSV file detected")
}

Building a formatted string

let items = ["apple", "banana", "cherry"]
let count = items.len().to_str()
let list = items.join(", ")
print("Found {count} items: {list}")

Dictionaries

Dictionaries (dicts) are key-value stores. Keys are always strings; values can be any type.

Creating dictionaries

let person = {"name": "Alice", "age": 30, "active": true}
let empty = {}

Accessing values

Use bracket notation with a string key:

let name = person["name"]     // "Alice"
let age = person["age"]       // 30

Accessing a missing key returns none (no error):

let email = person["email"]
print(email)    // none

if email.is_none() { print("no email on file") }

Two caveats:

  • A key whose value is explicitly none is presentd.has(k) returns true for it, even though d[k] and d["absent_key"] are both none. Use d.has(k) when you need to distinguish “unset” from “set to none”.
  • If you want a read to fail on a missing key, check d.has(key) first and raise explicitly — d[key] itself always succeeds.

Modifying values

person["age"] = 31             // update existing key
person["email"] = "a@b.com"   // add new entry

Methods

MethodReturnsDescription
d.len()intNumber of entries
d.keys()arrayArray of all keys
d.values()arrayArray of all values
d.has(key)boolWhether the key exists

Plus the universal d.type(), d.to_str(), d.inspect().

Practical examples

Counting occurrences

let words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
let counts = {}
for word in words {
  if counts.has(word) {
    counts[word] += 1
  } else {
    counts[word] = 1
  }
}

for key in counts {
  print(key + ": " + counts[key].to_str())
}

Storing structured data

let point = {"x": 10, "y": 20}
let x = point["x"].to_str()
let y = point["y"].to_str()
print("Position: ({x}, {y})")

Iterating over entries

let config = {"width": 800, "height": 600, "title": "My App"}
for key in config {
  let val = config[key].to_str()
  print(key + ": " + val)
}

Checking for a key before using it

let settings = {"volume": 80}

if settings.has("volume") {
  let v = settings["volume"].to_str()
  print("Volume is {v}")
} else {
  print("Using default volume")
}

Structs & Enums

Bop supports user-defined struct and enum types, with methods attached via fn Type.method(self, ...). They give you type names in error messages, structural pattern matching, and an identity-aware equality.

Structs

A struct is a named record — a set of fields in a declared order. Field names are the part that matters; types aren’t declared.

struct Point { x, y }
struct Player { name, hp, inventory }

Create a value with TypeName { field: value, ... }:

let p = Point { x: 3, y: 4 }
print(p)                  // Point { x: 3, y: 4 }
print(p.x)                // 3
print(p.type())           // "struct"

Construction is strict: fields you provide must match the declaration exactly (no unknown fields, no duplicates, no missing ones). Extra fields or typos become parse or runtime errors with a “did you mean?” suggestion.

Field access and assignment

Read with .field, write with .field = value or any compound assignment:

let c = Counter { n: 10 }
c.n += 5                 // works
c.n *= 2                 // works
print(c.n)               // 30

The field has to already exist; assigning to an undeclared field is an error.

Passing structs

Structs follow Bop’s copy-by-value rule: passing one to a function, returning it, or assigning it to another variable makes an independent copy. Mutating the copy leaves the original alone.

fn grow(p) { p.x += 10; return p }

let a = Point { x: 1, y: 2 }
let b = grow(a)
print(a)                 // Point { x: 1, y: 2 }
print(b)                 // Point { x: 11, y: 2 }

Enums

An enum is a tagged union — one of several named variants, each with an optional payload:

enum Shape {
  Circle(r),
  Rectangle { w, h },
  Empty,
}

Variants come in three shapes:

ShapeDeclarationConstruction
UnitEmptyShape::Empty
TupleCircle(r)Shape::Circle(5)
StructRectangle { w, h }Shape::Rectangle { w: 4, h: 3 }
let a = Shape::Circle(5)
let b = Shape::Rectangle { w: 4, h: 3 }
let c = Shape::Empty
print(a.type())          // "enum"

Variants with a struct payload expose their fields via .field just like structs:

let r = Shape::Rectangle { w: 4, h: 3 }
print(r.w * r.h)         // 12

Short-name variants like enum Dir { N, E, S, W } are accepted — the case rule is “starts with an uppercase letter”, not “must contain a lowercase”.

Methods

Attach a method to a type with fn Type.method(self, ...) { ... }. The receiver arrives as the first parameter (called self by convention; any name works).

struct Point { x, y }

fn Point.sum(self)      { return self.x + self.y }
fn Point.moved(self, dx, dy) {
  return Point { x: self.x + dx, y: self.y + dy }
}

let p = Point { x: 3, y: 4 }
print(p.sum())                   // 7
print(p.moved(1, 1))             // Point { x: 4, y: 5 }

For enums, methods dispatch on the enum type — not per-variant:

enum Shape { Circle(r), Rectangle { w, h } }

fn Shape.area(self) {
  return match self {
    Shape::Circle(r)          => 3.14159 * r * r,
    Shape::Rectangle { w, h } => w * h,
  }
}

print(Shape::Circle(3).area())                  // 28.27431
print(Shape::Rectangle { w: 4, h: 3 }.area())   // 12

A user-declared method with the same name as a builtin (len, keys, etc.) wins over the builtin for receivers of that type — the precedence matches the walker, VM, and AOT.

Methods must live in the type’s own module

Methods can only be declared on structs and enums you own — that is, types declared in the same module as the method. Concretely:

  • You cannot extend a type imported from another module with new methods. If paint declares struct Color { ... }, then fn Color.brighten(self) must also live in paint, not in a consumer.
  • You cannot add methods to the built-in types (int, number, string, bool, array, dict, fn, module, iter) or to the engine-registered types Result, RuntimeError, Iter.

Declarations that violate this rule parse fine but never dispatch — the method registers against (your_module, TypeName), while values of that type carry their original home module in their identity, so lookups miss. If you catch yourself wanting to “just add a helper to Array” or “extend Result with a domain combinator,” write a free function that takes the value as an argument instead:

// Not this (declared in some consumer module):
//   fn Result.tag(self, label) { ... }   // ghost method — never fires
// Do this:
fn tag(r, label) {
  return match r {
    Ok(v)  => "{label}: ok {v}",
    Err(e) => "{label}: err {e}",
  }
}

This is the same discipline Go enforces: a type’s behaviour lives where the type is declared. It keeps dispatch coherent (no surprising overrides, no load-order dependence) at the cost of the extensions you’d get in Swift / Kotlin / Ruby.

Methods return a new value

Because values are copy-by-value, a method can’t mutate the receiver in place. Return a new instance and reassign:

fn Point.shift(self, dx, dy) {
  return Point { x: self.x + dx, y: self.y + dy }
}

let p = Point { x: 0, y: 0 }
p = p.shift(3, 4)        // p is now Point { x: 3, y: 4 }

This plays well with fluent chains:

let final = Point { x: 0, y: 0 }
  .shift(1, 0)
  .shift(0, 2)
  .shift(5, 5)

Equality

Two struct or enum values are equal when their full type identity — the module they were declared in plus the type name — and every payload matches structurally:

let p = Point { x: 1, y: 2 }
let q = Point { x: 1, y: 2 }
print(p == q)            // true (structural)

let r = Point { x: 1, y: 3 }
print(p == r)            // false (field differs)

Two types with the same name declared in different modules are distinct — see Modules.

Redeclaring the same shape is fine

Declaring the exact same struct or enum twice inside one module is a no-op — matches the “idempotent re-import” rule use already follows. Declaring two different shapes with the same name in the same module is a hard error.

Defining Functions

Functions let you name a sequence of actions and reuse it. Bop has first-class functions — you can store them in variables, pass them to other functions, return them from other functions, and stash them in arrays.

Declaring a function

fn greet() {
  print("Hello!")
}

greet()    // "Hello!"
greet()    // "Hello!"

Parameters

Functions can take parameters — values you pass in when calling:

fn repeat_string(text, times) {
  let result = ""
  repeat times {
    result += text
  }
  return result
}

print(repeat_string("ha", 3))    // "hahaha"

Parameters are positional. There are no default values or type annotations.

Return values

Use return to send a value back from the function:

fn double(x) {
  return x * 2
}

let result = double(5)
print(result)    // 10

return with no value (or reaching the end of the function) returns none:

fn do_something() {
  print("Working...")
  // no return — returns none
}

let result = do_something()
print(result)    // none

Early return

return exits the function immediately, even from inside loops or conditionals:

fn find_first_big(numbers, threshold) {
  for n in numbers {
    if n > threshold {
      return n
    }
  }
  return none
}

let result = find_first_big([3, 7, 1, 15, 4], 10)
print(result)    // 15

Calling functions

Parentheses are always required, even with no arguments:

greet()          // correct
// greet         // error — 'greet' is a function, call it with greet()

Practical example: sum of squares

fn sum_of_squares(n) {
  let total = 0
  for i in range(1, n + 1) {
    total += i * i
  }
  return total
}

let result = sum_of_squares(5)
print("Sum of squares: {result}")    // Sum of squares: 55

Recursion

Functions can call themselves. Bop caps recursion depth to prevent runaway stacks (also bounded by the step limit):

fn factorial(n) {
  if n <= 1 {
    return 1
  }
  return n * factorial(n - 1)
}

print(factorial(5))    // 120

First-class functions

A named fn is a value just like anything else — you can assign it to a variable, pass it as an argument, return it from another function, or store it in a collection:

fn double(x) { return x * 2 }
let f = double
print(f(7))          // 14

fn apply(f, x) { return f(x) }
print(apply(double, 21))    // 42

Function expressions (lambdas)

fn(...) { ... } — without a name — is an expression that produces a function value. Use it when you want a one-off function inline:

let square = fn(x) { return x * x }
print(square(6))     // 36

let mul = fn(a, b) { return a * b }
print([mul(2, 3), mul(4, 5)])    // [6, 20]

Closures

Function expressions capture variables from the enclosing scope. The capture is a snapshot taken at the moment the closure is built — mutating the outer variable afterwards doesn’t change what the closure sees:

let n = 5
let add_n = fn(x) { return x + n }
n = 100
print(add_n(3))      // 8, not 103

The classic “factory returning a specialised function” pattern works:

fn make_adder(n) {
  return fn(x) { return x + n }
}

let add5 = make_adder(5)
let add10 = make_adder(10)
print(add5(3))       // 8
print(add10(3))      // 13

Recursion in lambdas

An anonymous fn(...) can’t see itself by name. If a lambda needs to recurse, assign it to a named fn instead — named fns are visible inside their own body:

fn fib(n) {
  if n < 2 { return n }
  return fib(n - 1) + fib(n - 2)
}
print(fib(10))       // 55

Modules

A Bop program can be split across multiple files — or in-memory source strings, asset bundles, anywhere the embedding host can return Bop source. The use statement pulls another module’s public surface into the current scope.

The four forms of use

use path                    // glob:        everything public
use path.{a, b, Type}       // selective:   just the listed items
use path as m               // aliased:     binds `m` as a Value::Module
use path.{a, b} as m        // aliased + selective

Paths are dot-joined identifiers: std.math, game.entity.player. How the host resolves a path is up to the embedder — bop-sys’s StandardHost::with_module_root maps foo.bar to <root>/foo/bar.bop, in-memory hosts can look up a string table, a web host can fetch a URL. See Embedding.

Glob use

Brings every public export of a module into the current scope as a bare name:

use std.math
print(PI)            // constant from std.math
print(factorial(5))  // fn from std.math → 120

Names that start with _ are considered private by convention and glob imports skip them:

// In module `foo`:
fn _helper() { return 42 }
fn public() { return _helper() }

// Elsewhere:
use foo
print(public())      // 42
// print(_helper())  // error: `_helper` not in scope

Glob is idempotent at the injection site — running use foo twice in the same scope is a no-op (matches Python’s import foo; import foo). When two glob imports would introduce the same name, the first wins and the second emits a runtime warning — explicit selective imports are the way to disambiguate.

Selective use

Pick exactly which names you want:

use std.math.{PI, factorial}
print(PI)
print(factorial(4))
// print(clamp(1, 0, 10))   // error — not imported

Selective imports can reach private names explicitly:

use foo.{_helper}
print(_helper())     // ok — explicit opt-in

If a listed name doesn’t exist in the target module, you get a clear error pointing at the use site.

Aliased use

Binds the whole module as a single value under the alias:

use std.math as m
print(m.PI)
print(m.factorial(5))

m is a Value::Modulem.type() is "module". You access its exports via the . operator. Methods on aliased modules (m.helper(...)) work the same way they would on a bare imported fn.

Combine with selective to shrink the alias’s surface:

use std.math.{PI, factorial} as m
print(m.PI)
print(m.factorial(5))
// print(m.clamp(1, 0, 10))   // error — `clamp` wasn't imported

Namespaced types

User-defined struct and enum types can be constructed and pattern-matched through the alias:

// In `paint.bop`:
enum Color { Red, Green, Blue }
struct Point { x, y }

// In main:
use paint as p
let c = p.Color::Red
let origin = p.Point { x: 0, y: 0 }

print(match c {
  p.Color::Red   => "stop",
  p.Color::Green => "go",
  p.Color::Blue  => "cool",
})

The namespace is required — bare Color::Red inside the main file wouldn’t find the type unless you also imported paint.{Color} by bare name.

Type identity

Types carry their declaring module as part of their identity. Two modules can declare a type with the same name; values from them are distinct types — equality is always false across the module boundary, and patterns only match values from the module the pattern named.

// paint.bop: enum Color { Red, Blue }
// other.bop: enum Color { Red, Green, Yellow }

use paint as p
use other as o

let a = p.Color::Red
let b = o.Color::Red

print(a == b)        // false — different `Color` types
print(a == a)        // true

A pattern over an aliased module’s type only fires for values from that module:

fn label(c) {
  return match c {
    p.Color::Red => "paint-red",
    o.Color::Red => "other-red",
    _            => "something else",
  }
}
print(label(p.Color::Red))   // "paint-red"
print(label(o.Color::Red))   // "other-red"

This is Bop’s answer to the “same-named type, different shape, in different modules” problem. No renames required.

Re-exports are transitive

A module’s effective exports include everything it uses from other modules (minus privacy filtering). If a does use b and b declares fn foo(), then use a in the top-level program makes foo visible too. The same applies to types — importing a brings b’s public types in scope.

Builtin types

Result and RuntimeError are engine built-ins. They’re always in scope — you don’t need any use to write Result::Ok(v) or to match on RuntimeError { message, line }. The combinators (unwrap, map, and_then, …) live as methods on the Result type, also always available. See Error Handling.

Cycles

Circular imports (a uses b which uses a) are detected at load time and raise a clear error naming the cycle path. Restructure the code so the cycle breaks — usually by pulling shared definitions into a third module that neither circular node depends on.

Inside a function body

Aliased modules and bare-imported types remain visible inside function bodies declared in the same module:

use paint as p

fn describe(c) {
  return match c {
    p.Color::Red   => "red",
    p.Color::Blue  => "blue",
    _              => "other",
  }
}

The p alias doesn’t need to be a parameter — module-level aliases persist across function call boundaries so patterns inside fn bodies can resolve them.

Error Handling

Bop uses a Result-shaped value model for recoverable errors, with two language features that make it ergonomic:

  • try — unwrap an Ok(v) or propagate an Err(e) up to the enclosing function.
  • try_call(f) — run a zero-arg callable, catch any runtime error, and return the outcome as a Result.

Both Result and RuntimeError are engine built-ins — always in scope, no import required. The combinators (is_ok, unwrap, map, and_then, …) are methods on the Result type, also always available.

The Result type

enum Result {
  Ok(value),
  Err(error),
}

By convention, Ok(v) carries the successful value and Err(e) carries whatever describes the failure — a string, a struct, anything. Values from fallible operations are the typical shape:

fn parse_positive(s) {
  let n = s.to_int()
  if n <= 0 {
    return Err("must be positive, got {n}")
  }
  return Ok(n)
}

print(parse_positive("42"))    // Result::Ok(42)
print(parse_positive("-3"))    // Result::Err("must be positive, got -3")

Ok / Err shorthand

Ok(x) and Err(e) are parser-level sugar for Result::Ok(x) and Result::Err(e). The rewrite applies in both expression and pattern position, so you can write:

fn classify(n) {
  if n > 0 { return Ok(n) }
  return Err("non-positive")
}

print(match classify(5) {
  Ok(v)  => "ok: {v}",
  Err(e) => "err: {e}",
})
// ok: 5

Bop’s case rules already reserve uppercase identifiers for types and variants, so Ok and Err can’t collide with a user fn or variable. The long form (Result::Ok(v), Result::Err(e)) still works — pick whichever reads better.

If a different enum happens to have its own Ok / Err variants, use the qualified MyEnum::Ok(x) form for those. The bare sugar always means Result::Ok / Result::Err.

The try operator

try expr evaluates expr and:

  • If the result is Result::Ok(v), unwraps it to v.
  • If the result is Result::Err(e), immediately returns e from the enclosing function as-is (wrapped in the same Err variant the caller will see).
  • If the result is anything else (not Result-shaped), raises a runtime error.
fn pipeline(s) {
  let n = try parse_positive(s)        // Err propagates; Ok unwraps to `n`
  let doubled = try double_checked(n)
  return Ok(doubled)
}

print(pipeline("21"))    // Result::Ok(42)
print(pipeline("-3"))    // Result::Err("must be positive, got -3")

Because try propagates by returning from the enclosing function, it only works inside fn bodies. A top-level try that hits an Err raises a runtime error — wrap the call site in a fn.

Unit-Ok

try Result::Ok with no payload (or try Result::Ok where Ok is a unit variant) yields none. Mostly relevant for APIs where the success case carries no meaningful value.

try_call(f)

Catch runtime errors from a zero-arg callable. Returns Result::Ok(value) on success or Result::Err(RuntimeError { message, line }) on a caught error.

let r = try_call(fn() { return 1 / 0 })

print(match r {
  Result::Ok(v)                      => "got {v}",
  Result::Err(RuntimeError { message, line }) =>
    "failed at line {line}: {message}",
})
// failed at line 1: Division by zero

try_call is Bop’s answer to exception-like error handling without exceptions. It only catches non-fatal errors. Fatal conditions — step-budget exhaustion, memory-limit violation, host on_tick returning BopError::fatal — are not caught. That keeps the sandbox invariant intact: a runaway loop can’t wrap itself in try_call and keep going.

RuntimeError — the caught error shape

struct RuntimeError {
  message,   // string
  line,      // int — 1-indexed source line of the failing expression
}

You can construct one explicitly (it’s a regular struct), but most of the time you’ll see them as the payload inside Result::Err(...) returned from try_call.

Combinators — methods on Result

Every Result value has a small set of always-available methods. No import needed — Result is a built-in type and its combinators are engine-level methods.

print(Ok(1).is_ok())                     // true
print(Err("oops").is_err())              // true

// unwrap_or — default on Err
print(Ok(10).unwrap_or(0))               // 10
print(Err("fail").unwrap_or(0))          // 0

// map — transform the Ok payload, pass Err through
print(Ok(5).map(fn(n) { return n * n }))      // Result::Ok(25)
print(Err("x").map(fn(n) { return n * n }))   // Result::Err("x")

// and_then — monadic bind (for chaining fallible steps)
fn halve(x) {
  if x % 2 == 0 { return Ok((x / 2).to_int()) }
  return Err("odd")
}
print(Ok(8).and_then(halve).and_then(halve))   // Result::Ok(2)
print(Ok(7).and_then(halve))                    // Result::Err("odd")

Available: is_ok, is_err, unwrap, expect, unwrap_or, map, map_err, and_then. See Methods → Result for the full reference.

unwrap() and expect(msg) raise a runtime error on Err — use sparingly, and prefer try or pattern matching in production code.

When to use which

SituationUse
Writing a fallible functionReturn Result::Ok(v) / Result::Err(e)
Chaining several fallible callstry inside a fn, or r.and_then(f)
Running user-supplied code with a safety nettry_call(fn() { ... })
Handling every Err case explicitlymatch
You know it’s Ok and want the valuer.unwrap() / r.expect("...") (sparingly)
Supplying a default on Errr.unwrap_or(default)

Fatal vs non-fatal

  • Non-fatal (catchable by try_call): division by zero, “variable not found”, type mismatches, host-raised errors via BopError::runtime, wrong arg count, missing field, etc.
  • Fatal (not catchable): step-budget exceeded, memory-limit exceeded, fn-call-depth exceeded, host-raised BopError::fatal.

A script can observe whether an error was fatal by inspecting whether try_call caught it — fatal errors propagate past try_call to the host.

REPL

The bop CLI ships an interactive REPL that carries state across submissions, echoes bare-expression results, supports multi-line input, and persists history across sessions.

$ bop repl
> let x = 5
> let f = fn(n) { return n * x }
> f(7)
35
> :quit

Starting the REPL

bop            # default subcommand is `repl`
bop repl

Ctrl-D on an empty prompt exits. Ctrl-C clears the current line without touching the session.

What persists across submissions

Everything that shows up in the program’s scope:

  • let / const bindings.
  • fn declarations (named and anonymous-then-let-bound).
  • struct / enum / method declarations.
  • use imports and module aliases.
  • The rand() seed — same starting seed, same sequence (helpful for reproducing bugs).

Resource limits (BopLimits::standard() by default) reset per submission, so the step budget doesn’t accumulate across lines.

Bare expressions echo

When the last statement in a submission is a bare expression, the REPL prints its value:

> 1 + 2
3
> let x = 5      // `let` has no value — no echo
> x
5

print(...) returns none; the REPL suppresses none echoes so print(42) only shows 42 once (from the host), not “42” followed by “none”.

Multi-line input

When a line parses with “end of code” — unclosed brace, trailing +, unfinished match — the REPL keeps the buffer open and prompts for more. Paste a multi-line block and it runs as one submission:

> fn greet(name) {
...   return "hi " + name
... }
> greet("Bop")
hi Bop

A different parse error (typo, unexpected token) submits immediately so you can see the error rather than hunting through a stale buffer.

Tab completion

Hit Tab on an identifier prefix to see matches from:

  • Bop keywords (let, fn, match, use, …)
  • Built-in functions (print, len, range, int, float, sqrt, …)
  • Names currently in the session (let my_var = … shows up after declaration)
  • Identifiers the REPL has seen you type in previous submissions (covers fn parameters, struct field names)

Meta-commands

Lines starting with : are REPL commands, not Bop code:

CommandAction
:helpPrint the meta-command list
:varsList all currently-bound names (sorted)
:reset / :clearDrop every binding and start fresh
:quit / :q / :exitExit the REPL

Unknown commands surface a friendly “try :help” hint rather than being silently ignored.

History

Arrow keys browse history. ~/.bop_history ($USERPROFILE\.bop_history on Windows) persists history across sessions. History save on exit is best-effort — the REPL doesn’t error if it can’t write the file.

Error handling

Runtime and parse errors render with the same source-snippet + carat as errors from bop run:

> let f = fn(n) { return missing(n) }
> f(5)
error: I don't know what 'missing' is
  --> line 1:23
  |
1 | let f = fn(n) { return missing(n) }
  |                        ^
hint: Did you forget to create it with `let`?

The error doesn’t reset the session — subsequent submissions still see the prior bindings.

Piped / non-TTY input

When stdin isn’t a terminal (piped, heredoc, test harness), the REPL accumulates stdin line by line using the same incomplete-input heuristic, so a script like:

bop repl <<EOF
fn double(n) {
  return n + n
}
print(double(21))
EOF

prints 42 and exits 0. Errors during a piped session exit 1 but don’t abort the remaining input — if you pipe five submissions and #2 fails, #3 through #5 still run. That matches how a user would experience the same sequence interactively.

Using the session from Rust

bop::ReplSession is the same session type the CLI uses, exposed for embedders that want to drive Bop as a scripting layer from their own app. See Embedding → Stateful REPL sessions.

Operators

All Bop operators, grouped by category. No operator overloading — each operator works on specific types and produces a runtime error on type mismatch.

Arithmetic

OperatorNameOperandsNotes
+Addint, number, string, arrayString + anything concats after stringifying the other side. Array + array concatenates.
-Subtractint, numberAlso unary negation: -x
*Multiplyint, number, or string * int"ab" * 3"ababab"
/Divideint, numberAlways returns number: 7 / 23.5, 6 / 23 (as number)
%Moduloint, numberSame sign as the dividend
print(10 + 3)      // 13
print(10 - 3)      // 7
print(10 * 3)      // 30
print(10 / 3)      // 3.3333333333333335
print(10 % 3)      // 1

print("Hello" + " " + "world")    // "Hello world"
print([1, 2] + [3])                // [1, 2, 3]
print("ha" * 3)                    // "hahaha"

Integer results

There is no dedicated integer-division operator. / always widens to number, which avoids the classic “1 / 2 == 0” footgun. When you need an integer result, coerce the quotient with .to_int():

let mid = ((low + high) / 2).to_int()

.to_int() truncates toward zero; .abs() preserves the numeric type.

Overflow and divide-by-zero

  • int + int, int - int, int * int use checked arithmetic — overflow is a runtime error, not silent wrap.
  • Division / modulo by zero raises a runtime error.
  • int / number or number / int widens to number (IEEE-754 rules; division by 0.0 is still a runtime error in Bop).

Comparison

OperatorNameReturns
==Equalbool
!=Not equalbool
<Less thanbool
>Greater thanbool
<=Less or equalbool
>=Greater or equalbool

Equality (==, !=)

Works on every type. Arrays and dicts compare structurally (element-wise, then entry-wise). User-defined struct and enum values compare by full type identity (declaring module, type name) plus their payloads — two structs with the same name declared in different modules are not equal even with matching field values.

print(5 == 5)                   // true
print(5 == "5")                 // false    (different types)
print(1 == 1.0)                 // true     (int ↔ number cross-type)
print([1, 2] == [1, 2])         // true     (structural)
print({"a": 1} == {"a": 1})     // true

Ordering (<, >, <=, >=)

Numeric (int + number, with cross-type widening) and strings (lexicographic) only. Applying an ordering operator to anything else raises a runtime error.

print(3 < 5)                    // true
print(1 > 0.5)                  // true     (int > number)
print("abc" < "def")            // true     (lexicographic)
// print([1, 2] < [3])          // error    — can't use `<` with array

Boolean

OperatorNameNotes
&&AndShort-circuits
||OrShort-circuits
!NotUnary prefix

There are no word-spelled aliases — and, or, not are not keywords.

Short-circuiting means the second operand isn’t evaluated if the first determines the result:

// && stops at the first false
if x > 0 && x < 100 {
  print("In range")
}

// || stops at the first true
if name == "" || name == none {
  print("No name provided")
}

// ! inverts a boolean
if !found {
  print("Still searching...")
}

Truthiness

false and none are falsy; every other value (including 0, "", [], {}) is truthy.

Assignment

OperatorEquivalent to
=Assign
+=x = x + ...
-=x = x - ...
*=x = x * ...
/=x = x / ...
%=x = x % ...
let score = 0
score += 10    // 10
score -= 3     // 7
score *= 2     // 14

Assignment targets can be:

  • Bare identifiers: x = ...
  • Index positions: items[0] = ..., dict["key"] = ...
  • Struct fields: point.x = ...

Reassigning an all-caps identifier is a parse error (“can’t reassign a constant”).

Field access / method call

OperatorWhat it does
.fieldRead a struct field or enum struct-variant payload field
.method(...)Call a builtin method (on arrays / strings / dicts) or a user-declared method
[idx]Index into an array / string / dict
let p = Point { x: 3, y: 4 }
print(p.x)               // 3
print(p.sum())           // calls user method `fn Point.sum`
print([1, 2, 3].len())   // 3
print("hello".upper())   // "HELLO"

try

try expr is a unary prefix that unwraps Result::Ok(v) to v or propagates Err(e) to the enclosing function’s caller. See Error Handling.

fn parse_and_double(s) {
  let n = try string_to_int(s)    // returns Err early on failure
  return Result::Ok(n * 2)
}

Conditional expressions

Bop has no ternary operator (?:). Use if/else as an expression:

let label = if count > 3 { "lots" } else { "few" }

Both branches are required when if/else is used as an expression. The last expression in each branch is the value.

Precedence

From highest (evaluated first) to lowest:

PriorityOperators
1.field, .method(...), [idx], (args), postfix
2!, - (unary), try
3*, /, %
4+, -
5<, >, <=, >=
6==, !=
7&&
8||
9=, +=, -=, *=, /=, %=

Parentheses override precedence:

let result = (1 + 2) * 3    // 9, not 7

Built-in Functions

Bop ships a very small set of built-in functions that are always in scope. They can’t be shadowed by user-defined fns. Host-backed builtins (I/O, time) are provided separately by the embedding host — see bop-sys’s StandardHost for the reference implementation.

Anything math- or conversion-shaped lives as a method on a value, not as a global — (-5).abs(), "42".to_int(), [1, 2, 3].len(). The global list is deliberately short: variadic (print), constructor-shaped (range), session-stateful (rand), callable-taking (try_call), and the shared error-signalling primitive (panic).

print(args...)

Prints values to the host’s stdout, separated by spaces. Returns none.

print("hello")           // hello
print("x =", 42)         // x = 42
print(1, "plus", 2)      // 1 plus 2

Accepts any number of arguments (including zero). Each argument is converted to its string representation (Display) automatically — you don’t need .to_str() first.

range(n) / range(start, end) / range(start, end, step)

Builds an array of int values.

range(5)           // [0, 1, 2, 3, 4]
range(2, 6)        // [2, 3, 4, 5]
range(0, 10, 2)    // [0, 2, 4, 6, 8]
range(5, 0)        // [5, 4, 3, 2, 1]  (auto-detects direction)
range(10, 0, -3)   // [10, 7, 4, 1]
  • With 1 arg: range(n)[0, 1, ..., n-1].
  • With 2 args: range(start, end) auto-detects direction.
  • With 3 args: explicit step (error if step == 0).
  • All arguments must be int — floats are rejected.
  • Maximum 10,000 elements. Bigger ranges raise a runtime error so loops can’t build gigabyte arrays by accident.

rand(n)

Returns a random integer from 0 to n - 1, inclusive. n must be a positive integer.

rand(6)     // 0..=5 (die roll)
rand(2)     // 0 or 1 (coin flip)

Uses a deterministic PRNG seeded per-session. The same inputs produce the same sequence — handy for tests, surprising for crypto (don’t use it for that).

try_call(callable)

Runs callable with no arguments and catches any non-fatal runtime error:

let r = try_call(fn() { return 1 / 0 })
print(match r {
  Ok(v)  => v,
  Err(e) => "caught: " + e.message,
})
// caught: Division by zero

On success returns Result::Ok(value); on a non-fatal error returns Result::Err(RuntimeError { message, line }). The Ok(v) / Err(e) pattern shorthand desugars to Result::Ok(v) / Result::Err(e) — see Error Handling. Fatal errors (step-limit / memory-limit / fn-call-depth) are not caught — they propagate past try_call unchanged so the sandbox invariant holds.

See Error Handling for the full story on try, try_call, Result, and RuntimeError.

panic(message)

Raise a non-fatal runtime error carrying message. Useful inside stdlib helpers (unwrap, expect, assert_eq, …) and user code that needs to bail with a readable error from an expression position where a plain return isn’t enough.

fn take_positive(n) {
  if n <= 0 { panic("n must be positive, got {n}") }
  return n * 2
}

print(take_positive(3))                           // 6
// print(take_positive(-1))                       // runtime error: n must be positive, got -1

Non-fatal, so try_call catches it:

let r = try_call(fn() { panic("deliberate") })
print(match r {
  Ok(_)  => "ok?",
  Err(e) => e.message,
})
// deliberate

Non-string arguments are stringified via Display, so panic(42) or panic(RuntimeError { message: "x", line: 0 }) also works without an explicit .to_str().

Everything else lives on a value

CategoryWasNow
Introspectiontype(x), inspect(x)x.type(), x.inspect()
Conversionstr(x), int(x), float(x)x.to_str(), x.to_int(), x.to_float()
Lengthlen(x)x.len() (arrays, strings, dicts)
Absolute / min / maxabs(x), min(a, b), max(a, b)x.abs(), a.min(b), a.max(b)
Trig / rootssqrt(x), sin(x), cos(x), tan(x)x.sqrt(), x.sin(), x.cos(), x.tan()
Roundingfloor(x), ceil(x), round(x)x.floor(), x.ceil(), x.round()
Power / log / exppow(b, e), log(x), exp(x)b.pow(e), x.log(), x.exp()

See Methods for the full per-type method catalogue.

Methods

Bop dispatches methods with .name(args...). Every built-in method on primitives, arrays, strings, and dicts is listed here. User-defined methods on structs use the same syntax — see Structs & Enums for the fn Type.method(self, ...) form.

Methods on every value

Three methods work on any value — introspection + stringification. They’re dispatched before the type-specific tables, so they’re always available:

MethodReturnsNotes
x.type()stringOne of "int", "number", "string", "bool", "none", "array", "dict", "fn", "struct", "enum", "module", "iter"
x.to_str()stringDisplay repr — same as what print(x) would emit for a single arg
x.inspect()stringDebug repr — strings are wrapped in "...", nested strings stay quoted inside arrays / dicts
x.is_none()booltrue iff x is the none value. Equivalent to x == none.
x.is_some()boolInverse of .is_none()true for every value except none.
print((42).type())                 // "int"
print("hi".to_str())               // "hi"
print("hi".inspect())              // "hi"   (quoted)
print([1, "two"].inspect())        // [1, "two"]

print(none.is_none())              // true
print((0).is_none())               // false — `0` is falsy but not `none`
print(first_result().is_some())    // check an optional return without `== none`

.is_none() / .is_some() cover Bop’s “any variable can be none” story — they work on every receiver, not just Option-shaped ones (Bop doesn’t have Option). Equivalent to x == none / x != none, but reads better in method chains.

Parens around numeric literals

Number literals need parens before a method call because . is otherwise a decimal point:

// print(42.type())   // parse error — `42.t…` looks like a decimal
print((42).type())     // "int"
print((-5).abs())      // 5

Identifiers don’t have this problem: x.type(), count.to_str(), etc.

Numeric methods — int and number

All of these work on both int and number receivers. Return type is noted per method; most math operations always widen to number.

MethodReturnsDescription
x.abs()int / numberAbsolute value. Preserves receiver type. (-5).abs()5 (int), (-2.7).abs()2.7 (number). Integer overflow on (i64::MIN).abs() is a runtime error.
x.sqrt()numberSquare root.
x.sin(), x.cos(), x.tan()numberTrig. Angles in radians.
x.exp()numbere^x.
x.log()numberNatural log.
x.pow(e)numberx raised to e.
x.floor(), x.ceil(), x.round()int / numberRound toward -∞, +∞, or nearest (ties away from zero). Returns int when the rounded result fits in i64, number otherwise (so rounding a number that overflows i64 stays a number instead of raising). Int receivers pass through unchanged.
a.min(b), a.max(b)int / numberPair-wise. Preserves type when both sides match; widens to number on mixed int/number.
x.to_int()intTruncates toward zero. (3.7).to_int()3, (-2.7).to_int()-2.
x.to_float()numberWidens intnumber; number passes through.
print((9).sqrt())                   // 3
print((0).cos())                    // 1
print((2).pow(10))                  // 1024
print((3).min(7))                   // 3
print((1).max(2.5))                 // 2.5   (widened)
print((3.7).floor())                // 3     (int)
print((3.7).floor().type())         // "int"

Boolean methods — bool

MethodReturnsDescription
b.to_int()inttrue.to_int()1, false.to_int()0.
b.to_float()numbertrue.to_float()1 (as number), false.to_float()0.

Plus the universal type / to_str / inspect.

String methods — string

See Strings for worked examples.

MethodReturnsDescription
s.len()intNumber of Unicode code points.
s.contains(sub)boolWhether sub appears anywhere.
s.starts_with(prefix)bool
s.ends_with(suffix)bool
s.index_of(sub)intByte index of first occurrence, or -1 if not found.
s.split(sep)arraySplit into an array of strings on sep.
s.replace(old, new)stringReplace every occurrence.
s.upper(), s.lower()stringCase conversion.
s.trim()stringStrip leading / trailing whitespace.
s.slice(start, end)stringSubstring by code-point index.
s.to_int()intParse. "3.7".to_int() parses as float then truncates → 3. Raises on junk.
s.to_float()numberParse. Raises on junk.
s.iter()iterLazy iterator over Unicode code points. See Iter methods.

Array methods — array

See Arrays for worked examples.

MethodReturnsDescription
arr.len()intNumber of elements.
arr.push(v)noneAppend.
arr.pop()valueRemove and return the last element.
arr.has(v)boolStructural equality check.
arr.index_of(v)intIndex of first match, or -1.
arr.insert(i, v)noneInsert at index, shifting right.
arr.remove(i)valueRemove at index, returning the removed value.
arr.slice(start, end)arraySub-array.
arr.reverse()noneIn-place.
arr.sort()noneIn-place, numeric or lexicographic depending on element types.
arr.join(sep)stringJoin after stringifying each element.
arr.iter()iterLazy iterator over the elements. See Iter methods.

Dict methods — dict

See Dictionaries for worked examples.

MethodReturnsDescription
d.len()intNumber of entries.
d.keys()arrayAll keys as strings.
d.values()arrayAll values.
d.has(key)boolWhether key exists.
d.iter()iterLazy iterator over keys, in declaration order. See Iter methods.

Result methods — Result

Result is an engine built-in. All combinators are methods on the built-in type — no import required. Ok(x) and Err(e) are parser-level shorthand for Result::Ok(x) / Result::Err(e) in both expression and pattern position.

MethodReturnsDescription
r.is_ok()booltrue when r is Result::Ok(_).
r.is_err()booltrue when r is Result::Err(_).
r.unwrap()valuePayload on Ok; raises a runtime error on Err (message includes the .inspect() of the payload).
r.expect(msg)valuePayload on Ok; raises with msg on Err.
r.unwrap_or(default)valuePayload on Ok; default on Err.
r.map(f)ResultOk(v)Ok(f(v)); Err(e) passes through.
r.map_err(f)ResultErr(e)Err(f(e)); Ok(v) passes through.
r.and_then(f)ResultOk(v)f(v) (expected to return a Result); Err(e) passes through.
print(Ok(5).is_ok())                           // true
print(Err("bad").unwrap_or(0))                 // 0
print(Ok(5).map(fn(v) { return v * 2 }))       // Result::Ok(10)
print(Err("x").map(fn(v) { return v * 2 }))    // Result::Err("x")

fn halve(x) {
  if x % 2 == 0 { return Ok((x / 2).to_int()) }
  return Err("odd")
}
print(Ok(8).and_then(halve).and_then(halve))   // Result::Ok(2)

Iter methods — iter

An iter is Bop’s lazy iterator. Values you can iterate over — arrays, strings, dicts, built-in iterators, and user-defined containers — all participate in the same protocol:

  1. v.iter() returns an iterator.
  2. it.next() advances it, returning Iter::Next(value) or Iter::Done.

for x in v uses this protocol, so anything with a working .iter() method works with for.

MethodReturnsDescription
it.next()Iter::Next(v) / Iter::DoneAdvance by one. Cloning an iterator shares its cursor (like Python / Rust / JS) — two names pointing at the same iterator advance together.
it.iter()iterReturns the same iterator. Makes for x in it work whether it is already an iterator or a fresh iterable.
let it = [10, 20, 30].iter()
print(it.type())                // "iter"
print(it.next())                // Iter::Next(10)
print(it.next())                // Iter::Next(20)

for x in it { print(x) }        // 30  (picks up from the current cursor)

User-defined iterables

A struct can participate in the iterator protocol by implementing .iter() (and, if it’s its own iterator, .next()):

struct Bag { items }
fn bag_of(arr) { return Bag { items: arr } }
fn Bag.iter(self) { return self.items.iter() }   // delegate to the backing array

let b = bag_of(["x", "y", "z"])
for v in b { print(v) }                           // x  y  z

That’s the minimal shape. A container that wraps an array and delegates .iter() is the 80% case. User types with genuine internal state (like a lazy counter) work the same way — define fn Counter.iter(self) to return an iterator (either the backing data’s iterator, or self if self also has .next()).

The Iter enum

.next() returns one of two variants of the built-in Iter enum — always in scope, no use required:

enum Iter {
  Next(value),
  Done,
}

Pattern-match directly:

let it = [1, 2].iter()
let r = it.next()
print(match r {
  Iter::Next(v) => "got: " + v.to_str(),
  Iter::Done    => "exhausted",
})
// got: 1

Struct / enum methods

User-declared. See Structs & Enums. Method dispatch on a struct tries the universal common methods first, then looks up fn TypeName.method declared in the same module.

Same-module rule: user-declared methods must live in the module that declares the type. You can’t extend a type imported from another module, and you can’t add methods to the built-ins (int, string, array, Result, Iter, …). See Methods must live in the type’s own module for the rationale and the “use a free function instead” workaround.

Module methods

If you use path as m, m is a Value::Module. m.type()"module", m.inspect()"<module path>". Otherwise . on a module accesses its exports:

use std.math as m
print(m.PI)             // exported constant
print(m.type())         // "module"   (universal method, not an export)

Grammar

An informal grammar for the Bop language, plus the complete list of reserved words.

Reserved words

let const fn return
if else while for in repeat break continue
use as struct enum match try
true false none

These can’t be used as variable or function names. There are no word-spelled logical operators — use &&, ||, ! rather than and, or, not.

Built-in functions

Always in scope; can’t be shadowed by user fns. See Built-in Functions for details.

FunctionReturnsDescription
print(args...)noneHost-captured output
range(n) / range(s, e) / range(s, e, step)arrayInteger range
rand(n)intPseudo-random 0..n
try_call(f)ResultRun f, return Ok(v) or Err(RuntimeError)
panic(message)never returnsRaise a non-fatal runtime error carrying message

All math and conversion operations are methods on values: x.type(), x.to_str(), x.to_int(), x.to_float(), x.abs(), a.min(b), x.sqrt(), x.len(), etc.

Grammar

Informal EBNF. Whitespace is insignificant except for newlines, which auto-insert semicolons (see below).

program     = statement*
statement   = letDecl | constDecl | assign | ifStmt | whileStmt | repeatStmt
            | forStmt | fnDecl | returnStmt | breakStmt | continueStmt
            | useStmt | structDecl | enumDecl | methodDecl
            | exprStmt

letDecl     = "let" IDENT "=" expr
constDecl   = "const" IDENT "=" expr
assign      = target ("=" | "+=" | "-=" | "*=" | "/=" | "%=") expr
target      = IDENT | postfix "[" expr "]" | postfix "." IDENT

ifStmt      = "if" expr block ("else" "if" expr block)* ("else" block)?
whileStmt   = "while" expr block
repeatStmt  = "repeat" expr block
forStmt     = "for" IDENT "in" expr block
fnDecl      = "fn" IDENT "(" params? ")" block
returnStmt  = "return" expr?
breakStmt   = "break"
continueStmt = "continue"

useStmt     = "use" path
            | "use" path "." "{" IDENT ("," IDENT)* "}"
            | "use" path "as" IDENT
            | "use" path "." "{" IDENT ("," IDENT)* "}" "as" IDENT
path        = IDENT ("." IDENT)*

structDecl  = "struct" IDENT "{" fields? "}"
fields      = IDENT ("," IDENT)*
enumDecl    = "enum" IDENT "{" variants? "}"
variants    = variant ("," variant)*
variant     = IDENT                                             // unit
            | IDENT "(" IDENT ("," IDENT)* ")"                  // tuple
            | IDENT "{" IDENT ("," IDENT)* "}"                  // struct
methodDecl  = "fn" IDENT "." IDENT "(" params ")" block

exprStmt    = expr
block       = "{" statement* "}"

expr        = or
or          = and ("||" and)*
and         = equality ("&&" equality)*
equality    = comparison (("==" | "!=") comparison)*
comparison  = addition (("<" | ">" | "<=" | ">=") addition)*
addition    = multiply (("+" | "-") multiply)*
multiply    = unary (("*" | "/" | "%") unary)*
unary       = ("!" | "-" | "try") unary | postfix
postfix     = primary (call | index | field | method | structLit | variantCtor)*
call        = "(" args? ")"
index       = "[" expr "]"
field       = "." IDENT
method      = "." IDENT "(" args? ")"
structLit   = "{" (IDENT ":" expr ("," IDENT ":" expr)*)? "}"   // only at expr position
variantCtor = "::" IDENT payload?
payload     = "(" expr ("," expr)* ")"                          // tuple variant
            | "{" IDENT ":" expr ("," IDENT ":" expr)* "}"      // struct variant

primary     = INT | NUMBER | STRING | "true" | "false" | "none"
            | IDENT | resultShorthandExpr | "(" expr ")" | arrayLit | dictLit
            | ifExpr | matchExpr | fnExpr

resultShorthandExpr = ("Ok" | "Err") "(" expr ("," expr)* ")"  // sugar for Result::Ok/Err

arrayLit    = "[" (expr ("," expr)*)? "]"
dictLit     = "{" (STRING ":" expr ("," STRING ":" expr)*)? "}"
ifExpr      = "if" expr "{" expr "}" "else" "{" expr "}"
matchExpr   = "match" expr "{" arm ("," arm)* ","? "}"
arm         = pattern ("if" expr)? "=>" expr
fnExpr      = "fn" "(" params? ")" block

pattern     = orPattern
orPattern   = singlePattern ("|" singlePattern)*
singlePattern = "_" | IDENT | literal | variantPattern | structPattern | arrayPattern
              | resultShorthand
variantPattern = (IDENT ".")? IDENT "::" IDENT
              | (IDENT ".")? IDENT "::" IDENT "(" pattern ("," pattern)* ")"
              | (IDENT ".")? IDENT "::" IDENT "{" IDENT ":" pattern ("," IDENT ":" pattern)* "}"
resultShorthand = ("Ok" | "Err") "(" pattern ("," pattern)* ")"  // sugar for Result::Ok/Err
structPattern  = (IDENT ".")? IDENT "{" IDENT ":" pattern ("," IDENT ":" pattern)* "}"
arrayPattern   = "[" patternList? arrayRest? "]"
patternList    = pattern ("," pattern)*
arrayRest      = ".." | ".." IDENT

params      = IDENT ("," IDENT)*
args        = expr ("," expr)*

Note: methodDecl, enum variant IDENTs, and struct names must start with an uppercase letter. IDENT bound by let, fn, parameters, for, etc. must start with lowercase or _. const names must be all-caps. Mis-shaped declarations parse-error with a “did you mean?” suggestion — see Variables.

Automatic semicolons

Bop automatically inserts a semicolon at the end of a line if the last token is one of:

  • An identifier or literal (int, number, string)
  • true, false, none
  • break, continue, return
  • ), ], }

This means the opening { of a block must be on the same line as its keyword:

// Correct
if x > 3 {
  print("yes")
}

// Wrong — semicolon inserted after "3"
if x > 3
{
  print("yes")
}

You can also separate statements on the same line with an explicit ;:

let x = 1; let y = 2

Comments

// starts a line comment — everything to the end of line is ignored:

// Whole-line comment
let x = 5   // Inline trailing comment

There’s no block-comment syntax.

String interpolation

Inside double-quoted strings, {identifier} inserts the value of a variable. Only plain variable names are allowed — no expressions, operators, or function calls:

let name = "Alice"
print("Hello, {name}!")     // works
// print("Hello, {1 + 2}!")  // error — expressions not allowed

Use \{ and \} for literal braces in strings. Other supported escapes: \", \\, \n, \t, \r.

Standard Library — overview

bop-std ships a small set of modules written in Bop itself. They live under the std.* namespace and are resolved by the default host (StandardHost in bop-sys) — any host that defers to StandardHost::resolve_module picks them up for free.

The stdlib is deliberately thin. Core math and Result operations are methods on values ((-5).abs(), (9).sqrt(), r.unwrap_or(0), r.map(f)) — they don’t need a module. The stdlib covers what’s left: constants, higher-order helpers on arrays, data-structure types, string formatting, JSON, test assertions.

Modules

ModuleWhat it gives you
std.mathPI, E, TAU, clamp, sign, factorial, gcd, lcm, mean
std.itermap, filter, reduce, take, drop, zip, enumerate, all, any, count, find, find_index, flatten, sum, product, min_array, max_array
std.collectionsStack, Queue, Set as value-semantics structs
std.stringpad_left, pad_right, center, chars, reverse, is_palindrome, count, join
std.jsonparse, stringify (RFC-8259, pure Bop)
std.testassert, assert_eq, assert_near, assert_raises

Using the stdlib

std modules work with every use form:

use std.math                   // glob — `PI`, `clamp`, etc. available bare
use std.iter.{map, filter}     // selective
use std.json as j              // aliased

The modules are plain Bop source — you can find the implementations in bop/src/modules/*.bop if you want to see how a helper is wired, or copy-paste the source into a host that doesn’t ship bop-std.

Things you might expect to find here

  • Result combinatorsis_ok, is_err, unwrap, expect, unwrap_or, map, map_err, and_then used to live in std.result. They’re now methods on the built-in Result type and always available without any import. See Methods → Result.
  • print, range, rand, try_call, panic — always-in-scope built-in functions, not stdlib.
  • Math on numbersabs, sqrt, sin, cos, floor, ceil, round, pow, log, exp, min, max, to_int, to_float are methods on int / number, not stdlib.

Hosts without the stdlib

Embedders who don’t want bop-std on the host side can leave resolve_module unimplemented; any use std.* then fails with “can’t resolve module”. Nothing about the language depends on the stdlib — it’s a convenience, not a runtime prerequisite.

std.math

Numeric constants and helpers that aren’t idiomatic as methods.

The core math operations — abs, sqrt, sin, cos, tan, floor, ceil, round, pow, log, exp, min, max — are methods on numbers, not stdlib functions. They live in core because they wrap f64::* operations that Bop can’t implement itself.

Import

use std.math                           // glob
use std.math.{PI, clamp}               // selective
use std.math as m                      // aliased

Constants

All three are const (all-caps name, value is fixed at module load).

NameValue
PI3.141592653589793
E2.718281828459045
TAU6.283185307179586

Functions

clamp(x, lo, hi)

Clamp x into the range [lo, hi]. Works on any mix of int and number; the return type mirrors the widest input.

use std.math.{clamp}
print(clamp(5, 0, 10))     // 5
print(clamp(-3, 0, 10))    // 0
print(clamp(42, 0, 10))    // 10

sign(x)

Returns -1, 0, or 1. Works on both int and number.

use std.math.{sign}
print(sign(-7))       // -1
print(sign(0))        // 0
print(sign(3.14))     // 1

factorial(n)

n! using iterative multiplication. Raises an integer-overflow error for n ≥ 21 (the smallest factorial that doesn’t fit in i64). Negative n returns 0.

use std.math.{factorial}
print(factorial(5))    // 120
print(factorial(10))   // 3628800

gcd(a, b)

Greatest common divisor using the Euclidean algorithm. Handles negatives by taking absolute values. gcd(0, 0) returns 0.

use std.math.{gcd}
print(gcd(12, 18))    // 6
print(gcd(-15, 25))   // 5

lcm(a, b)

Least common multiple. lcm(0, x) is 0 (so callers don’t have to special-case).

use std.math.{lcm}
print(lcm(4, 6))     // 12
print(lcm(0, 9))     // 0

mean(arr)

Arithmetic mean of a numeric array. Raises on an empty array so callers notice rather than silently getting 0.

use std.math.{mean}
print(mean([1, 2, 3, 4]))     // 2.5
print(mean([10.0, 20.0]))     // 15

Need the sum or product instead? Use std.iter.sum / std.iter.product.

std.iter

Functional helpers on arrays. Array-in, array-out — there’s no lazy iterator protocol. If you care about allocation, a hand-written for loop will always beat these.

Every helper handles empty arrays gracefully and preserves relative order.

Import

use std.iter                                // glob
use std.iter.{map, filter, reduce}          // selective
use std.iter as i                           // aliased

Higher-order

map(arr, f)

Apply f to each element; return a new array of results.

use std.iter.{map}
print(map([1, 2, 3], fn(x) { return x * 2 }))     // [2, 4, 6]

filter(arr, pred)

Keep only the elements for which pred(x) is truthy.

use std.iter.{filter}
let evens = filter([1, 2, 3, 4, 5], fn(n) { return n % 2 == 0 })
print(evens)    // [2, 4]

reduce(arr, initial, combine)

Fold over the array left-to-right with a two-arg combiner.

use std.iter.{reduce}
let sum = reduce([1, 2, 3, 4], 0, fn(acc, x) { return acc + x })
print(sum)    // 10

all(arr, pred), any(arr, pred)

all returns true when every element passes (vacuously true for empty). any returns true when at least one element passes.

use std.iter.{all, any}
print(all([2, 4, 6], fn(n) { return n % 2 == 0 }))    // true
print(any([1, 3, 4], fn(n) { return n % 2 == 0 }))    // true

count(arr, pred)

Count elements for which pred(x) is truthy.

use std.iter.{count}
print(count([1, 2, 3, 4, 5], fn(n) { return n > 2 }))   // 3

find(arr, pred) / find_index(arr, pred)

find returns the first matching element, or none if no match. find_index returns the 0-based index, or -1.

use std.iter.{find, find_index}
print(find([1, 2, 3, 4], fn(n) { return n > 2 }))          // 3
print(find_index([1, 2, 3, 4], fn(n) { return n > 2 }))    // 2

Slicing

take(arr, n)

First n elements (or the whole array if it’s shorter). Negative n yields an empty array.

use std.iter.{take}
print(take([1, 2, 3, 4, 5], 3))    // [1, 2, 3]
print(take([1, 2], 10))            // [1, 2]

drop(arr, n)

Drop the first n elements. Negative n yields a full copy; n >= arr.len() yields [].

use std.iter.{drop}
print(drop([1, 2, 3, 4, 5], 2))    // [3, 4, 5]

Combining

zip(a, b)

Pair elements from two arrays. Stops at the shorter array’s length.

use std.iter.{zip}
print(zip([1, 2, 3], ["a", "b", "c"]))    // [[1, "a"], [2, "b"], [3, "c"]]

enumerate(arr)

Pair each element with its 0-based index.

use std.iter.{enumerate}
print(enumerate(["a", "b"]))    // [[0, "a"], [1, "b"]]

flatten(arr)

Flatten an array of arrays one level down.

use std.iter.{flatten}
print(flatten([[1, 2], [3, 4], [5]]))    // [1, 2, 3, 4, 5]

Reductions

sum(arr)

Sum of a numeric array. Empty → 0.

use std.iter.{sum}
print(sum([1, 2, 3, 4]))     // 10
print(sum([]))               // 0

product(arr)

Product of a numeric array. Empty → 1 (multiplicative identity, matching NumPy / Python’s math.prod).

use std.iter.{product}
print(product([2, 3, 4]))    // 24
print(product([]))           // 1

min_array(arr) / max_array(arr)

Minimum / maximum of a numeric array. Raises on empty input so callers notice.

use std.iter.{min_array, max_array}
print(min_array([3, 1, 4, 1, 5]))    // 1
print(max_array([3, 1, 4, 1, 5]))    // 5

For pairwise min / max use the .min() / .max() numeric methods instead.

std.collections

Stack, Queue, and Set as value-semantics structs.

Value semantics

Bop’s user methods pass self by value, so these collections all return a fresh instance on mutation and you rebind:

let s = stack()
s = s.push(1)
s = s.push(2)

The pattern trades a little boilerplate for predictable semantics: let a = b doesn’t alias, and method calls never surprise you by mutating out of band.

Import

use std.collections                          // glob
use std.collections.{stack, queue, set}      // selective
use std.collections as c                     // aliased

Stack (LIFO)

use std.collections.{stack}

let s = stack()
s = s.push(1)
s = s.push(2)
s = s.push(3)
print(s.top())       // 3
print(s.size())      // 3

s = s.pop()
print(s.top())       // 2
MethodReturnsNotes
stack()StackEmpty constructor
s.is_empty()bool
s.size()int
s.push(v)StackNew stack with v on top
s.top()valueTop element, or none if empty
s.pop()StackNew stack without the top; popping empty is a no-op

Queue (FIFO)

use std.collections.{queue}

let q = queue()
q = q.enqueue("a")
q = q.enqueue("b")
q = q.enqueue("c")
print(q.front())    // "a"

q = q.dequeue()
print(q.front())    // "b"
MethodReturnsNotes
queue()QueueEmpty constructor
q.is_empty()bool
q.size()int
q.enqueue(v)QueueNew queue with v at the back
q.front()valueFront element, or none if empty
q.dequeue()QueueNew queue without the front; dequeuing empty is a no-op

Dequeue is O(n) in this naive implementation — good enough for scripting workloads. If you need tighter bounds, write an array-backed ring buffer.

Set (unique, insertion-ordered)

use std.collections.{set, set_of}

let a = set_of([1, 2, 3])
let b = set_of([2, 3, 4])

print(a.union(b).values())         // [1, 2, 3, 4]
print(a.intersect(b).values())     // [2, 3]
print(a.difference(b).values())    // [1]
MethodReturnsNotes
set()SetEmpty constructor
set_of(arr)SetFrom an array (duplicates collapse, first-seen wins)
s.is_empty()bool
s.size()int
s.has(v)bool
s.add(v)SetNew set with v. Idempotent.
s.remove(v)SetNew set without v. Removing absent is a no-op.
s.values()arrayElements in insertion order
s.union(other)Set
s.intersect(other)Set
s.difference(other)Setself minus other

std.string

Formatting and character-level helpers that don’t fit the method-on-string pattern.

The core string operations — .len(), .trim(), .upper(), .split(), .contains(), .slice(), .replace(), .to_int(), .to_float(), etc. — are methods on strings. This module adds things that are awkward as methods.

Import

use std.string
use std.string.{pad_left, center}
use std.string as str

Padding

pad_left(s, width, ch) / pad_right(s, width, ch)

Pad s on the left (or right) with ch until it reaches width total characters. Already-long strings are returned unchanged. ch should be a single-character string.

use std.string.{pad_left, pad_right}
print(pad_left("42", 5, "0"))      // "00042"
print(pad_right("hi", 6, "."))     // "hi...."

center(s, width, ch)

Centre s inside width chars using ch as filler. Odd leftover space goes on the right (matches Python’s str.center).

use std.string.{center}
print(center("OK", 6, "-"))     // "--OK--"
print(center("OK", 7, "-"))     // "--OK---"

Character-level

chars(s)

Split s into an array of single-character strings, preserving order.

use std.string.{chars}
print(chars("abc"))    // ["a", "b", "c"]

reverse(s)

Reverse the character sequence. Works on ASCII and UTF-8 (iterates by code point).

use std.string.{reverse}
print(reverse("hello"))    // "olleh"

is_palindrome(s)

true when s reads the same forwards and backwards. Case-sensitive — lowercase the input first if you need case-insensitive comparison.

use std.string.{is_palindrome}
print(is_palindrome("racecar"))    // true
print(is_palindrome("Racecar"))    // false

Other helpers

count(s, needle)

Count non-overlapping occurrences of needle in s. An empty needle returns 0 (avoids the “infinite matches at every position” ambiguity).

use std.string.{count}
print(count("banana", "a"))      // 3
print(count("aaaa", "aa"))       // 2  (non-overlapping)

join(arr, sep)

Join an array of strings with sep. This is a thin wrapper around the built-in arr.join(sep) array method — it’s here so users starting from std.string don’t have to look elsewhere.

use std.string.{join}
print(join(["a", "b", "c"], "-"))    // "a-b-c"

std.json

RFC-8259 JSON parse and stringify, implemented in pure Bop.

Performance is reasonable for scripting workloads (config files, API payloads up to a few MB). A C-backed parser would be faster but would pull bop-std out of its zero-Rust-dep contract.

Import

use std.json                       // glob
use std.json.{parse, stringify}    // selective
use std.json as j                  // aliased

stringify(value)

Emit value as JSON text. Bop values that have no JSON analogue (fn, struct, enum) raise a runtime error — strip them out before calling.

use std.json.{stringify}

print(stringify(42))                           // "42"
print(stringify("hello"))                      // "\"hello\""
print(stringify([1, 2, 3]))                    // "[1,2,3]"
print(stringify({"name": "Alice", "age": 30})) // '{"name":"Alice","age":30}'
print(stringify(true))                         // "true"
print(stringify(none))                         // "null"

Strings are escaped for the five characters JSON requires (", \, \n, \r, \t). Other control characters under 0x20 are not currently escaped — fine for the common cases, but a known gap if you’re stringifying raw binary.

parse(text)

Parse JSON text into a Bop value. Parse errors raise a runtime error with a position marker.

use std.json.{parse}

print(parse("42"))                       // 42
print(parse("\"hello\""))                // "hello"
print(parse("[1, 2, 3]"))                // [1, 2, 3]
print(parse("{\"name\": \"Alice\"}"))    // {"name": "Alice"}
print(parse("null"))                     // none

Mapping

JSON typeBop type
number (integer)int
number (decimal / exponent)number
stringstring
booleanbool
nullnone
arrayarray
objectdict (keys must be strings, which JSON already requires)

Catching parse errors

parse raises on malformed input. Wrap in try_call if you want a Result-shaped outcome:

use std.json.{parse}

let r = try_call(fn() { return parse("\{broken") })
match r {
  Result::Ok(v)  => print("parsed: " + v.to_str()),
  Result::Err(e) => print("parse failed: " + e.message),
}
// parse failed: ...

(The leading \{ escapes the { so Bop doesn’t mistake it for a string-interpolation marker.)

Known gaps

  • \b / \f escapes are rejected. Rare in real payloads.
  • \uXXXX escapes are rejected — they’d need 4-hex parsing plus code-point-to-UTF-8 conversion, which is nontrivial in pure Bop.

Both raise a clear “unsupported escape” error so you know what happened.

std.test

Minimal assertion toolkit for sanity checks in Bop scripts.

This isn’t an xUnit clone — just the assertion primitives you’ll reach for when writing quick tests. Assertions fail by routing through the panic builtin, so the failure detail is surfaced verbatim in Err(e).message when caught by try_call. Print-based reporting is intentionally out of scope — wrap the assertion in try_call if you need “report and continue”.

Import

use std.test                                // glob
use std.test.{assert_eq, assert_near}       // selective
use std.test as t                           // aliased

Assertions

assert(cond, message)

Assert that cond is truthy. On failure, raises a runtime error — message is surfaced in the crash trace.

use std.test.{assert}
assert(1 + 1 == 2, "arithmetic still works")

assert_eq(actual, expected)

Assert two values are structurally equal (same as ==). On failure the error includes an assert_eq failed: prefix and the .inspect() of both sides.

use std.test.{assert_eq}
assert_eq([1, 2, 3].len(), 3)
assert_eq("hi".upper(), "HI")

assert_near(actual, expected, tolerance)

Assert two floats are within tolerance of each other. Use this instead of assert_eq when comparing number values subject to rounding.

use std.test.{assert_near}
assert_near((2).sqrt() * (2).sqrt(), 2, 0.0000001)

assert_raises(body)

Assert that body — a zero-arg closure — raises a runtime error. On success (no raise), the assertion itself fails. Useful for negative tests.

use std.test.{assert_raises}

assert_raises(fn() {
  let _ = 1 / 0     // expected to raise "Division by zero"
})

assert_raises(fn() {
  "not a number".to_int()
})

Under the hood, assert_raises uses try_call to observe whether body raised, so the same fatal-vs-non-fatal rules apply — a step-limit exceeded inside body propagates past the assertion instead of being caught.

Putting it together

use std.test.{assert_eq, assert_raises}

fn normalize(arr) {
  if arr.len() == 0 { return none[0] }
  let total = 0
  for x in arr { total = total + x }
  let m = total / arr.len()
  let out = []
  for x in arr { out.push(x - m) }
  return out
}

assert_eq(normalize([1, 2, 3]), [-1, 0, 1])
assert_raises(fn() { normalize([]) })      // raises on empty
print("normalize: all checks passed")

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)),
    }
}