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 singleBopHosttrait — 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
ValueandBopError, and the test suite pins them to byte-for-byte output agreement via a three-way differential harness. -
no_stdand wasm. The core crate compiles unchanged forwasm32-unknown-unknownand bare-metal targets. Enable theno_stdfeature for alibm-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/Iterbuilt-ins — that’s close to the whole surface. Everything else (math, JSON, iteration helpers, string utilities, test assertions) lives in the bundledstd.*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 host — Embedding Bop walks through the
BopHosttrait, resource limits, module resolution, and picking an engine. - Learning the language — start with the Language Guide and work through
Basics→Control Flow→Data→Functions→Modules→Error 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 interactively —
bop replopens 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,nonebreak,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:
| Declaration | Required shape | Examples |
|---|---|---|
let, fn, parameters, fields, aliases, for-loop vars, match bindings | starts with a lowercase letter or _ | let x, fn double(n), for i in … |
const | ALL_CAPS (+ digits / _) | const PI = 3.14, const MAX_SIZE = 100 |
struct, enum, variant names | starts with an uppercase letter | struct 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() returns | Description | Literal examples |
|---|---|---|
"int" | 64-bit signed integer | 0, 42, -7 |
"number" | 64-bit floating point | 3.14, -0.5, 4.0 |
"string" | UTF-8 text | "hello", "got {n} items" |
"bool" | Boolean | true, false |
"none" | Absence of a value | none |
"array" | Ordered, mutable collection | [1, 2, 3], [] |
"dict" | String-keyed map | {"x": 10, "name": "Alice"} |
"fn" | First-class function / closure | fn(x) { return x + 1 } |
"struct" | User-defined struct instance | Point { x: 3, y: 4 } |
"enum" | User-defined enum variant | Color::Red |
"module" | Aliased module namespace | result 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:
| Declaration | Required shape |
|---|---|
let x, fn foo(param), struct fields, match bindings, for variables, aliases | starts with lowercase or _ |
const FOO | all caps (+ digits / _) |
struct Point, enum Shape, enum variants | starts 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:
breakandcontinueonly affect the innermost loop. If you have nested loops and want to exit the outer one, use a variable flag or a function withreturn.
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
| Method | Returns | Description |
|---|---|---|
arr.len() | int | Number of elements |
arr.push(val) | none | Append to end |
arr.pop() | value | Remove and return last element |
arr.has(val) | bool | Whether the array contains the value |
arr.index_of(val) | int | Index of first occurrence, or -1 |
arr.insert(i, val) | none | Insert at index, shifting elements right |
arr.remove(i) | value | Remove at index, shifting elements left |
arr.slice(start, end) | array | New sub-array |
arr.reverse() | none | Reverse in place |
arr.sort() | none | Sort in place |
arr.join(sep) | string | Join 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
| Method | Returns | Description |
|---|---|---|
s.len() | int | Number of characters |
s.contains(sub) | bool | Whether the string contains sub |
s.starts_with(prefix) | bool | Whether it starts with prefix |
s.ends_with(suffix) | bool | Whether it ends with suffix |
s.index_of(sub) | int | Index of first occurrence, or -1 |
s.split(sep) | array | Split into array of strings on sep |
s.replace(old, new) | string | Replace all occurrences |
s.upper() | string | Uppercase copy |
s.lower() | string | Lowercase copy |
s.trim() | string | Copy with leading/trailing whitespace removed |
s.slice(start, end) | string | Substring |
s.to_int() | int | Parse. "3.7".to_int() → 3 (float-then-truncate). Raises on junk. |
s.to_float() | number | Parse. 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
noneis present —d.has(k)returnstruefor it, even thoughd[k]andd["absent_key"]are bothnone. Used.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
| Method | Returns | Description |
|---|---|---|
d.len() | int | Number of entries |
d.keys() | array | Array of all keys |
d.values() | array | Array of all values |
d.has(key) | bool | Whether 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:
| Shape | Declaration | Construction |
|---|---|---|
| Unit | Empty | Shape::Empty |
| Tuple | Circle(r) | Shape::Circle(5) |
| Struct | Rectangle { 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
paintdeclaresstruct Color { ... }, thenfn Color.brighten(self)must also live inpaint, 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 typesResult,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::Module — m.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 anOk(v)or propagate anErr(e)up to the enclosing function.try_call(f)— run a zero-arg callable, catch any runtime error, and return the outcome as aResult.
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 tov. - If the result is
Result::Err(e), immediately returnsefrom the enclosing function as-is (wrapped in the sameErrvariant 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
| Situation | Use |
|---|---|
| Writing a fallible function | Return Result::Ok(v) / Result::Err(e) |
| Chaining several fallible calls | try inside a fn, or r.and_then(f) |
| Running user-supplied code with a safety net | try_call(fn() { ... }) |
Handling every Err case explicitly | match |
You know it’s Ok and want the value | r.unwrap() / r.expect("...") (sparingly) |
Supplying a default on Err | r.unwrap_or(default) |
Fatal vs non-fatal
- Non-fatal (catchable by
try_call): division by zero, “variable not found”, type mismatches, host-raised errors viaBopError::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/constbindings.fndeclarations (named and anonymous-then-let-bound).struct/enum/ method declarations.useimports 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:
| Command | Action |
|---|---|
:help | Print the meta-command list |
:vars | List all currently-bound names (sorted) |
:reset / :clear | Drop every binding and start fresh |
:quit / :q / :exit | Exit 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
| Operator | Name | Operands | Notes |
|---|---|---|---|
+ | Add | int, number, string, array | String + anything concats after stringifying the other side. Array + array concatenates. |
- | Subtract | int, number | Also unary negation: -x |
* | Multiply | int, number, or string * int | "ab" * 3 → "ababab" |
/ | Divide | int, number | Always returns number: 7 / 2 → 3.5, 6 / 2 → 3 (as number) |
% | Modulo | int, number | Same 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 * intuse checked arithmetic — overflow is a runtime error, not silent wrap.- Division / modulo by zero raises a runtime error.
int / numberornumber / intwidens tonumber(IEEE-754 rules; division by 0.0 is still a runtime error in Bop).
Comparison
| Operator | Name | Returns |
|---|---|---|
== | Equal | bool |
!= | Not equal | bool |
< | Less than | bool |
> | Greater than | bool |
<= | Less or equal | bool |
>= | Greater or equal | bool |
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
| Operator | Name | Notes |
|---|---|---|
&& | And | Short-circuits |
|| | Or | Short-circuits |
! | Not | Unary 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
| Operator | Equivalent 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
| Operator | What it does |
|---|---|
.field | Read 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:
| Priority | Operators |
|---|---|
| 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 ifstep == 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
| Category | Was | Now |
|---|---|---|
| Introspection | type(x), inspect(x) | x.type(), x.inspect() |
| Conversion | str(x), int(x), float(x) | x.to_str(), x.to_int(), x.to_float() |
| Length | len(x) | x.len() (arrays, strings, dicts) |
| Absolute / min / max | abs(x), min(a, b), max(a, b) | x.abs(), a.min(b), a.max(b) |
| Trig / roots | sqrt(x), sin(x), cos(x), tan(x) | x.sqrt(), x.sin(), x.cos(), x.tan() |
| Rounding | floor(x), ceil(x), round(x) | x.floor(), x.ceil(), x.round() |
| Power / log / exp | pow(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:
| Method | Returns | Notes |
|---|---|---|
x.type() | string | One of "int", "number", "string", "bool", "none", "array", "dict", "fn", "struct", "enum", "module", "iter" |
x.to_str() | string | Display repr — same as what print(x) would emit for a single arg |
x.inspect() | string | Debug repr — strings are wrapped in "...", nested strings stay quoted inside arrays / dicts |
x.is_none() | bool | true iff x is the none value. Equivalent to x == none. |
x.is_some() | bool | Inverse 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 benone” story — they work on every receiver, not justOption-shaped ones (Bop doesn’t haveOption). Equivalent tox == 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.
| Method | Returns | Description |
|---|---|---|
x.abs() | int / number | Absolute 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() | number | Square root. |
x.sin(), x.cos(), x.tan() | number | Trig. Angles in radians. |
x.exp() | number | e^x. |
x.log() | number | Natural log. |
x.pow(e) | number | x raised to e. |
x.floor(), x.ceil(), x.round() | int / number | Round 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 / number | Pair-wise. Preserves type when both sides match; widens to number on mixed int/number. |
x.to_int() | int | Truncates toward zero. (3.7).to_int() → 3, (-2.7).to_int() → -2. |
x.to_float() | number | Widens int → number; 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
| Method | Returns | Description |
|---|---|---|
b.to_int() | int | true.to_int() → 1, false.to_int() → 0. |
b.to_float() | number | true.to_float() → 1 (as number), false.to_float() → 0. |
Plus the universal type / to_str / inspect.
String methods — string
See Strings for worked examples.
| Method | Returns | Description |
|---|---|---|
s.len() | int | Number of Unicode code points. |
s.contains(sub) | bool | Whether sub appears anywhere. |
s.starts_with(prefix) | bool | |
s.ends_with(suffix) | bool | |
s.index_of(sub) | int | Byte index of first occurrence, or -1 if not found. |
s.split(sep) | array | Split into an array of strings on sep. |
s.replace(old, new) | string | Replace every occurrence. |
s.upper(), s.lower() | string | Case conversion. |
s.trim() | string | Strip leading / trailing whitespace. |
s.slice(start, end) | string | Substring by code-point index. |
s.to_int() | int | Parse. "3.7".to_int() parses as float then truncates → 3. Raises on junk. |
s.to_float() | number | Parse. Raises on junk. |
s.iter() | iter | Lazy iterator over Unicode code points. See Iter methods. |
Array methods — array
See Arrays for worked examples.
| Method | Returns | Description |
|---|---|---|
arr.len() | int | Number of elements. |
arr.push(v) | none | Append. |
arr.pop() | value | Remove and return the last element. |
arr.has(v) | bool | Structural equality check. |
arr.index_of(v) | int | Index of first match, or -1. |
arr.insert(i, v) | none | Insert at index, shifting right. |
arr.remove(i) | value | Remove at index, returning the removed value. |
arr.slice(start, end) | array | Sub-array. |
arr.reverse() | none | In-place. |
arr.sort() | none | In-place, numeric or lexicographic depending on element types. |
arr.join(sep) | string | Join after stringifying each element. |
arr.iter() | iter | Lazy iterator over the elements. See Iter methods. |
Dict methods — dict
See Dictionaries for worked examples.
| Method | Returns | Description |
|---|---|---|
d.len() | int | Number of entries. |
d.keys() | array | All keys as strings. |
d.values() | array | All values. |
d.has(key) | bool | Whether key exists. |
d.iter() | iter | Lazy 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.
| Method | Returns | Description |
|---|---|---|
r.is_ok() | bool | true when r is Result::Ok(_). |
r.is_err() | bool | true when r is Result::Err(_). |
r.unwrap() | value | Payload on Ok; raises a runtime error on Err (message includes the .inspect() of the payload). |
r.expect(msg) | value | Payload on Ok; raises with msg on Err. |
r.unwrap_or(default) | value | Payload on Ok; default on Err. |
r.map(f) | Result | Ok(v) → Ok(f(v)); Err(e) passes through. |
r.map_err(f) | Result | Err(e) → Err(f(e)); Ok(v) passes through. |
r.and_then(f) | Result | Ok(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:
v.iter()returns an iterator.it.next()advances it, returningIter::Next(value)orIter::Done.
for x in v uses this protocol, so anything with a working .iter() method works with for.
| Method | Returns | Description |
|---|---|---|
it.next() | Iter::Next(v) / Iter::Done | Advance by one. Cloning an iterator shares its cursor (like Python / Rust / JS) — two names pointing at the same iterator advance together. |
it.iter() | iter | Returns 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.
| Function | Returns | Description |
|---|---|---|
print(args...) | none | Host-captured output |
range(n) / range(s, e) / range(s, e, step) | array | Integer range |
rand(n) | int | Pseudo-random 0..n |
try_call(f) | Result | Run f, return Ok(v) or Err(RuntimeError) |
panic(message) | never returns | Raise 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,nonebreak,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
| Module | What it gives you |
|---|---|
std.math | PI, E, TAU, clamp, sign, factorial, gcd, lcm, mean |
std.iter | map, filter, reduce, take, drop, zip, enumerate, all, any, count, find, find_index, flatten, sum, product, min_array, max_array |
std.collections | Stack, Queue, Set as value-semantics structs |
std.string | pad_left, pad_right, center, chars, reverse, is_palindrome, count, join |
std.json | parse, stringify (RFC-8259, pure Bop) |
std.test | assert, 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
Resultcombinators —is_ok,is_err,unwrap,expect,unwrap_or,map,map_err,and_thenused to live instd.result. They’re now methods on the built-inResulttype 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 numbers —
abs,sqrt,sin,cos,floor,ceil,round,pow,log,exp,min,max,to_int,to_floatare methods onint/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 wrapf64::*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).
| Name | Value |
|---|---|
PI | 3.141592653589793 |
E | 2.718281828459045 |
TAU | 6.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
| Method | Returns | Notes |
|---|---|---|
stack() | Stack | Empty constructor |
s.is_empty() | bool | |
s.size() | int | |
s.push(v) | Stack | New stack with v on top |
s.top() | value | Top element, or none if empty |
s.pop() | Stack | New 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"
| Method | Returns | Notes |
|---|---|---|
queue() | Queue | Empty constructor |
q.is_empty() | bool | |
q.size() | int | |
q.enqueue(v) | Queue | New queue with v at the back |
q.front() | value | Front element, or none if empty |
q.dequeue() | Queue | New 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]
| Method | Returns | Notes |
|---|---|---|
set() | Set | Empty constructor |
set_of(arr) | Set | From an array (duplicates collapse, first-seen wins) |
s.is_empty() | bool | |
s.size() | int | |
s.has(v) | bool | |
s.add(v) | Set | New set with v. Idempotent. |
s.remove(v) | Set | New set without v. Removing absent is a no-op. |
s.values() | array | Elements in insertion order |
s.union(other) | Set | |
s.intersect(other) | Set | |
s.difference(other) | Set | self 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 type | Bop type |
|---|---|
| number (integer) | int |
| number (decimal / exponent) | number |
| string | string |
| boolean | bool |
| null | none |
| array | array |
| object | dict (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/\fescapes are rejected. Rare in real payloads.\uXXXXescapes 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:
| Function | Description |
|---|---|
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:
| Engine | Entry point | When it’s best |
|---|---|---|
| Tree-walker | bop::run(src, host, limits) | Lowest start-up cost. Best for one-off scripts, REPL, small inputs, no_std / wasm. |
| Bytecode VM | bop_vm::run(src, host, limits) | 2–3× faster than the walker on hot loops. Same program, no compilation to disk. |
| AOT transpiler | bop_compile::transpile(src, opts) → cargo build | Bop → 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 “modulefoonot 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
&AtomicBoolset 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:
| Preset | max_steps | max_memory |
|---|---|---|
BopLimits::standard() | 10,000 | 10 MB |
BopLimits::demo() | 1,000 | 1 MB |
Custom:
#![allow(unused)]
fn main() {
let limits = BopLimits {
max_steps: 50_000,
max_memory: 32 * 1024 * 1024,
};
}
Limit violations are fatal — try_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)),
}
}