Skip to main content

rux_script/
lib.rs

1//! Rux script tier, milestone M8.
2//!
3//! Wraps a `rhai` engine that holds the app's live state (the script's top-level
4//! `let` variables persist in a `Scope`) and evaluates `{{ }}` bindings,
5//! `r-if`/`r-for` expressions, and `@tap` handlers against it. Native
6//! capabilities are exposed under the `host::` namespace via the builder.
7//!
8//! This replaces the M5 signal reader and the M6 inline-expression evaluator
9//! with a real scripting language: named `fn` handlers, full expressions, and
10//! the compiled-Rust boundary (`docs/04-architecture.md`, script/host tiers).
11
12use std::cell::RefCell;
13use std::collections::{HashMap, HashSet};
14
15use rhai::{Dynamic, Engine as RhaiEngine, Module, Scope, AST};
16use rux_reactive::{Value, Warning};
17
18thread_local! {
19    /// While `Some`, every signal read during evaluation is recorded here. This is
20    /// how a binding discovers which signals it depends on (fine-grained
21    /// reactivity groundwork): we switch it on around one binding's evaluation,
22    /// evaluate, then take the set. `None` means "not tracking", so ordinary
23    /// evaluation (and the build-time script run) records nothing.
24    static READS: RefCell<Option<HashSet<String>>> = const { RefCell::new(None) };
25}
26
27/// Builds an [`Engine`]: register host functions, then `build` with the script.
28/// Host functions must be registered before the script runs, since the script
29/// may call them during initialization.
30pub struct Builder {
31    engine: RhaiEngine,
32    host: Module,
33}
34
35impl Default for Builder {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41impl Builder {
42    pub fn new() -> Self {
43        let mut engine = RhaiEngine::new();
44        // `signal(x)` is identity: `let level = signal(82)` just binds `level`.
45        // Numbers are coerced to float so arithmetic stays consistent.
46        engine.register_fn("signal", |x: Dynamic| -> Dynamic {
47            match x.as_int() {
48                Ok(i) => Dynamic::from(i as f64),
49                Err(_) => x,
50            }
51        });
52        // Record every variable read while dependency-tracking is active, then
53        // fall through (`Ok(None)`) to normal scope resolution. `on_var` is
54        // flagged volatile upstream, not deprecated, hence the allow.
55        #[allow(deprecated)]
56        engine.on_var(|name, _index, _context| {
57            READS.with(|r| {
58                if let Some(set) = r.borrow_mut().as_mut() {
59                    set.insert(name.to_string());
60                }
61            });
62            Ok(None)
63        });
64        Self {
65            engine,
66            host: Module::new(),
67        }
68    }
69
70    /// Register a zero-argument `host::<name>()` returning a number.
71    pub fn host_number(
72        &mut self,
73        name: &str,
74        f: impl Fn() -> f64 + Send + Sync + 'static,
75    ) -> &mut Self {
76        self.host.set_native_fn(name, move || -> Result<f64, Box<rhai::EvalAltResult>> {
77            Ok(f())
78        });
79        self
80    }
81
82    /// Compile and initialize the script, producing a ready [`Engine`].
83    pub fn build(mut self, script: &str) -> Result<Engine, String> {
84        self.engine
85            .register_static_module("host", self.host.into());
86
87        let ast = self.engine.compile(script).map_err(|e| e.to_string())?;
88        let mut scope = Scope::new();
89        self.engine
90            .run_ast_with_scope(&mut scope, &ast)
91            .map_err(|e| e.to_string())?;
92        let funcs = ast.clone_functions_only();
93
94        // The top-level `let` bindings are the app's signals. The set is fixed
95        // after init (no runtime `let` at top level), so capture it once here;
96        // dependency tracking filters reads down to these names.
97        let signals = scope.iter().map(|(name, _, _)| name.to_string()).collect();
98
99        Ok(Engine {
100            engine: self.engine,
101            scope,
102            funcs,
103            signals,
104        })
105    }
106}
107
108/// A live script engine: state in `scope`, script functions in `funcs`.
109pub struct Engine {
110    engine: RhaiEngine,
111    scope: Scope<'static>,
112    funcs: AST,
113    /// Names of the top-level signals, the universe of reactive dependencies.
114    signals: HashSet<String>,
115}
116
117// ── Warning collection ──────────────────────────────────────────────────────
118
119thread_local! {
120    /// Expression failures raised since the last drain. Mirrors the sink in
121    /// `rux-style`: the runtime drains both after a build so the dev overlay can
122    /// list everything wrong with the document, not just what reached stderr.
123    static WARNINGS: RefCell<Vec<Warning>> = const { RefCell::new(Vec::new()) };
124}
125
126fn warn(message: String) {
127    WARNINGS.with(|w| {
128        let mut w = w.borrow_mut();
129        // A binding is re-evaluated on every build, and an `r-for` evaluates the
130        // same expression once per row, so the same failure arrives many times.
131        if !w.iter().any(|existing: &Warning| existing.message == message) {
132            if ECHO.with(|e| e.get()) {
133                eprintln!("rux: {message}");
134            }
135            // Expression failures are still unplaced: an expression comes from a
136            // template attribute or a `{{ }}` span, and the template parser does
137            // not yet record where each of those started. See `rux-reactive`'s
138            // `Warning` on why a guess would be worse than nothing.
139            w.push(Warning::new(message));
140        }
141    });
142}
143
144thread_local! {
145    /// Whether to mirror each warning to stderr as it happens.
146    ///
147    /// On for anyone running the window, where stderr is the only place a
148    /// warning could go before the overlay existed. Off for a tool that drains
149    /// the sink and formats it itself: printing each warning twice, once as
150    /// prose and once as a diagnostic, is what makes machine-readable output
151    /// unpipeable.
152    static ECHO: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
153}
154
155/// Stop (or resume) mirroring warnings to stderr.
156///
157/// On by default, which suits anyone running the window, where stderr was the
158/// only place a warning could go before the overlay existed. A tool that drains
159/// the sink and formats the warnings itself turns it off: printing each one
160/// twice, once as prose and once as a diagnostic, is what makes machine-readable
161/// output unpipeable.
162pub fn set_stderr_echo(on: bool) {
163    ECHO.with(|e| e.set(on));
164}
165
166/// Take the expression failures raised since the last call, emptying the sink.
167pub fn take_warnings() -> Vec<Warning> {
168    WARNINGS.with(|w| std::mem::take(&mut *w.borrow_mut()))
169}
170
171/// Collapse an expression to one short line for a message, a handler can be a
172/// multi-line block, and the overlay has one line to spend on it.
173fn trim_expr(src: &str) -> String {
174    let flat: String = src.split_whitespace().collect::<Vec<_>>().join(" ");
175    if flat.chars().count() > 60 {
176        format!("{}…", flat.chars().take(60).collect::<String>())
177    } else {
178        flat
179    }
180}
181
182/// Strip rhai's own `(line N, position M)` suffix from an error message.
183///
184/// Every `{{ }}` and `@tap` is compiled as its own small script, so rhai's line
185/// is **always 1** and its position counts characters inside the expression, not
186/// inside the file. Printed beside a file name in the overlay or in `rux check`,
187/// it reads as a location in the document and is not one: the reader is sent
188/// confidently to line 1. That is the same failure this project removed from CSS
189/// warnings, so it does not belong here either.
190///
191/// Nothing is lost by dropping it. The expression is already quoted in the
192/// message, and a position within a string the reader can see is not worth the
193/// cost of looking like a file position.
194fn strip_rhai_position(message: &str) -> String {
195    let trimmed = message.trim_end();
196    // Only the exact trailing shape is removed, so a message that merely ends
197    // in a parenthesis keeps it.
198    let Some(open) = trimmed.rfind(" (line ") else {
199        return trimmed.to_string();
200    };
201    let Some(inner) = trimmed[open + 1..].strip_prefix('(') else {
202        return trimmed.to_string();
203    };
204    let Some(inner) = inner.strip_suffix(')') else {
205        return trimmed.to_string();
206    };
207    let Some(rest) = inner.strip_prefix("line ") else {
208        return trimmed.to_string();
209    };
210    let Some((line, position)) = rest.split_once(", position ") else {
211        return trimmed.to_string();
212    };
213    let numeric = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit());
214    if numeric(line) && numeric(position) {
215        trimmed[..open].trim_end().to_string()
216    } else {
217        trimmed.to_string()
218    }
219}
220
221impl Engine {
222    /// Evaluate `src` (an expression or statements) with `locals` temporarily in
223    /// scope. Script functions are available. Returns the resulting value.
224    fn eval(&mut self, src: &str, locals: &[(String, Value)]) -> Option<Dynamic> {
225        let ast = match self.engine.compile(src) {
226            Ok(ast) => ast,
227            Err(e) => {
228                // A `{{ }}` or `@tap` that doesn't compile used to evaluate to
229                // nothing, silently, the same failure mode as ignored CSS. Record
230                // it so the dev overlay can say what's wrong.
231                warn(format!(
232                    "expression `{}` failed to compile: {}",
233                    trim_expr(src),
234                    strip_rhai_position(&e.to_string())
235                ));
236                return None;
237            }
238        };
239        let merged = self.funcs.merge(&ast);
240
241        let base = self.scope.len();
242        for (name, value) in locals {
243            self.scope.push(name.clone(), to_dynamic(value));
244        }
245        let result = self.engine.eval_ast_with_scope::<Dynamic>(&mut self.scope, &merged);
246        self.scope.rewind(base); // drop the temporary locals
247        match result {
248            Ok(value) => Some(value),
249            Err(e) => {
250                warn(format!(
251                    "expression `{}` failed: {}",
252                    trim_expr(src),
253                    strip_rhai_position(&e.to_string())
254                ));
255                None
256            }
257        }
258    }
259
260    /// Evaluate an expression to a [`Value`].
261    pub fn eval_value(&mut self, src: &str, locals: &[(String, Value)]) -> Option<Value> {
262        self.eval(src, locals).map(|d| from_dynamic(&d))
263    }
264
265    /// Evaluate a `{{ }}` binding to its display string (empty on error).
266    pub fn eval_display(&mut self, src: &str, locals: &[(String, Value)]) -> String {
267        self.eval_value(src, locals)
268            .map(|v| v.to_display())
269            .unwrap_or_default()
270    }
271
272    /// Evaluate a condition (`r-if` / `r-elif` / `r-show`).
273    pub fn eval_bool(&mut self, src: &str, locals: &[(String, Value)]) -> bool {
274        self.eval_value(src, locals)
275            .map(|v| v.is_truthy())
276            .unwrap_or(false)
277    }
278
279    /// Run an `@tap` handler (statements or a function call). Returns whether it
280    /// ran without error (assumed to have changed state).
281    pub fn run_handler(&mut self, src: &str) -> bool {
282        self.eval(src, &[]).is_some()
283    }
284
285    /// Evaluate an expression *and* report which signals it read, the binding's
286    /// dependency set. Only top-level signal names are returned; loop-locals and
287    /// function parameters are filtered out. This is the read half of fine-grained
288    /// reactivity: a binding subscribes to exactly the signals it touches.
289    pub fn eval_value_tracked(
290        &mut self,
291        src: &str,
292        locals: &[(String, Value)],
293    ) -> (Option<Value>, HashSet<String>) {
294        READS.with(|r| *r.borrow_mut() = Some(HashSet::new()));
295        let value = self.eval_value(src, locals);
296        let mut reads = READS.with(|r| r.borrow_mut().take()).unwrap_or_default();
297        reads.retain(|n| self.signals.contains(n));
298        (value, reads)
299    }
300
301    /// Evaluate a `{{ }}` binding to its display string *and* report its signal
302    /// deps (the tracked twin of `eval_display`).
303    pub fn eval_display_tracked(
304        &mut self,
305        src: &str,
306        locals: &[(String, Value)],
307    ) -> (String, HashSet<String>) {
308        let (value, deps) = self.eval_value_tracked(src, locals);
309        (value.map(|v| v.to_display()).unwrap_or_default(), deps)
310    }
311
312    /// Evaluate a condition *and* report its signal deps (the tracked twin of
313    /// `eval_bool`).
314    pub fn eval_bool_tracked(
315        &mut self,
316        src: &str,
317        locals: &[(String, Value)],
318    ) -> (bool, HashSet<String>) {
319        let (value, deps) = self.eval_value_tracked(src, locals);
320        (value.map(|v| v.is_truthy()).unwrap_or(false), deps)
321    }
322
323    /// Run an `@tap` handler and report which signals it *changed*, the write
324    /// half. Detected by diffing the signal values across the run, so it needs no
325    /// cooperation from the handler source (which is arbitrary rhai). Returns an
326    /// empty set if the handler errored or changed nothing.
327    pub fn run_handler_tracked(&mut self, src: &str) -> HashSet<String> {
328        let names: Vec<String> = self.signals.iter().cloned().collect();
329        let before: HashMap<String, Option<Value>> =
330            names.iter().map(|n| (n.clone(), self.read_signal(n))).collect();
331        if !self.run_handler(src) {
332            return HashSet::new();
333        }
334        names
335            .into_iter()
336            .filter(|n| self.read_signal(n) != before[n])
337            .collect()
338    }
339
340    /// A signal's current value, read straight from the scope (no evaluation).
341    fn read_signal(&self, name: &str) -> Option<Value> {
342        self.scope.get_value::<Dynamic>(name).map(|d| from_dynamic(&d))
343    }
344
345    /// Read a signal's current value as a display string (for input `r-model`).
346    pub fn get_string(&mut self, name: &str) -> String {
347        self.eval_value(name, &[]).map(|v| v.to_display()).unwrap_or_default()
348    }
349
350    /// Set a signal to a string value (from input editing).
351    pub fn set_string(&mut self, name: &str, value: &str) {
352        self.scope.set_or_push(name, value.to_string());
353    }
354}
355
356fn to_dynamic(v: &Value) -> Dynamic {
357    match v {
358        Value::Number(n) => Dynamic::from(*n),
359        Value::Text(s) => Dynamic::from(s.clone()),
360        Value::Bool(b) => Dynamic::from(*b),
361        Value::List(items) => {
362            let arr: rhai::Array = items.iter().map(to_dynamic).collect();
363            Dynamic::from(arr)
364        }
365        Value::Map(entries) => {
366            let map: rhai::Map =
367                entries.iter().map(|(k, v)| (k.as_str().into(), to_dynamic(v))).collect();
368            Dynamic::from(map)
369        }
370    }
371}
372
373fn from_dynamic(d: &Dynamic) -> Value {
374    if let Ok(i) = d.as_int() {
375        return Value::Number(i as f64);
376    }
377    if let Ok(f) = d.as_float() {
378        return Value::Number(f);
379    }
380    if let Ok(b) = d.as_bool() {
381        return Value::Bool(b);
382    }
383    if let Some(s) = d.clone().try_cast::<String>() {
384        return Value::Text(s);
385    }
386    if let Some(arr) = d.clone().try_cast::<rhai::Array>() {
387        return Value::List(arr.iter().map(from_dynamic).collect());
388    }
389    if let Some(map) = d.clone().try_cast::<rhai::Map>() {
390        return Value::Map(
391            map.iter().map(|(k, v)| (k.to_string(), from_dynamic(v))).collect(),
392        );
393    }
394    Value::Text(d.to_string())
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    /// A failing expression is *reported*, not just swallowed, it used to
402    /// evaluate to an empty string with nothing said anywhere.
403    #[test]
404    fn a_failing_expression_is_reported() {
405        let mut e = engine();
406        let _ = take_warnings(); // start from a clean sink
407
408        assert_eq!(e.eval_display("nope(1)", &[]), "", "still degrades to empty");
409        let warnings = take_warnings();
410        assert_eq!(warnings.len(), 1, "{warnings:?}");
411        assert!(warnings[0].message.contains("nope(1)"), "names the expression: {warnings:?}");
412
413        assert!(take_warnings().is_empty(), "draining empties the sink");
414    }
415
416    /// rhai appends its own `(line 1, position N)` to every error. Each binding
417    /// is compiled alone, so that line is always 1 and the position counts
418    /// inside the expression, never inside the file. Beside a file name it reads
419    /// as a document location, which is the one thing a diagnostic must not do.
420    ///
421    /// Asserted against a real rhai error rather than a hand-written string, so
422    /// it still fails if rhai changes the wording.
423    #[test]
424    fn a_failing_expression_does_not_quote_a_line_that_is_not_in_the_file() {
425        let mut e = engine();
426        let _ = take_warnings();
427
428        let _ = e.eval_display("names", &[]); // an undefined variable
429        let warnings = take_warnings();
430        assert_eq!(warnings.len(), 1, "{warnings:?}");
431        let message = &warnings[0].message;
432
433        assert!(message.contains("Variable not found"), "keeps the cause: {message}");
434        assert!(message.contains("names"), "keeps the expression: {message}");
435        assert!(
436            !message.contains("line 1"),
437            "must not report a line that is not a line of the file: {message}"
438        );
439        assert!(!message.contains("position"), "nor a position: {message}");
440    }
441
442    /// The stripping is narrow: only rhai's exact trailing shape goes, so a
443    /// message that merely ends in a parenthesis is left alone.
444    #[test]
445    fn stripping_the_position_leaves_other_parentheses_alone() {
446        assert_eq!(
447            strip_rhai_position("Variable not found: names (line 1, position 1)"),
448            "Variable not found: names"
449        );
450        // Not the shape: no position, so nothing is removed.
451        assert_eq!(strip_rhai_position("something (line 4)"), "something (line 4)");
452        assert_eq!(
453            strip_rhai_position("call to fn(a, b) failed"),
454            "call to fn(a, b) failed"
455        );
456        assert_eq!(strip_rhai_position("plain message"), "plain message");
457        // Non-numeric where digits belong, so it is not rhai's suffix.
458        assert_eq!(
459            strip_rhai_position("x (line one, position two)"),
460            "x (line one, position two)"
461        );
462    }
463
464    /// The same failing binding is re-evaluated on every build (and once per row
465    /// in an `r-for`), so the sink must not grow a duplicate each time.
466    #[test]
467    fn repeated_failures_are_reported_once() {
468        let mut e = engine();
469        let _ = take_warnings();
470        for _ in 0..5 {
471            let _ = e.eval_display("nope(1)", &[]);
472        }
473        assert_eq!(take_warnings().len(), 1);
474    }
475
476    /// A working expression reports nothing.
477    #[test]
478    fn a_good_expression_is_silent() {
479        let mut e = engine();
480        let _ = take_warnings();
481        assert_eq!(e.eval_display("double(4)", &[]), "8");
482        assert!(take_warnings().is_empty());
483    }
484
485    fn engine() -> Engine {
486        let mut b = Builder::new();
487        b.host_number("full", || 100.0);
488        b.build(
489            "let level = signal(82); \
490             let items = signal([1, 2, 3]); \
491             fn double(x) { x * 2 }",
492        )
493        .expect("build engine")
494    }
495
496    #[test]
497    fn reads_and_evaluates_state() {
498        let mut e = engine();
499        assert_eq!(e.eval_display("level", &[]), "82");
500        assert_eq!(e.eval_display("level - 2", &[]), "80");
501        assert!(e.eval_bool("level > 50", &[]));
502        assert!(!e.eval_bool("level < 20", &[]));
503    }
504
505    #[test]
506    fn runs_inline_handlers_and_pure_fns() {
507        let mut e = engine();
508        e.run_handler("level = level - 5"); // inline statement mutates scope state
509        assert_eq!(e.eval_display("level", &[]), "77");
510        e.run_handler("level = level + 3");
511        assert_eq!(e.eval_display("level", &[]), "80");
512        // A pure script function is usable inside a binding.
513        assert_eq!(e.eval_display("double(level)", &[]), "160");
514    }
515
516    /// rhai backtick template literals interpolate `${…}`, this is what makes
517    /// `:style="`background: ${c}`"` work (no template-layer code in Rux).
518    #[test]
519    fn evaluates_backtick_string_interpolation() {
520        let mut e = engine(); // has `level = 82`
521        // Strings interpolate exactly, the common `:style`/`:class` case.
522        assert_eq!(
523            e.eval_display("`background: ${c}`", &[("c".into(), Value::Text("teal".into()))]),
524            "background: teal"
525        );
526        // WRINKLE: a whole-number signal renders through rhai's float default
527        // (`82.0`), NOT Rux's `to_display` (`82`), inside a backtick string, signals
528        // are stored as f64. Valid CSS (`82.0px` works) but not pretty; interpolate
529        // strings, or convert (`${level.to_int()}`), when you need `82`.
530        assert_eq!(e.eval_display("`level is ${level}`", &[]), "level is 82.0");
531        // The read is tracked, so a `:style` reading a signal reconciles on change.
532        let (_, deps) = e.eval_value_tracked("`level: ${level}`", &[]);
533        assert!(deps.contains("level"));
534    }
535
536    #[test]
537    fn calls_host_functions() {
538        let mut e = engine();
539        e.run_handler("level = host::full()");
540        assert_eq!(e.eval_display("level", &[]), "100");
541    }
542
543    #[test]
544    fn lists_and_locals() {
545        let mut e = engine();
546        let items = e.eval_value("items", &[]).unwrap();
547        assert_eq!(items.as_list().unwrap().len(), 3);
548        // A loop-local shadows for one evaluation.
549        assert_eq!(e.eval_display("x + 1", &[("x".into(), Value::Number(4.0))]), "5");
550    }
551
552    fn deps(e: &mut Engine, src: &str, locals: &[(String, Value)]) -> Vec<String> {
553        let (_, set) = e.eval_value_tracked(src, locals);
554        let mut v: Vec<String> = set.into_iter().collect();
555        v.sort();
556        v
557    }
558
559    /// A binding reports exactly the signals it read, the subscription set.
560    #[test]
561    fn tracks_binding_dependencies() {
562        let mut e = engine();
563        assert_eq!(deps(&mut e, "level", &[]), ["level"]);
564        assert_eq!(deps(&mut e, "level > 20", &[]), ["level"]);
565        // A pure function reads its argument, not a phantom signal: `double`'s
566        // parameter `x` is a local and must be filtered out, leaving just `level`.
567        assert_eq!(deps(&mut e, "double(level)", &[]), ["level"]);
568        // A loop-local is not a signal, so it contributes no dependency.
569        assert_eq!(deps(&mut e, "x + 1", &[("x".into(), Value::Number(4.0))]), Vec::<String>::new());
570        assert_eq!(deps(&mut e, "x + level", &[("x".into(), Value::Number(4.0))]), ["level"]);
571        // Reading two signals subscribes to both.
572        assert_eq!(deps(&mut e, "level + items[0]", &[]), ["items", "level"]);
573    }
574
575    /// A handler reports exactly the signals it changed, and nothing it left
576    /// alone. This is what lets a write dirty only the affected bindings.
577    #[test]
578    fn tracks_handler_writes() {
579        let mut e = engine();
580        let changed = |e: &mut Engine, src: &str| {
581            let mut v: Vec<String> = e.run_handler_tracked(src).into_iter().collect();
582            v.sort();
583            v
584        };
585        assert_eq!(changed(&mut e, "level = level - 5"), ["level"]);
586        assert_eq!(e.eval_display("level", &[]), "77");
587        // Writing a signal back to its own value is not a change.
588        assert_eq!(changed(&mut e, "level = level"), Vec::<String>::new());
589        // Touching one signal does not report the others.
590        assert_eq!(changed(&mut e, "items = [9]"), ["items"]);
591        assert_eq!(changed(&mut e, "level"), Vec::<String>::new()); // a bare read changes nothing
592    }
593}
594