zdc_runtime/lib.rs
1//! The ZDeceptron JavaScript runtime, embedded and executable from Rust.
2//!
3//! The runtime library that generated code links against is written in
4//! JavaScript, because it manipulates the DOM and there is no other way to
5//! do that (spec §14E.2). But *verifying* it must not require a JavaScript
6//! toolchain: needing Node to build ZDeceptron would be the first crack in
7//! the claim that a developer installs one binary and nothing else.
8//!
9//! So the sources are embedded here and evaluated with a pure-Rust engine.
10//! `cargo test` covers the runtime; nothing else has to be installed.
11//!
12//! # Two halves, and why the seam is a feature
13//!
14//! Holding the runtime sources and *running* them are different jobs, and
15//! the `evaluate` feature is where they part. Everything above the seam is
16//! text and plain data — the `.js` and `.css` a bundle ships, and the
17//! signatures a capability is written against. Everything below it is
18//! `boa_engine`, a JavaScript interpreter written in Rust.
19//!
20//! The seam is cut here rather than left implicit because `boa_engine`
21//! reaches `getrandom`, and `getrandom` will not build for
22//! `wasm32-unknown-unknown` without an entropy backend chosen by `--cfg`.
23//! `zdc-codegen` depends on this crate for seven `const &str`s, so without
24//! the seam the entire front end inherited that refusal and no part of this
25//! compiler could run in a browser (#171). See the feature's comment in
26//! `Cargo.toml` for the dependency chain in full.
27#![forbid(unsafe_code)]
28
29use std::borrow::Cow;
30use std::path::Path;
31
32#[cfg(feature = "evaluate")]
33use std::path::PathBuf;
34
35#[cfg(feature = "evaluate")]
36use boa_engine::object::builtins::JsArray;
37#[cfg(feature = "evaluate")]
38use boa_engine::{
39 js_string, Context, JsError, JsNativeError, JsNativeErrorKind, JsResult, JsValue,
40 NativeFunction, Source,
41};
42
43/// The reactivity core: signals, derived values, effects, batching.
44pub const SIGNAL_JS: &str = include_str!("../runtime/signal.js");
45
46/// The minimal DOM the runtime and everything downstream of it run
47/// against when there is no browser.
48///
49/// Exposed from here because four crates were reaching for it and only one
50/// of them owns it. They used to reach across the workspace with
51/// `../../zdc-runtime/tests/dom-shim.js`, which works in a workspace build
52/// and does not survive `cargo package`: a crate may only embed files
53/// inside its own directory. One copy, one owner, and a published
54/// `zdc-runtime` that compiles.
55pub const DOM_SHIM_JS: &str = include_str!("../runtime/dom-shim.js");
56
57/// DOM rendering. Requires a document, so it is embedded for shipping
58/// rather than for evaluation here.
59pub const DOM_JS: &str = include_str!("../runtime/dom.js");
60
61/// The lifecycle of a `foreign … gives view`: create, update, destroy.
62///
63/// Its own module rather than part of `dom.js` because a DOM-owning
64/// foreign is optional and its machinery is not small: a program that
65/// writes none must not download it (§16.3.1). It imports `signal.js` and
66/// nothing else — the node is handed in, so there is no DOM dependency.
67pub const FOREIGN_JS: &str = include_str!("../runtime/foreign.js");
68
69/// The `Prose` render path — the one function in the runtime that parses
70/// HTML. Its own module so a program with no `Prose` does not ship it.
71pub const MARKUP_JS: &str = include_str!("../runtime/markup.js");
72
73/// Keyed list reconciliation: `each`, `eachInto` and the interim key
74/// function.
75///
76/// Its own module for the reason `foreign.js` and `markup.js` are: a
77/// program with no list must not download a reconciler it never calls
78/// (§16.3.1), and the minimal-move reconciler §16.10 scheduled is the
79/// largest single thing the renderer contains. It imports `signal.js` and
80/// one function from `dom.js`, both of which a program with a list has
81/// already linked.
82pub const LIST_JS: &str = include_str!("../runtime/list.js");
83
84/// The `remembered` placement's store: `localStorage`, as a signal.
85///
86/// Its own module for the reason `foreign.js`, `markup.js` and `list.js`
87/// are: a program that declares no `remembered` state must not download a
88/// store wrapper it never calls (§16.3.1). It imports `signal.js` and
89/// `wire.js` — the same encoding a `durable` value uses for the same trip,
90/// because `JSON.stringify` turns a `Map` into `{}` here exactly as it
91/// does there.
92pub const REMEMBERED_JS: &str = include_str!("../runtime/remembered.js");
93
94/// `media "…"` — a CSS media query, as a signal that changes with it.
95///
96/// Its own module, and it imports `signal.js` and nothing else: a program
97/// that asks the browser no question must not ship a `matchMedia`
98/// subscription (§16.3.1).
99pub const MEDIA_JS: &str = include_str!("../runtime/media.js");
100
101/// The clock: `every "250ms"`, `every frame` and `after "2s"`.
102///
103/// Its own module for the same reason as the modules above, and the size
104/// gate is the reason it is not in `signal.js`: a null program links
105/// `signal.js`, so anything put there is shipped to every program forever.
106/// It imports `signal.js` and nothing else — a clock writes a cell and
107/// touches no DOM.
108pub const CLOCK_JS: &str = include_str!("../runtime/clock.js");
109
110/// Document key listeners: `on key "Escape"`.
111///
112/// Its own module for the reason the modules above are: a program that
113/// writes no `on key` must not download it (§16.3.1).
114/// It imports `signal.js` and nothing else — it needs a listener and a
115/// focus question rather than a node to render into.
116pub const KEYS_JS: &str = include_str!("../runtime/keys.js");
117
118/// The outbound request a `request` declaration is (#19).
119///
120/// Its own module for the reason the modules above are theirs, and with
121/// more riding on it: a program that declares no
122/// `request` must not ship the one `fetch` in the runtime that can name a
123/// host it was not given. It imports `signal.js` and nothing else.
124pub const REQUEST_JS: &str = include_str!("../runtime/request.js");
125
126/// The client half of the derived boundary: `$remote` and `$call`.
127///
128/// A bundle links against this only when the split found a crossing, so a
129/// client-only program still ships nothing it does not use (§16.3.1).
130pub const RPC_JS: &str = include_str!("../runtime/rpc.js");
131
132/// The wire format: how a ZD value survives JSON.
133///
134/// Its own module because three separate things encode and decode with it
135/// — the browser, the platform adapter, and the live-sync stream — and a
136/// second copy of the rules is how they come to disagree.
137pub const WIRE_JS: &str = include_str!("../runtime/wire.js");
138
139/// Live sync for `durable` placement, and the transport seam it needs.
140///
141/// Shipped only when the split found a durable key. It imports `rpc.js`,
142/// which a program with a crossing already has.
143pub const STORE_JS: &str = include_str!("../runtime/store.js");
144
145/// The built-in view elements.
146pub const ELEMENTS_JS: &str = include_str!("../runtime/elements.js");
147
148/// The base styling of the built-in elements, as classes.
149///
150/// Spec §16.2 R6: `Column` and `Row` carry `zd-col`/`zd-row` rather than an
151/// inline style object, so the declarations have to ship somewhere. This is
152/// the base layer of the `styles.css` a build emits.
153pub const BASE_CSS: &str = include_str!("../runtime/base.css");
154
155/// Which build a runtime module is being emitted for — spec §16.3.1's
156/// "ships nothing it does not use", applied to the checks themselves.
157///
158/// # Why there are two builds at all
159///
160/// Several of the defects this repository has found were invisible to
161/// every static pass and visible only in an emitted program's answer: a
162/// durable `Map` serialised to `{}` (#204), a `switch` fell through. A
163/// runtime that checks its own invariants is where that class is caught
164/// next time. But a check that runs in production is a check a reader
165/// downloads and pays for on every event, and the size gate in
166/// `crates/zdc-bench/tests/scaling.rs` is measured in single-digit bytes
167/// of headroom — so an assertion that could not be removed would have to
168/// be argued against on size, one at a time, forever.
169///
170/// So the assertions are marked and the release build removes them. What
171/// makes that safe rather than a second source of truth is the marker's
172/// shape: it delimits *whole lines*, so what a release build ships is a
173/// subsequence of the lines a developer reads and tests, and
174/// `the_release_runtime_still_passes_the_suite` in `tests/render.rs` runs
175/// the stripped source through the same suite as the unstripped one.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
177pub enum Mode {
178 /// Keep the assertions. `zdc dev` builds this.
179 Development,
180 /// Remove them. `zdc build` builds this, and it is the default,
181 /// because the failure that costs a reader bytes must be the one that
182 /// takes an explicit decision to cause.
183 #[default]
184 Release,
185}
186
187/// The line that opens a block only a development build carries.
188pub const DEV_OPEN: &str = "// $dev";
189
190/// The line that closes one.
191pub const DEV_CLOSE: &str = "// $end";
192
193/// One runtime module's source, as the given build ships it.
194pub fn for_mode(source: &'static str, mode: Mode) -> Cow<'static, str> {
195 match mode {
196 Mode::Development => Cow::Borrowed(source),
197 Mode::Release => Cow::Owned(strip_dev_blocks(source)),
198 }
199}
200
201/// Drop every `// $dev` … `// $end` block, markers included.
202///
203/// Whole lines and no nesting: a nested block would need a depth counter
204/// here and would let a reader mis-count which `// $end` closes what, and
205/// no assertion has wanted one. `dev_blocks_are_balanced` fails the build
206/// if a module ever writes one, rather than this function guessing.
207fn strip_dev_blocks(source: &str) -> String {
208 let mut out = String::with_capacity(source.len());
209 let mut inside = false;
210 for line in source.lines() {
211 match line.trim() {
212 DEV_OPEN => inside = true,
213 DEV_CLOSE => inside = false,
214 _ if !inside => {
215 out.push_str(line);
216 out.push('\n');
217 }
218 _ => {}
219 }
220 }
221 out
222}
223
224/// Every embedded runtime module, by the path a bundle writes it to.
225///
226/// One list, so a module added to this crate is covered by the marker
227/// check and by the size survey without anyone remembering to add it
228/// twice.
229pub const MODULES: &[(&str, &str)] = &[
230 ("runtime/signal.js", SIGNAL_JS),
231 ("runtime/dom.js", DOM_JS),
232 ("runtime/foreign.js", FOREIGN_JS),
233 ("runtime/markup.js", MARKUP_JS),
234 ("runtime/keys.js", KEYS_JS),
235 ("runtime/wire.js", WIRE_JS),
236 // `list.js` was missing from this list, which is the exact failure the
237 // doc comment above promises it prevents: it carries two `// $dev`
238 // blocks, and an unbalanced marker in an unlisted module deletes the
239 // rest of that file from every release build with nothing to say so.
240 ("runtime/list.js", LIST_JS),
241 ("runtime/request.js", REQUEST_JS),
242 ("runtime/rpc.js", RPC_JS),
243 ("runtime/store.js", STORE_JS),
244 ("runtime/elements.js", ELEMENTS_JS),
245];
246
247/// An evaluation failure, with the engine's own message.
248#[derive(Debug)]
249pub struct RuntimeError {
250 pub message: String,
251 /// `true` when the engine stopped the program rather than the program
252 /// stopping itself: a loop that never ends, or recursion that never
253 /// bottoms out. The two need different diagnostics, because one is a
254 /// mistake in the program and the other is a mistake about what a
255 /// build is allowed to do.
256 pub budget_exceeded: bool,
257}
258
259impl std::fmt::Display for RuntimeError {
260 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261 f.write_str(&self.message)
262 }
263}
264
265impl std::error::Error for RuntimeError {}
266
267#[cfg(feature = "evaluate")]
268impl From<JsError> for RuntimeError {
269 fn from(error: JsError) -> Self {
270 let budget_exceeded = matches!(
271 error.as_native().map(|native| &native.kind),
272 Some(JsNativeErrorKind::RuntimeLimit)
273 );
274 RuntimeError {
275 message: error.to_string(),
276 budget_exceeded,
277 }
278 }
279}
280
281/// How much work one evaluation may do before the engine stops it.
282///
283/// A bound, not a timeout. §17.4.8 reached for a wall-clock budget because
284/// it assumed the code would run in someone else's process, where there is
285/// nothing to meter; in an engine the compiler owns there is, and a bound
286/// is strictly better. It is **deterministic** — the same program fails on
287/// a slow machine and a fast one alike — and §14A.4 cannot tolerate a
288/// build failure that depends on how busy the host was, which is the same
289/// argument §17.4.7 makes against seeding a parity test randomly.
290///
291/// Every non-terminating JavaScript program loops or recurses, so bounding
292/// both bounds termination.
293#[cfg(feature = "evaluate")]
294const LOOP_ITERATION_BUDGET: u64 = 10_000_000;
295
296/// What a capability may answer with.
297///
298/// Three shapes, because the closed set has three result types and a
299/// fourth would be a design decision rather than a convenience. There is
300/// no `Object` here on purpose: a capability that could return arbitrary
301/// structure would be a module loader with extra steps.
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub enum Provided {
304 Text(String),
305 /// HTML, from the one capability that produces it.
306 ///
307 /// It carries a `String` exactly as `Text` does, and crosses into the
308 /// engine as the same JavaScript string — the distinction is not a
309 /// runtime representation, it is the compiler's `Type::Markup`, which
310 /// is what decides whether a value may reach the one element that
311 /// parses HTML. Keeping the variant separate here means the answer to
312 /// "which capability produced HTML" is in the type of the answer
313 /// rather than in a comment.
314 Markup(String),
315 List(Vec<String>),
316}
317
318/// One capability the compiler answers for the code it is running.
319///
320/// `answer` is a plain function pointer, not a closure: the only state a
321/// capability may consult is the project root it is handed, so there is
322/// nowhere for ambient authority to hide.
323#[derive(Clone, Copy)]
324pub struct Capability {
325 pub name: &'static str,
326 pub answer: fn(&Path, &str) -> Result<Provided, String>,
327}
328
329/// The global a capability is registered under before `$build` gathers it.
330///
331/// `$`-prefixed, which no ZDeceptron identifier can be, so a program
332/// cannot name one and cannot shadow one.
333#[cfg(feature = "evaluate")]
334fn global_name(capability: &str) -> String {
335 format!("$build${capability}")
336}
337
338/// Turn a capability's answer into a JavaScript value, or into a thrown
339/// error carrying the refusal verbatim.
340#[cfg(feature = "evaluate")]
341fn provided(answer: Result<Provided, String>, context: &mut Context) -> JsResult<JsValue> {
342 match answer {
343 Ok(Provided::Text(text)) | Ok(Provided::Markup(text)) => {
344 Ok(JsValue::from(js_string!(text.as_str())))
345 }
346 Ok(Provided::List(items)) => {
347 let values: Vec<JsValue> = items
348 .iter()
349 .map(|item| JsValue::from(js_string!(item.as_str())))
350 .collect();
351 Ok(JsArray::from_iter(values, context).into())
352 }
353 Err(refusal) => Err(JsNativeError::typ().with_message(refusal).into()),
354 }
355}
356
357/// A JavaScript sandbox the compiler owns, for running the code it just
358/// emitted — spec §17.4.8.
359///
360/// This is the crate's second job, stated in its own module doc: verifying
361/// ZDeceptron must not require a JavaScript toolchain. Build-time
362/// evaluation is the same requirement pointed at the user rather than at
363/// CI. `zdc build` therefore evaluates a `static` signal **in process**,
364/// and a developer who uses the fourth placement still installs one binary
365/// and nothing else.
366#[cfg(feature = "evaluate")]
367pub struct Sandbox {
368 context: Context,
369}
370
371#[cfg(feature = "evaluate")]
372impl Default for Sandbox {
373 fn default() -> Sandbox {
374 Sandbox::new()
375 }
376}
377
378#[cfg(feature = "evaluate")]
379impl Sandbox {
380 pub fn new() -> Sandbox {
381 let mut context = Context::default();
382 context
383 .runtime_limits_mut()
384 .set_loop_iteration_limit(LOOP_ITERATION_BUDGET);
385 Sandbox { context }
386 }
387
388 /// Evaluate a module in the sandbox, keeping its bindings for later
389 /// questions.
390 ///
391 /// `export` is stripped rather than honoured: the engine's module
392 /// loader wants a filesystem resolver, and a module evaluated as a
393 /// script leaves its top-level `const`s where a following `eval` can
394 /// see them — which is exactly the interface wanted here.
395 pub fn load(&mut self, module: &str) -> Result<(), RuntimeError> {
396 let script = strip_exports(module);
397 self.context
398 .eval(Source::from_bytes(script.as_bytes()))
399 .map(|_| ())
400 .map_err(RuntimeError::from)
401 }
402
403 /// Install the capabilities the code being run may ask the compiler
404 /// for, as `$build.<name>(argument)`.
405 ///
406 /// **This is the whole of the build-time FFI, and its shape is the
407 /// argument for it.** A capability is a Rust function pointer with a
408 /// fixed signature, resolved against `root` before it is answered.
409 /// Nothing is imported, nothing is resolved from a registry, and
410 /// nothing outside `root` is reachable — which a module loader could
411 /// promise none of.
412 ///
413 /// `root` is passed to each answer rather than baked into it so the
414 /// sandbox boundary is one value, checked in one place, and visible in
415 /// every capability's signature.
416 pub fn provide(
417 &mut self,
418 root: &Path,
419 capabilities: &[Capability],
420 ) -> Result<(), RuntimeError> {
421 for capability in capabilities {
422 let answer = capability.answer;
423 self.context
424 .register_global_builtin_callable(
425 js_string!(global_name(capability.name).as_str()),
426 1,
427 NativeFunction::from_copy_closure_with_captures(
428 move |_this, args, root: &PathBuf, context| {
429 let argument = match args.first() {
430 Some(value) => value.to_string(context)?.to_std_string_escaped(),
431 None => {
432 return Err(JsNativeError::typ()
433 .with_message("a capability takes one argument")
434 .into())
435 }
436 };
437 provided(answer(root, &argument), context)
438 },
439 root.to_path_buf(),
440 ),
441 )
442 .map_err(RuntimeError::from)?;
443 }
444
445 // One object, so generated code spells a capability the same way
446 // the language does: `build read x` becomes `$build.read(x)`.
447 let fields: Vec<String> = capabilities
448 .iter()
449 .map(|capability| {
450 format!(
451 " {}: {}",
452 capability.name,
453 global_name(capability.name).as_str()
454 )
455 })
456 .collect();
457 self.load(&format!(
458 "const $build = {{\n{},\n}};\n",
459 fields.join(",\n")
460 ))
461 }
462
463 /// Evaluate an expression and return its value as text.
464 ///
465 /// `String(value)`, not the engine's debug rendering: a string comes
466 /// back as itself, so a caller that asked for `JSON.stringify(x)` gets
467 /// the JSON and a caller that asked for a file's contents gets the
468 /// contents. There is no framing anywhere in this interface, because
469 /// one question returns one answer.
470 pub fn text(&mut self, expression: &str) -> Result<String, RuntimeError> {
471 let value = self
472 .context
473 .eval(Source::from_bytes(expression.as_bytes()))
474 .map_err(RuntimeError::from)?;
475 let text = value
476 .to_string(&mut self.context)
477 .map_err(RuntimeError::from)?;
478 Ok(text.to_std_string_escaped())
479 }
480}
481
482/// Evaluate `script` with the reactivity core already in scope.
483///
484/// The core is inlined rather than imported: the engine's module loader
485/// wants a filesystem resolver, and the point here is to exercise the
486/// exact source that ships, not to test a module loader. `export` is
487/// stripped so the same file serves both purposes without a build step.
488#[cfg(feature = "evaluate")]
489pub fn eval_with_signals(script: &str) -> Result<String, RuntimeError> {
490 let mut context = Context::default();
491 let core = strip_exports(SIGNAL_JS);
492
493 context
494 .eval(Source::from_bytes(core.as_bytes()))
495 .map_err(RuntimeError::from)?;
496
497 let value = context
498 .eval(Source::from_bytes(script.as_bytes()))
499 .map_err(RuntimeError::from)?;
500
501 Ok(value.display().to_string())
502}
503
504/// Remove ES module syntax so a module can be evaluated as a script.
505///
506/// Only leading `export ` is removed. The runtime has no imports between
507/// `signal.js` and anything else, which is deliberate — the reactivity
508/// core has no dependencies at all, so it can be evaluated in isolation.
509#[cfg(feature = "evaluate")]
510fn strip_exports(source: &str) -> String {
511 source
512 .lines()
513 .map(|line| match line.strip_prefix("export ") {
514 Some(rest) => rest,
515 None => line,
516 })
517 .collect::<Vec<_>>()
518 .join("\n")
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn the_embedded_sources_are_not_empty() {
527 assert!(SIGNAL_JS.contains("export function signal"));
528 assert!(DOM_JS.contains("export function el"));
529 assert!(FOREIGN_JS.contains("export function foreign"));
530 assert!(MARKUP_JS.contains("export function markup"));
531 assert!(MARKUP_JS.contains("export function bindMarkup"));
532 // The render path moved out of `dom.js` whole. `template()` still
533 // assigns `innerHTML` — parsing one static string per region is
534 // what template cloning *is* — so the property is the wrong thing
535 // to look for; the exported entry points are the right one.
536 assert!(!DOM_JS.contains("export function markup("));
537 assert!(!DOM_JS.contains("export function bindMarkup("));
538 assert!(RPC_JS.contains("export function remoteCell"));
539 assert!(STORE_JS.contains("export function subscribe"));
540 assert!(WIRE_JS.contains("export function stringify"));
541 assert!(DOM_JS.contains("export function template"));
542 assert!(ELEMENTS_JS.contains("export function Column"));
543 assert!(BASE_CSS.contains(".zd-col"));
544 }
545
546 /// A release build carries no assertion, and a development build does.
547 #[test]
548 fn a_release_build_drops_the_dev_blocks_a_development_build_keeps() {
549 let source = "keep one\n // $dev\n throw new Error('x');\n // $end\nkeep two\n";
550 assert_eq!(
551 for_mode_str(source, Mode::Release),
552 "keep one\nkeep two\n",
553 "a release build ships the assertion"
554 );
555 assert_eq!(
556 for_mode_str(source, Mode::Development),
557 source,
558 "a development build dropped one"
559 );
560 }
561
562 /// The stripped text is a subsequence of the lines a developer reads.
563 ///
564 /// This is what makes the two builds one source rather than two: a
565 /// marker can only remove lines, so no line can differ between them.
566 #[test]
567 fn stripping_only_ever_removes_whole_lines() {
568 let mut checked = 0;
569 for (name, source) in MODULES {
570 let release = strip_dev_blocks(source);
571 let mut development = source.lines();
572 for line in release.lines() {
573 checked += 1;
574 assert!(
575 development.any(|written| written == line),
576 "{name}: the release build has a line the development build does not: {line}"
577 );
578 }
579 }
580 // The runtime is thousands of lines; a loop that checked a handful
581 // of them would be a loop that had stopped finding the modules.
582 assert!(
583 MODULES.len() >= 8 && checked > 2_000,
584 "{checked} lines compared across {} modules",
585 MODULES.len()
586 );
587 }
588
589 /// Every marker in every module is matched, and none is nested.
590 ///
591 /// Without this an unclosed `// $dev` would silently delete the rest of
592 /// a module from every release build, which is the worst failure this
593 /// mechanism could have: it compiles, it ships, and the missing code is
594 /// whatever came after the mistake.
595 #[test]
596 fn dev_blocks_are_balanced() {
597 let mut blocks = 0;
598 for (name, source) in MODULES {
599 let mut inside = false;
600 for (number, line) in source.lines().enumerate() {
601 match line.trim() {
602 DEV_OPEN => {
603 assert!(!inside, "{name}:{}: a nested `{DEV_OPEN}`", number + 1);
604 inside = true;
605 blocks += 1;
606 }
607 DEV_CLOSE => {
608 assert!(inside, "{name}:{}: a stray `{DEV_CLOSE}`", number + 1);
609 inside = false;
610 }
611 _ => {}
612 }
613 }
614 assert!(!inside, "{name}: a `{DEV_OPEN}` block was never closed");
615 }
616 assert!(
617 blocks >= 2,
618 "only {blocks} dev blocks in the whole runtime; the mechanism is \
619 not carrying any assertions, so nothing it claims is tested"
620 );
621 }
622
623 fn for_mode_str(source: &str, mode: Mode) -> String {
624 match mode {
625 Mode::Development => source.to_string(),
626 Mode::Release => strip_dev_blocks(source),
627 }
628 }
629
630 /// The tests below this line all need an engine to run. They are
631 /// gated on the same feature the engine is, so `--no-default-features`
632 /// still runs the ones that cover the half of the crate that remains
633 /// — rather than compiling nothing and calling it a pass.
634 #[cfg(feature = "evaluate")]
635 #[test]
636 fn stripping_exports_leaves_the_declaration() {
637 assert_eq!(
638 strip_exports("export function signal(x) {}"),
639 "function signal(x) {}"
640 );
641 assert_eq!(strip_exports(" indented stays"), " indented stays");
642 }
643
644 #[cfg(feature = "evaluate")]
645 #[test]
646 fn a_provided_capability_answers_the_code_it_is_running() {
647 fn shout(root: &Path, argument: &str) -> Result<Provided, String> {
648 Ok(Provided::Text(format!(
649 "{}/{}",
650 root.display(),
651 argument.to_uppercase()
652 )))
653 }
654 fn twice(_root: &Path, argument: &str) -> Result<Provided, String> {
655 Ok(Provided::List(vec![
656 argument.to_string(),
657 argument.to_string(),
658 ]))
659 }
660
661 let mut sandbox = Sandbox::new();
662 sandbox
663 .provide(
664 Path::new("/project"),
665 &[
666 Capability {
667 name: "shout",
668 answer: shout,
669 },
670 Capability {
671 name: "twice",
672 answer: twice,
673 },
674 ],
675 )
676 .expect("capabilities install");
677
678 assert_eq!(
679 sandbox.text("$build.shout(\"hi\")").expect("answers"),
680 "/project/HI"
681 );
682 assert_eq!(
683 sandbox
684 .text("$build.twice(\"a\").join(\",\")")
685 .expect("answers"),
686 "a,a"
687 );
688 }
689
690 /// A refusal is a thrown error, so it stops the build rather than
691 /// becoming a value the program goes on to inline.
692 #[cfg(feature = "evaluate")]
693 #[test]
694 fn a_refused_capability_stops_the_evaluation() {
695 fn always_refuses(_root: &Path, _argument: &str) -> Result<Provided, String> {
696 Err("no".to_string())
697 }
698
699 let mut sandbox = Sandbox::new();
700 sandbox
701 .provide(
702 Path::new("/project"),
703 &[Capability {
704 name: "nope",
705 answer: always_refuses,
706 }],
707 )
708 .expect("capabilities install");
709
710 let error = sandbox.text("$build.nope(\"x\")").expect_err("must refuse");
711 assert!(error.message.contains("no"), "{error}");
712 assert!(!error.budget_exceeded);
713 }
714
715 #[cfg(feature = "evaluate")]
716 #[test]
717 fn a_signal_round_trips_through_the_engine() {
718 let out = eval_with_signals(
719 r#"
720 const [get, set] = signal(1);
721 set(41);
722 get() + 1
723 "#,
724 )
725 .expect("evaluates");
726 assert_eq!(out, "42");
727 }
728}