Skip to main content

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}