Parse once, call many Rhai ASTs, script functions, and errors with positions
One pricing rule compiled at startup, called 20,000 times, and broken five ways, each failure reported with the line and column where it happened.
Tested on: rustc 1.99.0, cargo 1.99.0, rhai 1.27.0 (resolved from rhai = "1"), macOS, release build. Every output below is pasted from that run.
§I. Frame
The earlier lesson covered the basics: an Engine, a Scope, registering host types, and set_max_operations. This lesson covers a different shape of program. A service keeps a pricing rule as script text, so the rule can change without rebuilding the service. Every request needs a price computed by that rule.
The simplest way to do this is to call Engine::eval with the rule text on every request. That works, but each call reads the text, parses it, and builds a syntax tree before it runs anything. The text did not change between requests, so the parse result is identical every time. You pay for the same parse on every request. §III measures this on one machine.
The second reason is about when errors surface. A typo in a rule should be found when the service starts (or when someone loads a new rule), not when a customer asks for a quote. Rhai gives you two moments where a script can fail:
- Compile time. The text is parsed into an
AST. Syntax mistakes fail here and return aParseError. - Call time. The
ASTis evaluated or one of its functions is called. Failures here return aBox<EvalAltResult>.
Which errors land in which moment is partly up to you. Some engine settings move errors from call time to compile time. The theme of this lesson is to push as many mistakes as possible to compile time, and to make the remaining errors say where in the script they happened.
The earlier lesson called eval on a fresh string each time, which is fine for a REPL and for a first look. A service that prices thousands of orders a minute is a different era of the same program: the text is loaded once and the parsed form does the work.
You will work through four techniques, then run a small crate that uses all of them. The sources are The Rhai Book (rhai.rs/book) and the docs.rs pages for rhai. They are named inline where a claim comes from them.
§II. Four techniques
1. Compile once with Engine::compile and keep the AST
Engine::compile takes script text and returns Result<AST, ParseError>. The AST is the parsed script: its top-level statements and the functions it defines. The Rhai Book chapter "Compile to AST" describes this as the way to avoid parsing a script again when you run it many times.
let ast = engine.compile(src)?;
AST has no lifetime parameter, so you can store it in a struct next to the Engine and keep it for the life of the process. In the crate below, main compiles the rule once and then passes &ast to every call.
The rule script in this lesson defines two functions, discount and price, and one top-level statement, let banner = "pricing v2";. After compiling, main calls ast.iter_functions() and prints the names. That shows the script's functions are part of the AST itself, so you can call them later by name without the source text.
2. Call a script function with call_fn_with_options and eval_ast(false)
To call a script function from Rust, you pass the AST, the function name, and the arguments. Engine::call_fn does this, but there is a detail you need to know before you use it on a script that has top-level statements.
According to the Rhai Book ("Call Rhai Functions from Rust") and docs.rs, plain call_fn evaluates the AST's top-level statements before it calls the function. For a script that contains only functions, there is nothing to evaluate and you will not notice. If the script also has a top-level let, a print, or a loop, that code runs again on every call.
CallFnOptions controls this. Building one with CallFnOptions::new().eval_ast(false) tells the engine to skip the top-level statements and only call the function:
let mut scope = Scope::new();
let opts = CallFnOptions::new().eval_ast(false);
let out = engine.call_fn_with_options::<i64>(
opts, &mut scope, &ast, "price", (total, tier.to_string()),
)?;
The turbofish ::<i64> is the type you expect back. The arguments go in as a tuple. The crate creates a new Scope for each call, so one call cannot see state left behind by another.
The other half is Engine::run_ast_with_scope. It runs the top-level statements of an AST once, and variables declared at the top level stay in the Scope you passed in. The crate's banner function uses it: it runs the AST, then reads banner back out of the scope with get_value::<String>. You would use this for load-time setup that a rule needs once, not on every request.
The crate keeps the two apart on purpose. price never runs top-level statements. banner is the only function that does.
3. Turn errors into line and column
Both error types can tell you where the problem is:
ParseError::position()returns aPosition.ParseError::err_type()returns the kind of parse error.EvalAltResult::position()returns aPosition.Position::line()andPosition::position()each return anOption<usize>.
The Option matters. Not every error comes from a place in the script text, so a Position can have no line or no column. If you unwrap blindly, you will panic while trying to report an error. The crate's helper handles all three cases:
pub fn where_(pos: Position) -> String {
match (pos.line(), pos.position()) {
(Some(l), Some(c)) => format!("line {l}, col {c}"),
(Some(l), None) => format!("line {l}"),
_ => "no position".to_string(),
}
}
The function is named where_ because where is a Rust keyword.
Strict variables mode changes which of the two error moments you get for one common mistake. The Rhai Book ("Strict Variables Mode") describes it: with set_strict_variables(true), a reference to a variable that was never declared is an error when the script is compiled, rather than an error when that line runs. In this crate, a rule that mentions rebate without declaring it never makes it to the first customer request. It fails at startup with a line and column.
Strict variables covers variable names. It does not cover everything. In the output in §III, a call to a function that does not exist still shows up at call time.
4. Sandbox the engine before you compile
The crate builds its engine in one place:
pub fn engine() -> Engine {
let mut e = Engine::new();
e.set_strict_variables(true);
e.set_max_operations(10_000);
e.disable_symbol("eval");
e
}
Each call does one job:
set_strict_variables(true)moves undeclared-variable errors to compile time, as above.set_max_operations(10_000)makes a script that runs too long stop with an error instead of running forever. The Rhai Book covers this under "Maximum Number of Operations". The limit applies at call time, because it counts work done while running.disable_symbol("eval")removes theevalkeyword from the language for this engine. The Rhai Book covers it under "Disable Keywords and/or Operators". A disabled symbol is checked at compile time, so a script that usesevalnever gets as far as running. The error arrives fromcompile.
Set all of this before you call compile. The disabled-symbol check happens during compilation, so an engine has to be configured first for the check to apply to the script you are compiling.
§III. Run it
The crate has a Cargo.toml, a library, a binary that exercises it, and an integration test file.
Cargo.toml
[package]
name = "duha_rhai_ast"
version = "0.1.0"
edition = "2024"
[dependencies]
rhai = "1"
src/lib.rs
//! Compile a pricing rule once, reuse the AST, call script functions from Rust,
//! and turn script errors into messages that carry a line and column.
use rhai::{CallFnOptions, Dynamic, Engine, EvalAltResult, ParseError, Position, Scope, AST};
pub const RULE: &str = r#"
// top-level statement: runs whenever the AST is evaluated
let banner = "pricing v2";
fn discount(total, tier) {
if tier == "gold" { total * 10 / 100 }
else if tier == "silver" { total * 5 / 100 }
else { 0 }
}
fn price(total, tier) {
total - discount(total, tier)
}
"#;
/// A sandboxed engine: strict variables, operation budget, no `eval`.
pub fn engine() -> Engine {
let mut e = Engine::new();
e.set_strict_variables(true);
e.set_max_operations(10_000);
e.disable_symbol("eval");
e
}
/// Where a problem happened, in the script's own coordinates.
pub fn where_(pos: Position) -> String {
match (pos.line(), pos.position()) {
(Some(l), Some(c)) => format!("line {l}, col {c}"),
(Some(l), None) => format!("line {l}"),
_ => "no position".to_string(),
}
}
pub fn compile(e: &Engine, src: &str) -> Result<AST, String> {
e.compile(src)
.map_err(|err: ParseError| format!("parse error at {}: {}", where_(err.position()), err.err_type()))
}
/// Call `price` without re-running top-level statements.
pub fn price(e: &Engine, ast: &AST, total: i64, tier: &str) -> Result<i64, String> {
let mut scope = Scope::new();
let opts = CallFnOptions::new().eval_ast(false);
e.call_fn_with_options::<i64>(opts, &mut scope, ast, "price", (total, tier.to_string()))
.map_err(|err: Box<EvalAltResult>| format!("runtime error at {}: {}", where_(err.position()), err))
}
/// Evaluate the AST's top-level statements once and read what they left in scope.
pub fn banner(e: &Engine, ast: &AST) -> Result<String, String> {
let mut scope = Scope::new();
e.run_ast_with_scope(&mut scope, ast).map_err(|err| err.to_string())?;
scope
.get_value::<String>("banner")
.ok_or_else(|| "banner not set".to_string())
}
pub fn eval_line(e: &Engine, src: &str) -> Result<Dynamic, String> {
e.eval::<Dynamic>(src)
.map_err(|err| format!("error at {}: {}", where_(err.position()), err))
}
src/main.rs
use duha_rhai_ast::*;
use std::time::Instant;
fn main() {
let e = engine();
let ast = compile(&e, RULE).expect("rule compiles");
println!("functions in AST: {:?}", ast.iter_functions().map(|f| f.name).collect::<Vec<_>>());
println!("banner: {}", banner(&e, &ast).unwrap());
for (total, tier) in [(1000, "gold"), (1000, "silver"), (1000, "bronze")] {
println!("price({total}, {tier}) = {}", price(&e, &ast, total, tier).unwrap());
}
// compile once, call many vs compile every time
let n = 20_000;
let t = Instant::now();
for i in 0..n { price(&e, &ast, i, "gold").unwrap(); }
let reuse = t.elapsed();
let t = Instant::now();
for i in 0..n { let a = compile(&e, RULE).unwrap(); price(&e, &a, i, "gold").unwrap(); }
let recompile = t.elapsed();
println!("{n} calls: reuse AST {:?}, recompile each time {:?}", reuse, recompile);
// errors with positions
let bad_syntax = "fn price(total, tier) {\n total - \n}";
println!("{}", compile(&e, bad_syntax).unwrap_err());
let undeclared = "fn price(total, tier) {\n total - rebate\n}";
println!("{}", compile(&e, undeclared).unwrap_err());
let ast2 = compile(&e, "fn price(total, tier) {\n total - lookup(tier)\n}").unwrap();
println!("{}", price(&e, &ast2, 10, "gold").unwrap_err());
println!("{}", eval_line(&e, "let x = 1;\nloop { x += 1; }").unwrap_err());
println!("{}", compile(&e, "eval(\"1+1\")").unwrap_err());
}
tests/rule.rs
use duha_rhai_ast::*;
#[test]
fn ast_is_reused_across_calls() {
let e = engine();
let ast = compile(&e, RULE).unwrap();
assert_eq!(price(&e, &ast, 1000, "gold").unwrap(), 900);
assert_eq!(price(&e, &ast, 1000, "silver").unwrap(), 950);
assert_eq!(price(&e, &ast, 1000, "none").unwrap(), 1000);
}
#[test]
fn parse_error_carries_line() {
let e = engine();
let err = compile(&e, "fn price(total, tier) {\n total - \n}").unwrap_err();
assert!(err.contains("line 3"), "{err}");
}
#[test]
fn strict_variables_reject_undeclared_name_at_compile_time() {
let e = engine();
let err = compile(&e, "fn f() {\n rebate\n}").unwrap_err();
assert!(err.contains("line 2"), "{err}");
}
#[test]
fn missing_function_is_a_runtime_error_with_position() {
let e = engine();
let ast = compile(&e, "fn price(total, tier) {\n total - lookup(tier)\n}").unwrap();
let err = price(&e, &ast, 10, "gold").unwrap_err();
assert!(err.contains("line 2"), "{err}");
}
#[test]
fn runaway_loop_hits_operation_budget() {
let e = engine();
let err = eval_line(&e, "let x = 1;\nloop { x += 1; }").unwrap_err();
assert!(err.to_lowercase().contains("too many operations"), "{err}");
}
The test run
cargo test --release, tail:
running 5 tests
test strict_variables_reject_undeclared_name_at_compile_time ... ok
test parse_error_carries_line ... ok
test missing_function_is_a_runtime_error_with_position ... ok
test ast_is_reused_across_calls ... ok
test runaway_loop_hits_operation_budget ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Each test maps to one idea. ast_is_reused_across_calls compiles once and calls three times with different tiers. parse_error_carries_line and strict_variables_reject_undeclared_name_at_compile_time check that compile-time failures name a line. missing_function_is_a_runtime_error_with_position checks the same for a call-time failure. runaway_loop_hits_operation_budget checks that the operation limit stops a loop with no exit.
The program output
cargo run --release -q:
functions in AST: ["discount", "price"]
banner: pricing v2
price(1000, gold) = 900
price(1000, silver) = 950
price(1000, bronze) = 1000
20000 calls: reuse AST 11.07ms, recompile each time 147.083792ms
parse error at line 3, col 1: Unexpected '}'
parse error at line 2, col 13: Undefined variable: rebate
runtime error at line 2, col 13: Function not found: lookup (&str | ImmutableString | String) (line 2, position 13)
error at line 2, col 13: Too many operations (line 2, position 13)
parse error at line 1, col 1: reserved keyword 'eval' is disabled
Walking through each line
**functions in AST: ["discount", "price"]**. The AST holds the two functions the script defined. The top-level let banner is not a function and does not appear here.
**banner: pricing v2**. banner() ran the top-level statements once with run_ast_with_scope, and the let left banner in the scope. This is the only place the crate uses top-level evaluation.
**The three price lines**. Gold takes 10 percent off 1000, which gives 900. Silver takes 5 percent, which gives 950. Any other tier falls through to the else branch, a discount of 0, so bronze stays at 1000. These calls use eval_ast(false), so the let banner statement did not run for any of them.
The timing line. Both loops make 20,000 calls to price. The first reuses one AST. The second compiles the rule text before every call. The reuse loop took 11.07ms and the recompile loop took 147.083792ms. That is one run on one laptop, so your absolute numbers will differ. The point is the ratio, about 13 times here, and the reason for it: the second loop repeats parsing that the first loop did once.
**parse error at line 3, col 1: Unexpected '}'**. The source was total - on line 2 followed by a closing brace on line 3. The parser was waiting for something after the minus sign and found } instead. So the position points at the token where the parser gave up (line 3), not at the line where you started writing the expression (line 2). Remember this when you read parse errors: look at the reported line and the line above it.
**parse error at line 2, col 13: Undefined variable: rebate**. This is strict variables mode at work. The script never ran. compile rejected it. Line 2 is total - rebate, and column 13 is the first character of rebate (four spaces plus total - is twelve characters).
**runtime error at line 2, col 13: Function not found: lookup (...) (line 2, position 13)**. This script compiled without complaint, because strict variables does not check function calls. The failure arrived when price was called, and the message names the missing function. Notice that the text says the position twice: once from the helper's runtime error at line 2, col 13: prefix and once in the (line 2, position 13) suffix. The suffix comes from the Display output of EvalAltResult, which already appends the position. In a real service, pick one. You can log the Display text as it is, or you can drop the helper's prefix and keep the position as separate structured fields. The compile-time lines have no suffix because the helper uses err.err_type() for those, not the whole ParseError.
**error at line 2, col 13: Too many operations (line 2, position 13)**. The script was let x = 1; and then loop { x += 1; }. With a limit of 10,000 operations the engine stopped it. The position is on line 2, the loop line, at column 13, which falls inside the loop's braces. So the error points at the work that was running when the limit was hit. It repeats the position for the same reason as the previous line.
**parse error at line 1, col 1: reserved keyword 'eval' is disabled**. disable_symbol("eval") was set on the engine, and the script was never run. The error came from compile, which is the behavior §II.4 described.
§IV. Wiring it into a service
The crate above is the core of a service. What a service adds is somewhere to keep the Engine and the AST so that request handlers can reach them.
Build both at startup, before you accept traffic. If the rule fails to compile, refuse to start and print the line-and-column message. A bad rule file at startup is a deploy problem, and you want to learn about it from the process exit, not from a failed customer request.
Then store them in shared state. In Axum that is usually a struct wrapped in an Arc and passed to the router as State. The sketch below is not compiled or run in this lesson. It shows the shape:
use std::sync::{Arc, RwLock};
use rhai::{Engine, AST};
pub struct Rules {
engine: Engine,
ast: RwLock<Arc<AST>>,
}
impl Rules {
pub fn load(src: &str) -> Result<Self, String> {
let engine = duha_rhai_ast::engine();
let ast = duha_rhai_ast::compile(&engine, src)?;
Ok(Self { engine, ast: RwLock::new(Arc::new(ast)) })
}
pub fn price(&self, total: i64, tier: &str) -> Result<i64, String> {
let ast = self.ast.read().unwrap().clone();
duha_rhai_ast::price(&self.engine, &ast, total, tier)
}
}
The state then goes into the router with .with_state(Arc::new(rules)), and a handler takes State<Arc<Rules>> and calls rules.price(...). The handler clones the Arc<AST> out of the lock and releases the lock before it runs the script, so a slow script does not block a reload.
Threads. An Axum server handles requests on multiple threads, so the Engine and the AST have to be safe to share across them. The Rhai Book documents a sync feature for this: Engine and AST are usable from multiple threads only when that feature is enabled. Treat this as a requirement to check in the Rhai Book before you write the service, including what else the feature asks of your code. This lesson does not enable sync and does not share anything across threads, so nothing here demonstrates it.
Reload. To change the rule without restarting, add a method that compiles the new text and replaces the stored AST only if compilation succeeded:
pub fn reload(&self, src: &str) -> Result<(), String> {
let ast = duha_rhai_ast::compile(&self.engine, src)?;
*self.ast.write().unwrap() = Arc::new(ast);
Ok(())
}
The ? returns early on a compile error, and the old AST stays in place. Requests keep getting prices from the last good rule. Because the engine has strict variables and eval disabled, a bad rule is rejected at this step, not at the first request that uses it.
Logging. When reload or load fails, log the string you got back. It already carries the line and column, so whoever edits the rule file can find the mistake. Do the same for call-time errors from price, and keep the duplicate-position point from §III in mind when you decide how to format them. Log the script's error text and return a plain server error to the caller. A customer does not need to see your script's line numbers.
Calling the script is synchronous CPU work inside your handler. The operation limit is what bounds it, so keep set_max_operations in the engine you build for the service.
§V. Drills
Each drill uses this crate. Each one names what to check, not what the output will be.
- See the top-level statements run on every call. In
price, changeeval_ast(false)toeval_ast(true). Addprint("loaded");as a top-level statement inRULE. Runcargo run --release -q. Check how many timesloadedappears in the output compared with the number ofpricecalls, and check what happens to the timing line. Then change it back and confirmloadedis gone from the price calls.
- Write a reload that keeps the old rule. Add a function
reload(e: &Engine, current: AST, new_src: &str) -> ASTtolib.rsthat returns the newASTifnew_srccompiles andcurrentif it does not. Write two tests intests/rule.rs. In the first, a good replacement changes a price. In the second, a replacement with a syntax error leaves the original prices unchanged. Check that both pass.
- Turn strict variables off. In
engine(), change the argument toset_strict_variables(false)and runcargo test --release. Check which test fails. Then run the binary: it should panic at theunwrap_err()on therebateline, becausecompilenow returnsOk. Change that line to compile the script and callpriceon the result, run again, and compare the error's moment (call time, not compile time) and position with what you saw before.
- Find the edge of the budget. Lower the number passed to
set_max_operationsand run the binary. Check at what value a normalprice(1000, gold)call starts to fail, and check that the message says "Too many operations" with a position inside thepriceordiscountfunction. Then restore10_000.
Close
Compile the rule once, keep the AST next to the Engine, and call its functions with call_fn_with_options and eval_ast(false) so that top-level statements do not run again on every request. Configure the engine before you compile, so that undeclared variables and disabled keywords fail at startup. Turn every error into a message with a line and a column, and decide once how much of the position text you log. The code here is small, and the habits carry over to any service that runs script rules on a request path.
Sources
- The Rhai Book: https://rhai.rs/book/ (chapters "Compile to AST", "Call Rhai Functions from Rust", "Strict Variables Mode", "Disable Keywords and/or Operators", "Maximum Number of Operations")
rhaiAPI documentation: https://docs.rs/rhai/latest/rhai/ (Engine,AST,CallFnOptions,ParseError,EvalAltResult,Position)- The Rust Programming Language, ch. 9.2 "Recoverable Errors with Result" and ch. 16.3 "Shared-State Concurrency" (for the
Arcand lock in §IV)
Related
- Rhai embed: Engine, Scope, host types, and operation limits (first pass)
- Rune embed: context, modules, VM, and budget (the alternate embed)