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