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, ImmutableString, 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        // Numbers become text the same way everywhere.
53        //
54        // Every number in Rux is an f64, so rhai renders a whole one as "32.0"
55        // while `{{ }}` renders it as "32": the same value spelled two ways in
56        // one window, depending on whether it went through string concatenation
57        // on the way. These overloads point rhai at the same rule `Value`
58        // displays with, so `"over by " + total` and `{{ total }}` agree.
59        engine.register_fn("to_string", |n: f64| Value::Number(n).to_display());
60        engine.register_fn("+", |a: ImmutableString, b: f64| {
61            format!("{a}{}", Value::Number(b).to_display())
62        });
63        engine.register_fn("+", |a: f64, b: ImmutableString| {
64            format!("{}{b}", Value::Number(a).to_display())
65        });
66        // `emit("change")` / `emit("change", payload)`: a component telling its
67        // caller that something happened. It only records the emission; who
68        // listens, and in whose scope their handler runs, is the runtime's
69        // business. A script function cannot mutate a signal, so it could not
70        // run the caller's body itself even if it knew it.
71        engine.register_fn("emit", |name: ImmutableString| {
72            EMISSIONS.with(|e| e.borrow_mut().push((name.to_string(), None)));
73        });
74        engine.register_fn("emit", |name: ImmutableString, payload: Dynamic| {
75            EMISSIONS.with(|e| e.borrow_mut().push((name.to_string(), Some(from_dynamic(&payload)))));
76        });
77        // `navigate("/path")`, `back()`, `forward()`: the router's verbs. Like
78        // `emit`, they record an intent rather than acting on it. Navigation
79        // moves the `route` signal and pushes history, and neither is something
80        // a script function can reach from in here.
81        engine.register_fn("navigate", |path: ImmutableString| {
82            NAVIGATIONS.with(|n| n.borrow_mut().push(Nav::To(path.to_string())));
83        });
84        // `replace` is not a convenience over `navigate`: it is the only way to
85        // redirect. A redirect done with `navigate` leaves the page that
86        // redirected sitting in the history, so Back returns to it and it
87        // redirects again, and the user cannot leave. Nothing in userland can
88        // work around that.
89        engine.register_fn("replace", |path: ImmutableString| {
90            NAVIGATIONS.with(|n| n.borrow_mut().push(Nav::Replace(path.to_string())));
91        });
92        // `path_for("crew-detail", #{ id: "grace" })` builds a path from a
93        // named route. A function returning a string rather than a second form
94        // of `navigate`, because a path is what `to=`, `:to=`, `navigate` and
95        // `replace` all already take: one new function reaches all four, and
96        // there is no second way to say the same thing.
97        engine.register_fn("path_for", |name: ImmutableString, values: rhai::Map| {
98            let values: Vec<(String, Value)> =
99                values.into_iter().map(|(k, v)| (k.to_string(), from_dynamic(&v))).collect();
100            build_named_path(&name, &values)
101        });
102        // A route with no parameters still has a name worth using.
103        engine.register_fn("path_for", |name: ImmutableString| {
104            build_named_path(&name, &[])
105        });
106        engine.register_fn("back", || {
107            NAVIGATIONS.with(|n| n.borrow_mut().push(Nav::Back));
108        });
109        engine.register_fn("forward", || {
110            NAVIGATIONS.with(|n| n.borrow_mut().push(Nav::Forward));
111        });
112        // Record every variable read while dependency-tracking is active, then
113        // fall through (`Ok(None)`) to normal scope resolution. `on_var` is
114        // flagged volatile upstream, not deprecated, hence the allow.
115        #[allow(deprecated)]
116        engine.on_var(|name, _index, _context| {
117            READS.with(|r| {
118                if let Some(set) = r.borrow_mut().as_mut() {
119                    set.insert(name.to_string());
120                }
121            });
122            Ok(None)
123        });
124        Self {
125            engine,
126            host: Module::new(),
127        }
128    }
129
130    /// Register a zero-argument `host::<name>()` returning a number.
131    pub fn host_number(
132        &mut self,
133        name: &str,
134        f: impl Fn() -> f64 + Send + Sync + 'static,
135    ) -> &mut Self {
136        self.host.set_native_fn(name, move || -> Result<f64, Box<rhai::EvalAltResult>> {
137            Ok(f())
138        });
139        self
140    }
141
142    /// Compile and initialize the script, producing a ready [`Engine`].
143    pub fn build(mut self, script: &str) -> Result<Engine, String> {
144        self.engine
145            .register_static_module("host", self.host.into());
146
147        let ast = self.engine.compile(script).map_err(|e| e.to_string())?;
148        let mut scope = Scope::new();
149        self.engine
150            .run_ast_with_scope(&mut scope, &ast)
151            .map_err(|e| e.to_string())?;
152        let funcs = ast.clone_functions_only();
153
154        // The top-level `let` bindings are the app's signals. The set is fixed
155        // after init (no runtime `let` at top level), so capture it once here;
156        // dependency tracking filters reads down to these names.
157        let signals = scope.iter().map(|(name, _, _)| name.to_string()).collect();
158
159        Ok(Engine {
160            engine: self.engine,
161            scope,
162            funcs,
163            signals,
164        })
165    }
166}
167
168/// The signal the router keeps the current path in. Reserved: a document that
169/// declares it is warned rather than quietly overwritten.
170pub const ROUTE_SIGNAL: &str = "route";
171
172/// The signal holding what the matched route captured, as a map, so
173/// `{{ params.id }}` works anywhere and not only inside the matched view.
174pub const PARAMS_SIGNAL: &str = "params";
175
176/// The signal holding the query string, parsed, as a map.
177///
178/// Separate from `route` rather than part of it, so `route == "/search"` keeps
179/// meaning what it says once a query is present. Matching ignores it too: a
180/// query is an argument to a page, not a different page.
181pub const QUERY_SIGNAL: &str = "query";
182
183/// Whether there is anywhere to go back to, and anywhere to go forward to.
184/// Signals rather than functions, because what they are for is disabling a
185/// button, and a button's `:class` reads signals.
186pub const CAN_BACK_SIGNAL: &str = "can_go_back";
187pub const CAN_FORWARD_SIGNAL: &str = "can_go_forward";
188
189/// Every name the router provides. A script declaring one of these is warned.
190pub const ROUTER_SIGNALS: [&str; 5] =
191    [ROUTE_SIGNAL, PARAMS_SIGNAL, QUERY_SIGNAL, CAN_BACK_SIGNAL, CAN_FORWARD_SIGNAL];
192
193/// A live script engine: state in `scope`, script functions in `funcs`.
194pub struct Engine {
195    engine: RhaiEngine,
196    scope: Scope<'static>,
197    funcs: AST,
198    /// Names of the top-level signals, the universe of reactive dependencies.
199    signals: HashSet<String>,
200}
201
202// ── Warning collection ──────────────────────────────────────────────────────
203
204thread_local! {
205    /// Expression failures raised since the last drain. Mirrors the sink in
206    /// `rux-style`: the runtime drains both after a build so the dev overlay can
207    /// list everything wrong with the document, not just what reached stderr.
208    static WARNINGS: RefCell<Vec<Warning>> = const { RefCell::new(Vec::new()) };
209}
210
211fn warn(message: String) {
212    WARNINGS.with(|w| {
213        let mut w = w.borrow_mut();
214        // A binding is re-evaluated on every build, and an `r-for` evaluates the
215        // same expression once per row, so the same failure arrives many times.
216        if !w.iter().any(|existing: &Warning| existing.message == message) {
217            if ECHO.with(|e| e.get()) {
218                eprintln!("rux: {message}");
219            }
220            // Expression failures are still unplaced: an expression comes from a
221            // template attribute or a `{{ }}` span, and the template parser does
222            // not yet record where each of those started. See `rux-reactive`'s
223            // `Warning` on why a guess would be worse than nothing.
224            w.push(Warning::new(message));
225        }
226    });
227}
228
229thread_local! {
230    /// Whether to mirror each warning to stderr as it happens.
231    ///
232    /// On for anyone running the window, where stderr is the only place a
233    /// warning could go before the overlay existed. Off for a tool that drains
234    /// the sink and formats it itself: printing each warning twice, once as
235    /// prose and once as a diagnostic, is what makes machine-readable output
236    /// unpipeable.
237    static ECHO: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
238}
239
240/// Stop (or resume) mirroring warnings to stderr.
241///
242/// On by default, which suits anyone running the window, where stderr was the
243/// only place a warning could go before the overlay existed. A tool that drains
244/// the sink and formats the warnings itself turns it off: printing each one
245/// twice, once as prose and once as a diagnostic, is what makes machine-readable
246/// output unpipeable.
247pub fn set_stderr_echo(on: bool) {
248    ECHO.with(|e| e.set(on));
249}
250
251/// Take the expression failures raised since the last call, emptying the sink.
252pub fn take_warnings() -> Vec<Warning> {
253    WARNINGS.with(|w| std::mem::take(&mut *w.borrow_mut()))
254}
255
256/// Raise a script warning from outside the engine.
257///
258/// The runtime, not the engine, decides who receives an emitted event and how
259/// far a chain of them may run, so it is the only layer that can notice an
260/// event with nowhere to go. The warning belongs in this sink anyway: to the
261/// overlay and to `rux check` it is one more thing wrong with the script, and
262/// a second sink would let those two disagree about what was said.
263pub fn warn_script(message: impl Into<String>) {
264    warn(message.into());
265}
266
267thread_local! {
268    /// Events raised by `emit` since the last drain, in the order they were
269    /// raised. A sink rather than a return value because an emission can happen
270    /// anywhere inside a handler, including several levels down a script
271    /// function, and the handler's own result is already spoken for.
272    static EMISSIONS: RefCell<Vec<(String, Option<Value>)>> = const { RefCell::new(Vec::new()) };
273}
274
275/// Take the events emitted since the last call, emptying the sink.
276pub fn take_emissions() -> Vec<(String, Option<Value>)> {
277    EMISSIONS.with(|e| std::mem::take(&mut *e.borrow_mut()))
278}
279
280/// One navigation asked for by a script: where to go, or which way along the
281/// history that has already been walked.
282#[derive(Clone, Debug, PartialEq, Eq)]
283pub enum Nav {
284    To(String),
285    /// Go there *instead of* here: the current entry is overwritten rather than
286    /// added to, so Back skips the page that redirected.
287    Replace(String),
288    Back,
289    Forward,
290}
291
292thread_local! {
293    /// Navigations asked for since the last drain, in order. A sink for the same
294    /// reason as [`EMISSIONS`]: `navigate` can be called from anywhere inside a
295    /// handler, and what it means (move the route, push history) belongs to the
296    /// runtime rather than to the script tier.
297    static NAVIGATIONS: RefCell<Vec<Nav>> = const { RefCell::new(Vec::new()) };
298}
299
300/// Take the navigations asked for since the last call, emptying the sink.
301pub fn take_navigations() -> Vec<Nav> {
302    NAVIGATIONS.with(|n| std::mem::take(&mut *n.borrow_mut()))
303}
304
305thread_local! {
306    /// Named routes, as `(name, pattern)` in the order written, so `path_for`
307    /// can build a path from a name and some parameters.
308    ///
309    /// A sink like the others, and for the same reason: `path_for` is a plain
310    /// rhai function registered once on the engine, and it has no way to reach
311    /// the document's template from inside a call.
312    static ROUTES: RefCell<Vec<(String, String)>> = const { RefCell::new(Vec::new()) };
313}
314
315/// Tell the script tier what the document's named routes are. Replaces the
316/// previous set, since a reload may have changed them.
317pub fn set_routes(routes: Vec<(String, String)>) {
318    ROUTES.with(|r| *r.borrow_mut() = routes);
319}
320
321/// Build a path from a named route and a map of values.
322///
323/// Values matching a `:name` segment fill it. Anything left over becomes a
324/// query string, which is what makes this usable for a route that takes no path
325/// parameters at all (`path_for("search", #{ q: "rust" })`).
326///
327/// Every failure is warned about and then produces a path that visibly does not
328/// work, rather than one that quietly goes somewhere plausible: landing on the
329/// fallback page is a bug you can see, and landing on the wrong record is not.
330fn build_named_path(name: &str, values: &[(String, Value)]) -> String {
331    let pattern = ROUTES.with(|r| {
332        r.borrow().iter().find(|(n, _)| n == name).map(|(_, p)| p.clone())
333    });
334    let Some(pattern) = pattern else {
335        warn(format!(
336            "`path_for(\"{name}\", …)` names a route that does not exist; add `name=\"{name}\"` \
337             to the <route> it means"
338        ));
339        return name.to_string();
340    };
341
342    let mut used: Vec<&str> = Vec::new();
343    let mut path = String::new();
344    for segment in pattern.split('/').filter(|s| !s.is_empty()) {
345        path.push('/');
346        match segment.strip_prefix(':') {
347            Some(param) => match values.iter().find(|(k, _)| k == param) {
348                Some((key, value)) => {
349                    used.push(key.as_str());
350                    path.push_str(&encode(&value.to_display()));
351                }
352                None => {
353                    warn(format!(
354                        "`path_for(\"{name}\", …)` was not given `{param}`, which the route \
355                         `{pattern}` needs"
356                    ));
357                    path.push_str(segment);
358                }
359            },
360            None => path.push_str(segment),
361        }
362    }
363    if path.is_empty() {
364        path.push('/');
365    }
366
367    // Whatever the pattern did not take is a query. Order is the caller's, so
368    // the same call always produces the same URL.
369    let query: Vec<String> = values
370        .iter()
371        .filter(|(k, _)| !used.contains(&k.as_str()))
372        .map(|(k, v)| format!("{}={}", encode(k), encode(&v.to_display())))
373        .collect();
374    if query.is_empty() {
375        path
376    } else {
377        format!("{path}?{}", query.join("&"))
378    }
379}
380
381/// Split a location into its path and its query string.
382pub fn split_query(location: &str) -> (&str, &str) {
383    match location.split_once('?') {
384        Some((path, query)) => (path, query),
385        None => (location, ""),
386    }
387}
388
389/// Parse `a=1&b=two` into pairs, undoing percent-encoding.
390///
391/// A key with no `=` is present with an empty value, which is how a flag in a
392/// URL (`?debug`) reads, and a repeated key keeps the first: a map has one slot
393/// per name, and the alternative (a list, sometimes) would make every read of
394/// every query parameter check which it got.
395pub fn parse_query(query: &str) -> Vec<(String, Value)> {
396    let mut out: Vec<(String, Value)> = Vec::new();
397    for pair in query.split('&').filter(|p| !p.is_empty()) {
398        let (key, value) = match pair.split_once('=') {
399            Some((k, v)) => (k, v),
400            None => (pair, ""),
401        };
402        let key = decode(key);
403        if key.is_empty() || out.iter().any(|(k, _)| *k == key) {
404            continue;
405        }
406        out.push((key, Value::Text(decode(value))));
407    }
408    out
409}
410
411/// Percent-decode, treating `+` as a space the way a query string does.
412///
413/// Public because a path parameter has to come back out of a URL as whatever
414/// went in: `path_for` escapes a `/` in an id, and the route that captures it
415/// has to undo that, or the view is handed `a%2Fb` and shows it to somebody.
416pub fn percent_decode(s: &str) -> String {
417    decode(s)
418}
419
420fn decode(s: &str) -> String {
421    let bytes = s.as_bytes();
422    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
423    let mut i = 0;
424    while i < bytes.len() {
425        match bytes[i] {
426            b'+' => out.push(b' '),
427            b'%' if i + 2 < bytes.len() => {
428                match u8::from_str_radix(&s[i + 1..i + 3], 16) {
429                    Ok(byte) => {
430                        out.push(byte);
431                        i += 2;
432                    }
433                    // Not an escape after all, so it is a literal `%`.
434                    Err(_) => out.push(b'%'),
435                }
436            }
437            byte => out.push(byte),
438        }
439        i += 1;
440    }
441    // A URL can carry any bytes; only valid UTF-8 can come back out as text.
442    String::from_utf8_lossy(&out).into_owned()
443}
444
445/// Percent-encode everything that is not unreserved, so a value carrying a `/`,
446/// an `&` or a space survives being put in a URL and read back.
447fn encode(s: &str) -> String {
448    let mut out = String::with_capacity(s.len());
449    for byte in s.as_bytes() {
450        match byte {
451            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
452                out.push(*byte as char)
453            }
454            other => out.push_str(&format!("%{other:02X}")),
455        }
456    }
457    out
458}
459
460/// Collapse an expression to one short line for a message, a handler can be a
461/// multi-line block, and the overlay has one line to spend on it.
462fn trim_expr(src: &str) -> String {
463    let flat: String = src.split_whitespace().collect::<Vec<_>>().join(" ");
464    if flat.chars().count() > 60 {
465        format!("{}…", flat.chars().take(60).collect::<String>())
466    } else {
467        flat
468    }
469}
470
471/// Strip rhai's own `(line N, position M)` suffix from an error message.
472///
473/// Every `{{ }}` and `@tap` is compiled as its own small script, so rhai's line
474/// is **always 1** and its position counts characters inside the expression, not
475/// inside the file. Printed beside a file name in the overlay or in `rux check`,
476/// it reads as a location in the document and is not one: the reader is sent
477/// confidently to line 1. That is the same failure this project removed from CSS
478/// warnings, so it does not belong here either.
479///
480/// Nothing is lost by dropping it. The expression is already quoted in the
481/// message, and a position within a string the reader can see is not worth the
482/// cost of looking like a file position.
483fn strip_rhai_position(message: &str) -> String {
484    let trimmed = message.trim_end();
485    // Only the exact trailing shape is removed, so a message that merely ends
486    // in a parenthesis keeps it.
487    let Some(open) = trimmed.rfind(" (line ") else {
488        return trimmed.to_string();
489    };
490    let Some(inner) = trimmed[open + 1..].strip_prefix('(') else {
491        return trimmed.to_string();
492    };
493    let Some(inner) = inner.strip_suffix(')') else {
494        return trimmed.to_string();
495    };
496    let Some(rest) = inner.strip_prefix("line ") else {
497        return trimmed.to_string();
498    };
499    let Some((line, position)) = rest.split_once(", position ") else {
500        return trimmed.to_string();
501    };
502    let numeric = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit());
503    if numeric(line) && numeric(position) {
504        trimmed[..open].trim_end().to_string()
505    } else {
506        trimmed.to_string()
507    }
508}
509
510impl Engine {
511    /// Evaluate `src` (an expression or statements) with `locals` temporarily in
512    /// scope. Script functions are available. Returns the resulting value.
513    fn eval(&mut self, src: &str, locals: &[(String, Value)]) -> Option<Dynamic> {
514        let ast = match self.engine.compile(src) {
515            Ok(ast) => ast,
516            Err(e) => {
517                // A `{{ }}` or `@tap` that doesn't compile used to evaluate to
518                // nothing, silently, the same failure mode as ignored CSS. Record
519                // it so the dev overlay can say what's wrong.
520                warn(format!(
521                    "expression `{}` failed to compile: {}",
522                    trim_expr(src),
523                    strip_rhai_position(&e.to_string())
524                ));
525                return None;
526            }
527        };
528        let merged = self.funcs.merge(&ast);
529
530        let base = self.scope.len();
531        for (name, value) in locals {
532            self.scope.push(name.clone(), to_dynamic(value));
533        }
534        let result = self.engine.eval_ast_with_scope::<Dynamic>(&mut self.scope, &merged);
535        self.scope.rewind(base); // drop the temporary locals
536        match result {
537            Ok(value) => Some(value),
538            Err(e) => {
539                warn(format!(
540                    "expression `{}` failed: {}",
541                    trim_expr(src),
542                    strip_rhai_position(&e.to_string())
543                ));
544                None
545            }
546        }
547    }
548
549    /// Evaluate an expression to a [`Value`].
550    pub fn eval_value(&mut self, src: &str, locals: &[(String, Value)]) -> Option<Value> {
551        self.eval(src, locals).map(|d| from_dynamic(&d))
552    }
553
554    /// Evaluate a `{{ }}` binding to its display string (empty on error).
555    pub fn eval_display(&mut self, src: &str, locals: &[(String, Value)]) -> String {
556        self.eval_value(src, locals)
557            .map(|v| v.to_display())
558            .unwrap_or_default()
559    }
560
561    /// Evaluate a condition (`r-if` / `r-elif` / `r-show`).
562    pub fn eval_bool(&mut self, src: &str, locals: &[(String, Value)]) -> bool {
563        self.eval_value(src, locals)
564            .map(|v| v.is_truthy())
565            .unwrap_or(false)
566    }
567
568    /// Run an `@tap` handler (statements or a function call). Returns whether it
569    /// ran without error (assumed to have changed state).
570    pub fn run_handler(&mut self, src: &str) -> bool {
571        self.eval(src, &[]).is_some()
572    }
573
574    /// Evaluate an expression *and* report which signals it read, the binding's
575    /// dependency set. Only top-level signal names are returned; loop-locals and
576    /// function parameters are filtered out. This is the read half of fine-grained
577    /// reactivity: a binding subscribes to exactly the signals it touches.
578    pub fn eval_value_tracked(
579        &mut self,
580        src: &str,
581        locals: &[(String, Value)],
582    ) -> (Option<Value>, HashSet<String>) {
583        READS.with(|r| *r.borrow_mut() = Some(HashSet::new()));
584        let value = self.eval_value(src, locals);
585        let mut reads = READS.with(|r| r.borrow_mut().take()).unwrap_or_default();
586        reads.retain(|n| self.signals.contains(n));
587        (value, reads)
588    }
589
590    /// Evaluate a `{{ }}` binding to its display string *and* report its signal
591    /// deps (the tracked twin of `eval_display`).
592    pub fn eval_display_tracked(
593        &mut self,
594        src: &str,
595        locals: &[(String, Value)],
596    ) -> (String, HashSet<String>) {
597        let (value, deps) = self.eval_value_tracked(src, locals);
598        (value.map(|v| v.to_display()).unwrap_or_default(), deps)
599    }
600
601    /// Evaluate a condition *and* report its signal deps (the tracked twin of
602    /// `eval_bool`).
603    pub fn eval_bool_tracked(
604        &mut self,
605        src: &str,
606        locals: &[(String, Value)],
607    ) -> (bool, HashSet<String>) {
608        let (value, deps) = self.eval_value_tracked(src, locals);
609        (value.map(|v| v.is_truthy()).unwrap_or(false), deps)
610    }
611
612    /// Run an `@tap` handler and report which signals it *changed*, the write
613    /// half. Detected by diffing the signal values across the run, so it needs no
614    /// cooperation from the handler source (which is arbitrary rhai). Returns an
615    /// empty set if the handler errored or changed nothing.
616    pub fn run_handler_tracked(&mut self, src: &str) -> HashSet<String> {
617        let names: Vec<String> = self.signals.iter().cloned().collect();
618        let before: HashMap<String, Option<Value>> =
619            names.iter().map(|n| (n.clone(), self.read_signal(n))).collect();
620        if !self.run_handler(src) {
621            return HashSet::new();
622        }
623        names
624            .into_iter()
625            .filter(|n| self.read_signal(n) != before[n])
626            .collect()
627    }
628
629    /// Put the current path in scope as the `route` signal.
630    ///
631    /// A signal rather than anything router-shaped, so `{{ route }}`, `r-if`,
632    /// `:class` and the change diff all understand navigation with no knowledge
633    /// of the router at all. It is added to the signal set as well as the scope,
634    /// or dependency tracking would filter reads of it out as a stray local and
635    /// nothing would subscribe.
636    ///
637    /// Returns whether the value actually moved, which is what tells the runtime
638    /// there is anything to repaint.
639    pub fn set_route(&mut self, path: &str) -> bool {
640        let changed = self.read_signal(ROUTE_SIGNAL).as_ref()
641            != Some(&Value::Text(path.to_string()));
642        self.scope.set_or_push(ROUTE_SIGNAL, path.to_string());
643        self.signals.insert(ROUTE_SIGNAL.to_string());
644        changed
645    }
646
647    /// Put one of the router's other provided values in scope, the same way
648    /// [`Self::set_route`] does with the path.
649    ///
650    /// Returns whether it moved, so the runtime can skip a repaint nothing
651    /// asked for: `can_go_forward` in particular is false through most of a
652    /// session and would otherwise report a change on every navigation.
653    pub fn set_provided(&mut self, name: &str, value: Value) -> bool {
654        let changed = self.read_signal(name).as_ref() != Some(&value);
655        self.scope.set_or_push(name, to_dynamic(&value));
656        self.signals.insert(name.to_string());
657        changed
658    }
659
660    /// Whether the script declared one of the router's names itself, so the
661    /// runtime can say so rather than silently overwriting it. Asked *before*
662    /// the setters, which would otherwise make the answer always yes.
663    pub fn declares(&self, name: &str) -> bool {
664        self.signals.contains(name)
665    }
666
667    /// A signal's current value, read straight from the scope (no evaluation).
668    fn read_signal(&self, name: &str) -> Option<Value> {
669        self.scope.get_value::<Dynamic>(name).map(|d| from_dynamic(&d))
670    }
671
672    /// Read a signal's current value as a display string (for input `r-model`).
673    pub fn get_string(&mut self, name: &str) -> String {
674        self.get_string_in(name, &[])
675    }
676
677    /// The same, with a row's loop variables in scope.
678    ///
679    /// An `r-model` is recorded as written, so one inside an `r-for` can mention
680    /// the loop variable (`items[item.at].note`). Read without it, that is not an
681    /// expression at all, and the field comes back empty.
682    pub fn get_string_in(&mut self, expr: &str, locals: &[(String, Value)]) -> String {
683        self.eval_value(expr, locals).map(|v| v.to_display()).unwrap_or_default()
684    }
685
686    /// Set a signal to a string value (from input editing).
687    pub fn set_string(&mut self, name: &str, value: &str) {
688        self.scope.set_or_push(name, value.to_string());
689    }
690
691    /// Run a component's own top-level script in a scope of its own, and hand
692    /// back the variables it declared: one instance's private state.
693    ///
694    /// The document's script is not visible, which is the point. A component
695    /// that could read the app's signals by name would be coupled to the app it
696    /// was first written for, and could not be used twice.
697    pub fn init_scope(&mut self, script: &str) -> Vec<(String, Value)> {
698        let ast = match self.engine.compile(script) {
699            Ok(ast) => ast,
700            Err(e) => {
701                warn(format!(
702                    "a component's script failed to compile: {}",
703                    strip_rhai_position(&e.to_string())
704                ));
705                return Vec::new();
706            }
707        };
708        // Its own functions plus everything already registered, so a component
709        // can call helpers it declared beside its state.
710        let merged = self.funcs.merge(&ast);
711        let mut scope = Scope::new();
712        if let Err(e) = self.engine.run_ast_with_scope(&mut scope, &merged) {
713            warn(format!(
714                "a component's script failed to run: {}",
715                strip_rhai_position(&e.to_string())
716            ));
717        }
718        scope.iter().map(|(name, _, value)| (name.to_string(), from_dynamic(&value))).collect()
719    }
720
721    /// Run a handler inside a component instance, whose state is `locals`.
722    ///
723    /// Returns the instance's variables as they stand afterwards, and which of
724    /// the *document's* signals changed. Both matter: a handler in a component
725    /// may touch its own state, a prop's underlying signal, or both.
726    ///
727    /// Reading the locals back before the scope is rewound is what makes a
728    /// component's state writable at all. Ordinary evaluation drops them, which
729    /// is right for a `{{ }}` binding and wrong for a `@tap`.
730    pub fn run_scoped_handler(
731        &mut self,
732        src: &str,
733        locals: &[(String, Value)],
734    ) -> (Vec<(String, Value)>, HashSet<String>) {
735        let names: Vec<String> = self.signals.iter().cloned().collect();
736        let before: HashMap<String, Option<Value>> =
737            names.iter().map(|n| (n.clone(), self.read_signal(n))).collect();
738
739        let ast = match self.engine.compile(src) {
740            Ok(ast) => ast,
741            Err(e) => {
742                warn(format!(
743                    "handler `{}` failed to compile: {}",
744                    trim_expr(src),
745                    strip_rhai_position(&e.to_string())
746                ));
747                return (locals.to_vec(), HashSet::new());
748            }
749        };
750        let merged = self.funcs.merge(&ast);
751        let base = self.scope.len();
752        for (name, value) in locals {
753            self.scope.push(name.clone(), to_dynamic(value));
754        }
755        let result = self.engine.eval_ast_with_scope::<Dynamic>(&mut self.scope, &merged);
756        // Read the instance's state back *before* rewinding, or the handler's
757        // effect on it is dropped along with the temporary scope.
758        let after: Vec<(String, Value)> = locals
759            .iter()
760            .map(|(name, previous)| {
761                let value = self
762                    .scope
763                    .get_value::<Dynamic>(name)
764                    .map(|d| from_dynamic(&d))
765                    .unwrap_or_else(|| previous.clone());
766                (name.clone(), value)
767            })
768            .collect();
769        self.scope.rewind(base);
770
771        if let Err(e) = result {
772            warn(format!(
773                "handler `{}` failed: {}",
774                trim_expr(src),
775                strip_rhai_position(&e.to_string())
776            ));
777            return (after, HashSet::new());
778        }
779        let changed = names.into_iter().filter(|n| self.read_signal(n) != before[n]).collect();
780        (after, changed)
781    }
782
783    /// Re-evaluate a computed's expression and store the result under its name.
784    ///
785    /// Returns whether the value actually changed, and what it read. Only a real
786    /// change is reported, so a computed that lands on the same answer does not
787    /// invalidate the bindings that read it: recomputing is cheap, rebuilding a
788    /// subtree is not.
789    ///
790    /// A computed is a signal like any other, because it is declared as a plain
791    /// `let` in the script handed to rhai. That is what makes `{{ total }}`
792    /// track it without anything else knowing computeds exist.
793    pub fn recompute(&mut self, name: &str, expr: &str) -> (bool, HashSet<String>) {
794        let (value, deps) = self.eval_value_tracked(expr, &[]);
795        let Some(value) = value else { return (false, deps) };
796        let changed = self.read_signal(name).as_ref() != Some(&value);
797        if changed {
798            self.scope.set_or_push(name, to_dynamic(&value));
799        }
800        (changed, deps)
801    }
802
803    /// Run an effect body, reporting what it read and what it wrote.
804    ///
805    /// Both halves are needed and neither can be inferred from the other: the
806    /// reads say when to run it again, and the writes say what its running has
807    /// invalidated. A handler only needs the writes, which is why this is not
808    /// [`run_handler_tracked`](Self::run_handler_tracked).
809    pub fn run_effect_tracked(&mut self, src: &str) -> (HashSet<String>, HashSet<String>) {
810        let names: Vec<String> = self.signals.iter().cloned().collect();
811        let before: HashMap<String, Option<Value>> =
812            names.iter().map(|n| (n.clone(), self.read_signal(n))).collect();
813
814        READS.with(|r| *r.borrow_mut() = Some(HashSet::new()));
815        let ran = self.eval(src, &[]).is_some();
816        let mut reads = READS.with(|r| r.borrow_mut().take()).unwrap_or_default();
817        reads.retain(|n| self.signals.contains(n));
818        if !ran {
819            // It still subscribes to whatever it managed to read, so a fixed
820            // signal re-runs it rather than leaving it dead until a reload.
821            return (reads, HashSet::new());
822        }
823        let writes = names.into_iter().filter(|n| self.read_signal(n) != before[n]).collect();
824        (reads, writes)
825    }
826
827    /// Write a string into whatever an `r-model` names, and report which signals
828    /// that changed.
829    ///
830    /// An assignment rather than [`set_string`](Self::set_string), which can only
831    /// set a scope variable *called* `name`: for anything but a bare signal
832    /// (`user.name`, `items[0].note`) that quietly created a variable with a
833    /// punctuation-filled name and left the real target untouched. Running it as
834    /// script is also what lets a row's loop variable be in scope.
835    pub fn assign_string(
836        &mut self,
837        target: &str,
838        value: &str,
839        locals: &[(String, Value)],
840    ) -> HashSet<String> {
841        let names: Vec<String> = self.signals.iter().cloned().collect();
842        let before: HashMap<String, Option<Value>> =
843            names.iter().map(|n| (n.clone(), self.read_signal(n))).collect();
844        // The value is a person's typing, so it is quoted as a literal rather
845        // than pasted in: a quote or a backslash in a text field would otherwise
846        // be a syntax error at best.
847        let src = format!("{target} = {}", rux_reactive::json_string(value));
848        if self.eval(&src, locals).is_none() {
849            return HashSet::new();
850        }
851        names.into_iter().filter(|n| self.read_signal(n) != before[n]).collect()
852    }
853}
854
855fn to_dynamic(v: &Value) -> Dynamic {
856    match v {
857        Value::Number(n) => Dynamic::from(*n),
858        Value::Text(s) => Dynamic::from(s.clone()),
859        Value::Bool(b) => Dynamic::from(*b),
860        Value::List(items) => {
861            let arr: rhai::Array = items.iter().map(to_dynamic).collect();
862            Dynamic::from(arr)
863        }
864        Value::Map(entries) => {
865            let map: rhai::Map =
866                entries.iter().map(|(k, v)| (k.as_str().into(), to_dynamic(v))).collect();
867            Dynamic::from(map)
868        }
869    }
870}
871
872fn from_dynamic(d: &Dynamic) -> Value {
873    if let Ok(i) = d.as_int() {
874        return Value::Number(i as f64);
875    }
876    if let Ok(f) = d.as_float() {
877        return Value::Number(f);
878    }
879    if let Ok(b) = d.as_bool() {
880        return Value::Bool(b);
881    }
882    if let Some(s) = d.clone().try_cast::<String>() {
883        return Value::Text(s);
884    }
885    if let Some(arr) = d.clone().try_cast::<rhai::Array>() {
886        return Value::List(arr.iter().map(from_dynamic).collect());
887    }
888    if let Some(map) = d.clone().try_cast::<rhai::Map>() {
889        return Value::Map(
890            map.iter().map(|(k, v)| (k.to_string(), from_dynamic(v))).collect(),
891        );
892    }
893    Value::Text(d.to_string())
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899
900    /// A failing expression is *reported*, not just swallowed, it used to
901    /// evaluate to an empty string with nothing said anywhere.
902    #[test]
903    fn a_failing_expression_is_reported() {
904        let mut e = engine();
905        let _ = take_warnings(); // start from a clean sink
906
907        assert_eq!(e.eval_display("nope(1)", &[]), "", "still degrades to empty");
908        let warnings = take_warnings();
909        assert_eq!(warnings.len(), 1, "{warnings:?}");
910        assert!(warnings[0].message.contains("nope(1)"), "names the expression: {warnings:?}");
911
912        assert!(take_warnings().is_empty(), "draining empties the sink");
913    }
914
915    /// rhai appends its own `(line 1, position N)` to every error. Each binding
916    /// is compiled alone, so that line is always 1 and the position counts
917    /// inside the expression, never inside the file. Beside a file name it reads
918    /// as a document location, which is the one thing a diagnostic must not do.
919    ///
920    /// Asserted against a real rhai error rather than a hand-written string, so
921    /// it still fails if rhai changes the wording.
922    #[test]
923    fn a_failing_expression_does_not_quote_a_line_that_is_not_in_the_file() {
924        let mut e = engine();
925        let _ = take_warnings();
926
927        let _ = e.eval_display("names", &[]); // an undefined variable
928        let warnings = take_warnings();
929        assert_eq!(warnings.len(), 1, "{warnings:?}");
930        let message = &warnings[0].message;
931
932        assert!(message.contains("Variable not found"), "keeps the cause: {message}");
933        assert!(message.contains("names"), "keeps the expression: {message}");
934        assert!(
935            !message.contains("line 1"),
936            "must not report a line that is not a line of the file: {message}"
937        );
938        assert!(!message.contains("position"), "nor a position: {message}");
939    }
940
941    /// The stripping is narrow: only rhai's exact trailing shape goes, so a
942    /// message that merely ends in a parenthesis is left alone.
943    #[test]
944    fn stripping_the_position_leaves_other_parentheses_alone() {
945        assert_eq!(
946            strip_rhai_position("Variable not found: names (line 1, position 1)"),
947            "Variable not found: names"
948        );
949        // Not the shape: no position, so nothing is removed.
950        assert_eq!(strip_rhai_position("something (line 4)"), "something (line 4)");
951        assert_eq!(
952            strip_rhai_position("call to fn(a, b) failed"),
953            "call to fn(a, b) failed"
954        );
955        assert_eq!(strip_rhai_position("plain message"), "plain message");
956        // Non-numeric where digits belong, so it is not rhai's suffix.
957        assert_eq!(
958            strip_rhai_position("x (line one, position two)"),
959            "x (line one, position two)"
960        );
961    }
962
963    /// The same failing binding is re-evaluated on every build (and once per row
964    /// in an `r-for`), so the sink must not grow a duplicate each time.
965    #[test]
966    fn repeated_failures_are_reported_once() {
967        let mut e = engine();
968        let _ = take_warnings();
969        for _ in 0..5 {
970            let _ = e.eval_display("nope(1)", &[]);
971        }
972        assert_eq!(take_warnings().len(), 1);
973    }
974
975    /// A working expression reports nothing.
976    #[test]
977    fn a_good_expression_is_silent() {
978        let mut e = engine();
979        let _ = take_warnings();
980        assert_eq!(e.eval_display("double(4)", &[]), "8");
981        assert!(take_warnings().is_empty());
982    }
983
984    fn engine() -> Engine {
985        let mut b = Builder::new();
986        b.host_number("full", || 100.0);
987        b.build(
988            "let level = signal(82); \
989             let items = signal([1, 2, 3]); \
990             fn double(x) { x * 2 }",
991        )
992        .expect("build engine")
993    }
994
995    #[test]
996    fn reads_and_evaluates_state() {
997        let mut e = engine();
998        assert_eq!(e.eval_display("level", &[]), "82");
999        assert_eq!(e.eval_display("level - 2", &[]), "80");
1000        assert!(e.eval_bool("level > 50", &[]));
1001        assert!(!e.eval_bool("level < 20", &[]));
1002    }
1003
1004    #[test]
1005    fn runs_inline_handlers_and_pure_fns() {
1006        let mut e = engine();
1007        e.run_handler("level = level - 5"); // inline statement mutates scope state
1008        assert_eq!(e.eval_display("level", &[]), "77");
1009        e.run_handler("level = level + 3");
1010        assert_eq!(e.eval_display("level", &[]), "80");
1011        // A pure script function is usable inside a binding.
1012        assert_eq!(e.eval_display("double(level)", &[]), "160");
1013    }
1014
1015    /// rhai backtick template literals interpolate `${…}`, this is what makes
1016    /// `:style="`background: ${c}`"` work (no template-layer code in Rux).
1017    #[test]
1018    fn evaluates_backtick_string_interpolation() {
1019        let mut e = engine(); // has `level = 82`
1020        // Strings interpolate exactly, the common `:style`/`:class` case.
1021        assert_eq!(
1022            e.eval_display("`background: ${c}`", &[("c".into(), Value::Text("teal".into()))]),
1023            "background: teal"
1024        );
1025        // A whole-number signal renders as `82`, not rhai's float default
1026        // `82.0`, because `to_string` is overridden to Rux's own rule. It used
1027        // to differ, so `${level}px` in a `:style` and `{{ level }}` in text
1028        // showed the same value two ways in one window.
1029        assert_eq!(e.eval_display("`level is ${level}`", &[]), "level is 82");
1030        // A fraction keeps its fraction; only the empty tail goes.
1031        assert_eq!(
1032            e.eval_display("`half is ${h}`", &[("h".into(), Value::Number(2.5))]),
1033            "half is 2.5"
1034        );
1035        // The read is tracked, so a `:style` reading a signal reconciles on change.
1036        let (_, deps) = e.eval_value_tracked("`level: ${level}`", &[]);
1037        assert!(deps.contains("level"));
1038    }
1039
1040    #[test]
1041    fn calls_host_functions() {
1042        let mut e = engine();
1043        e.run_handler("level = host::full()");
1044        assert_eq!(e.eval_display("level", &[]), "100");
1045    }
1046
1047    #[test]
1048    fn lists_and_locals() {
1049        let mut e = engine();
1050        let items = e.eval_value("items", &[]).unwrap();
1051        assert_eq!(items.as_list().unwrap().len(), 3);
1052        // A loop-local shadows for one evaluation.
1053        assert_eq!(e.eval_display("x + 1", &[("x".into(), Value::Number(4.0))]), "5");
1054    }
1055
1056    fn deps(e: &mut Engine, src: &str, locals: &[(String, Value)]) -> Vec<String> {
1057        let (_, set) = e.eval_value_tracked(src, locals);
1058        let mut v: Vec<String> = set.into_iter().collect();
1059        v.sort();
1060        v
1061    }
1062
1063    /// A binding reports exactly the signals it read, the subscription set.
1064    #[test]
1065    fn tracks_binding_dependencies() {
1066        let mut e = engine();
1067        assert_eq!(deps(&mut e, "level", &[]), ["level"]);
1068        assert_eq!(deps(&mut e, "level > 20", &[]), ["level"]);
1069        // A pure function reads its argument, not a phantom signal: `double`'s
1070        // parameter `x` is a local and must be filtered out, leaving just `level`.
1071        assert_eq!(deps(&mut e, "double(level)", &[]), ["level"]);
1072        // A loop-local is not a signal, so it contributes no dependency.
1073        assert_eq!(deps(&mut e, "x + 1", &[("x".into(), Value::Number(4.0))]), Vec::<String>::new());
1074        assert_eq!(deps(&mut e, "x + level", &[("x".into(), Value::Number(4.0))]), ["level"]);
1075        // Reading two signals subscribes to both.
1076        assert_eq!(deps(&mut e, "level + items[0]", &[]), ["items", "level"]);
1077    }
1078
1079    /// A handler reports exactly the signals it changed, and nothing it left
1080    /// alone. This is what lets a write dirty only the affected bindings.
1081    #[test]
1082    fn tracks_handler_writes() {
1083        let mut e = engine();
1084        let changed = |e: &mut Engine, src: &str| {
1085            let mut v: Vec<String> = e.run_handler_tracked(src).into_iter().collect();
1086            v.sort();
1087            v
1088        };
1089        assert_eq!(changed(&mut e, "level = level - 5"), ["level"]);
1090        assert_eq!(e.eval_display("level", &[]), "77");
1091        // Writing a signal back to its own value is not a change.
1092        assert_eq!(changed(&mut e, "level = level"), Vec::<String>::new());
1093        // Touching one signal does not report the others.
1094        assert_eq!(changed(&mut e, "items = [9]"), ["items"]);
1095        assert_eq!(changed(&mut e, "level"), Vec::<String>::new()); // a bare read changes nothing
1096    }
1097}
1098