use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use rhai::{Dynamic, Engine as RhaiEngine, Module, Scope, AST};
use rux_reactive::{Value, Warning};
thread_local! {
static READS: RefCell<Option<HashSet<String>>> = const { RefCell::new(None) };
}
pub struct Builder {
engine: RhaiEngine,
host: Module,
}
impl Default for Builder {
fn default() -> Self {
Self::new()
}
}
impl Builder {
pub fn new() -> Self {
let mut engine = RhaiEngine::new();
engine.register_fn("signal", |x: Dynamic| -> Dynamic {
match x.as_int() {
Ok(i) => Dynamic::from(i as f64),
Err(_) => x,
}
});
#[allow(deprecated)]
engine.on_var(|name, _index, _context| {
READS.with(|r| {
if let Some(set) = r.borrow_mut().as_mut() {
set.insert(name.to_string());
}
});
Ok(None)
});
Self {
engine,
host: Module::new(),
}
}
pub fn host_number(
&mut self,
name: &str,
f: impl Fn() -> f64 + Send + Sync + 'static,
) -> &mut Self {
self.host.set_native_fn(name, move || -> Result<f64, Box<rhai::EvalAltResult>> {
Ok(f())
});
self
}
pub fn build(mut self, script: &str) -> Result<Engine, String> {
self.engine
.register_static_module("host", self.host.into());
let ast = self.engine.compile(script).map_err(|e| e.to_string())?;
let mut scope = Scope::new();
self.engine
.run_ast_with_scope(&mut scope, &ast)
.map_err(|e| e.to_string())?;
let funcs = ast.clone_functions_only();
let signals = scope.iter().map(|(name, _, _)| name.to_string()).collect();
Ok(Engine {
engine: self.engine,
scope,
funcs,
signals,
})
}
}
pub struct Engine {
engine: RhaiEngine,
scope: Scope<'static>,
funcs: AST,
signals: HashSet<String>,
}
thread_local! {
static WARNINGS: RefCell<Vec<Warning>> = const { RefCell::new(Vec::new()) };
}
fn warn(message: String) {
WARNINGS.with(|w| {
let mut w = w.borrow_mut();
if !w.iter().any(|existing: &Warning| existing.message == message) {
if ECHO.with(|e| e.get()) {
eprintln!("rux: {message}");
}
w.push(Warning::new(message));
}
});
}
thread_local! {
static ECHO: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
}
pub fn set_stderr_echo(on: bool) {
ECHO.with(|e| e.set(on));
}
pub fn take_warnings() -> Vec<Warning> {
WARNINGS.with(|w| std::mem::take(&mut *w.borrow_mut()))
}
fn trim_expr(src: &str) -> String {
let flat: String = src.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() > 60 {
format!("{}…", flat.chars().take(60).collect::<String>())
} else {
flat
}
}
fn strip_rhai_position(message: &str) -> String {
let trimmed = message.trim_end();
let Some(open) = trimmed.rfind(" (line ") else {
return trimmed.to_string();
};
let Some(inner) = trimmed[open + 1..].strip_prefix('(') else {
return trimmed.to_string();
};
let Some(inner) = inner.strip_suffix(')') else {
return trimmed.to_string();
};
let Some(rest) = inner.strip_prefix("line ") else {
return trimmed.to_string();
};
let Some((line, position)) = rest.split_once(", position ") else {
return trimmed.to_string();
};
let numeric = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit());
if numeric(line) && numeric(position) {
trimmed[..open].trim_end().to_string()
} else {
trimmed.to_string()
}
}
impl Engine {
fn eval(&mut self, src: &str, locals: &[(String, Value)]) -> Option<Dynamic> {
let ast = match self.engine.compile(src) {
Ok(ast) => ast,
Err(e) => {
warn(format!(
"expression `{}` failed to compile: {}",
trim_expr(src),
strip_rhai_position(&e.to_string())
));
return None;
}
};
let merged = self.funcs.merge(&ast);
let base = self.scope.len();
for (name, value) in locals {
self.scope.push(name.clone(), to_dynamic(value));
}
let result = self.engine.eval_ast_with_scope::<Dynamic>(&mut self.scope, &merged);
self.scope.rewind(base); match result {
Ok(value) => Some(value),
Err(e) => {
warn(format!(
"expression `{}` failed: {}",
trim_expr(src),
strip_rhai_position(&e.to_string())
));
None
}
}
}
pub fn eval_value(&mut self, src: &str, locals: &[(String, Value)]) -> Option<Value> {
self.eval(src, locals).map(|d| from_dynamic(&d))
}
pub fn eval_display(&mut self, src: &str, locals: &[(String, Value)]) -> String {
self.eval_value(src, locals)
.map(|v| v.to_display())
.unwrap_or_default()
}
pub fn eval_bool(&mut self, src: &str, locals: &[(String, Value)]) -> bool {
self.eval_value(src, locals)
.map(|v| v.is_truthy())
.unwrap_or(false)
}
pub fn run_handler(&mut self, src: &str) -> bool {
self.eval(src, &[]).is_some()
}
pub fn eval_value_tracked(
&mut self,
src: &str,
locals: &[(String, Value)],
) -> (Option<Value>, HashSet<String>) {
READS.with(|r| *r.borrow_mut() = Some(HashSet::new()));
let value = self.eval_value(src, locals);
let mut reads = READS.with(|r| r.borrow_mut().take()).unwrap_or_default();
reads.retain(|n| self.signals.contains(n));
(value, reads)
}
pub fn eval_display_tracked(
&mut self,
src: &str,
locals: &[(String, Value)],
) -> (String, HashSet<String>) {
let (value, deps) = self.eval_value_tracked(src, locals);
(value.map(|v| v.to_display()).unwrap_or_default(), deps)
}
pub fn eval_bool_tracked(
&mut self,
src: &str,
locals: &[(String, Value)],
) -> (bool, HashSet<String>) {
let (value, deps) = self.eval_value_tracked(src, locals);
(value.map(|v| v.is_truthy()).unwrap_or(false), deps)
}
pub fn run_handler_tracked(&mut self, src: &str) -> HashSet<String> {
let names: Vec<String> = self.signals.iter().cloned().collect();
let before: HashMap<String, Option<Value>> =
names.iter().map(|n| (n.clone(), self.read_signal(n))).collect();
if !self.run_handler(src) {
return HashSet::new();
}
names
.into_iter()
.filter(|n| self.read_signal(n) != before[n])
.collect()
}
fn read_signal(&self, name: &str) -> Option<Value> {
self.scope.get_value::<Dynamic>(name).map(|d| from_dynamic(&d))
}
pub fn get_string(&mut self, name: &str) -> String {
self.eval_value(name, &[]).map(|v| v.to_display()).unwrap_or_default()
}
pub fn set_string(&mut self, name: &str, value: &str) {
self.scope.set_or_push(name, value.to_string());
}
}
fn to_dynamic(v: &Value) -> Dynamic {
match v {
Value::Number(n) => Dynamic::from(*n),
Value::Text(s) => Dynamic::from(s.clone()),
Value::Bool(b) => Dynamic::from(*b),
Value::List(items) => {
let arr: rhai::Array = items.iter().map(to_dynamic).collect();
Dynamic::from(arr)
}
Value::Map(entries) => {
let map: rhai::Map =
entries.iter().map(|(k, v)| (k.as_str().into(), to_dynamic(v))).collect();
Dynamic::from(map)
}
}
}
fn from_dynamic(d: &Dynamic) -> Value {
if let Ok(i) = d.as_int() {
return Value::Number(i as f64);
}
if let Ok(f) = d.as_float() {
return Value::Number(f);
}
if let Ok(b) = d.as_bool() {
return Value::Bool(b);
}
if let Some(s) = d.clone().try_cast::<String>() {
return Value::Text(s);
}
if let Some(arr) = d.clone().try_cast::<rhai::Array>() {
return Value::List(arr.iter().map(from_dynamic).collect());
}
if let Some(map) = d.clone().try_cast::<rhai::Map>() {
return Value::Map(
map.iter().map(|(k, v)| (k.to_string(), from_dynamic(v))).collect(),
);
}
Value::Text(d.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_failing_expression_is_reported() {
let mut e = engine();
let _ = take_warnings();
assert_eq!(e.eval_display("nope(1)", &[]), "", "still degrades to empty");
let warnings = take_warnings();
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].message.contains("nope(1)"), "names the expression: {warnings:?}");
assert!(take_warnings().is_empty(), "draining empties the sink");
}
#[test]
fn a_failing_expression_does_not_quote_a_line_that_is_not_in_the_file() {
let mut e = engine();
let _ = take_warnings();
let _ = e.eval_display("names", &[]); let warnings = take_warnings();
assert_eq!(warnings.len(), 1, "{warnings:?}");
let message = &warnings[0].message;
assert!(message.contains("Variable not found"), "keeps the cause: {message}");
assert!(message.contains("names"), "keeps the expression: {message}");
assert!(
!message.contains("line 1"),
"must not report a line that is not a line of the file: {message}"
);
assert!(!message.contains("position"), "nor a position: {message}");
}
#[test]
fn stripping_the_position_leaves_other_parentheses_alone() {
assert_eq!(
strip_rhai_position("Variable not found: names (line 1, position 1)"),
"Variable not found: names"
);
assert_eq!(strip_rhai_position("something (line 4)"), "something (line 4)");
assert_eq!(
strip_rhai_position("call to fn(a, b) failed"),
"call to fn(a, b) failed"
);
assert_eq!(strip_rhai_position("plain message"), "plain message");
assert_eq!(
strip_rhai_position("x (line one, position two)"),
"x (line one, position two)"
);
}
#[test]
fn repeated_failures_are_reported_once() {
let mut e = engine();
let _ = take_warnings();
for _ in 0..5 {
let _ = e.eval_display("nope(1)", &[]);
}
assert_eq!(take_warnings().len(), 1);
}
#[test]
fn a_good_expression_is_silent() {
let mut e = engine();
let _ = take_warnings();
assert_eq!(e.eval_display("double(4)", &[]), "8");
assert!(take_warnings().is_empty());
}
fn engine() -> Engine {
let mut b = Builder::new();
b.host_number("full", || 100.0);
b.build(
"let level = signal(82); \
let items = signal([1, 2, 3]); \
fn double(x) { x * 2 }",
)
.expect("build engine")
}
#[test]
fn reads_and_evaluates_state() {
let mut e = engine();
assert_eq!(e.eval_display("level", &[]), "82");
assert_eq!(e.eval_display("level - 2", &[]), "80");
assert!(e.eval_bool("level > 50", &[]));
assert!(!e.eval_bool("level < 20", &[]));
}
#[test]
fn runs_inline_handlers_and_pure_fns() {
let mut e = engine();
e.run_handler("level = level - 5"); assert_eq!(e.eval_display("level", &[]), "77");
e.run_handler("level = level + 3");
assert_eq!(e.eval_display("level", &[]), "80");
assert_eq!(e.eval_display("double(level)", &[]), "160");
}
#[test]
fn evaluates_backtick_string_interpolation() {
let mut e = engine(); assert_eq!(
e.eval_display("`background: ${c}`", &[("c".into(), Value::Text("teal".into()))]),
"background: teal"
);
assert_eq!(e.eval_display("`level is ${level}`", &[]), "level is 82.0");
let (_, deps) = e.eval_value_tracked("`level: ${level}`", &[]);
assert!(deps.contains("level"));
}
#[test]
fn calls_host_functions() {
let mut e = engine();
e.run_handler("level = host::full()");
assert_eq!(e.eval_display("level", &[]), "100");
}
#[test]
fn lists_and_locals() {
let mut e = engine();
let items = e.eval_value("items", &[]).unwrap();
assert_eq!(items.as_list().unwrap().len(), 3);
assert_eq!(e.eval_display("x + 1", &[("x".into(), Value::Number(4.0))]), "5");
}
fn deps(e: &mut Engine, src: &str, locals: &[(String, Value)]) -> Vec<String> {
let (_, set) = e.eval_value_tracked(src, locals);
let mut v: Vec<String> = set.into_iter().collect();
v.sort();
v
}
#[test]
fn tracks_binding_dependencies() {
let mut e = engine();
assert_eq!(deps(&mut e, "level", &[]), ["level"]);
assert_eq!(deps(&mut e, "level > 20", &[]), ["level"]);
assert_eq!(deps(&mut e, "double(level)", &[]), ["level"]);
assert_eq!(deps(&mut e, "x + 1", &[("x".into(), Value::Number(4.0))]), Vec::<String>::new());
assert_eq!(deps(&mut e, "x + level", &[("x".into(), Value::Number(4.0))]), ["level"]);
assert_eq!(deps(&mut e, "level + items[0]", &[]), ["items", "level"]);
}
#[test]
fn tracks_handler_writes() {
let mut e = engine();
let changed = |e: &mut Engine, src: &str| {
let mut v: Vec<String> = e.run_handler_tracked(src).into_iter().collect();
v.sort();
v
};
assert_eq!(changed(&mut e, "level = level - 5"), ["level"]);
assert_eq!(e.eval_display("level", &[]), "77");
assert_eq!(changed(&mut e, "level = level"), Vec::<String>::new());
assert_eq!(changed(&mut e, "items = [9]"), ["items"]);
assert_eq!(changed(&mut e, "level"), Vec::<String>::new()); }
}