#![forbid(unsafe_code)]
use std::borrow::Cow;
use std::path::Path;
#[cfg(feature = "evaluate")]
use std::path::PathBuf;
#[cfg(feature = "evaluate")]
use boa_engine::object::builtins::JsArray;
#[cfg(feature = "evaluate")]
use boa_engine::{
js_string, Context, JsError, JsNativeError, JsNativeErrorKind, JsResult, JsValue,
NativeFunction, Source,
};
pub const SIGNAL_JS: &str = include_str!("../runtime/signal.js");
pub const DOM_SHIM_JS: &str = include_str!("../runtime/dom-shim.js");
pub const DOM_JS: &str = include_str!("../runtime/dom.js");
pub const FOREIGN_JS: &str = include_str!("../runtime/foreign.js");
pub const MARKUP_JS: &str = include_str!("../runtime/markup.js");
pub const LIST_JS: &str = include_str!("../runtime/list.js");
pub const REMEMBERED_JS: &str = include_str!("../runtime/remembered.js");
pub const MEDIA_JS: &str = include_str!("../runtime/media.js");
pub const CLOCK_JS: &str = include_str!("../runtime/clock.js");
pub const KEYS_JS: &str = include_str!("../runtime/keys.js");
pub const REQUEST_JS: &str = include_str!("../runtime/request.js");
pub const RPC_JS: &str = include_str!("../runtime/rpc.js");
pub const WIRE_JS: &str = include_str!("../runtime/wire.js");
pub const STORE_JS: &str = include_str!("../runtime/store.js");
pub const ELEMENTS_JS: &str = include_str!("../runtime/elements.js");
pub const BASE_CSS: &str = include_str!("../runtime/base.css");
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
Development,
#[default]
Release,
}
pub const DEV_OPEN: &str = "// $dev";
pub const DEV_CLOSE: &str = "// $end";
pub fn for_mode(source: &'static str, mode: Mode) -> Cow<'static, str> {
match mode {
Mode::Development => Cow::Borrowed(source),
Mode::Release => Cow::Owned(strip_dev_blocks(source)),
}
}
fn strip_dev_blocks(source: &str) -> String {
let mut out = String::with_capacity(source.len());
let mut inside = false;
for line in source.lines() {
match line.trim() {
DEV_OPEN => inside = true,
DEV_CLOSE => inside = false,
_ if !inside => {
out.push_str(line);
out.push('\n');
}
_ => {}
}
}
out
}
pub const MODULES: &[(&str, &str)] = &[
("runtime/signal.js", SIGNAL_JS),
("runtime/dom.js", DOM_JS),
("runtime/foreign.js", FOREIGN_JS),
("runtime/markup.js", MARKUP_JS),
("runtime/keys.js", KEYS_JS),
("runtime/wire.js", WIRE_JS),
("runtime/list.js", LIST_JS),
("runtime/request.js", REQUEST_JS),
("runtime/rpc.js", RPC_JS),
("runtime/store.js", STORE_JS),
("runtime/elements.js", ELEMENTS_JS),
];
#[derive(Debug)]
pub struct RuntimeError {
pub message: String,
pub budget_exceeded: bool,
}
impl std::fmt::Display for RuntimeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for RuntimeError {}
#[cfg(feature = "evaluate")]
impl From<JsError> for RuntimeError {
fn from(error: JsError) -> Self {
let budget_exceeded = matches!(
error.as_native().map(|native| &native.kind),
Some(JsNativeErrorKind::RuntimeLimit)
);
RuntimeError {
message: error.to_string(),
budget_exceeded,
}
}
}
#[cfg(feature = "evaluate")]
const LOOP_ITERATION_BUDGET: u64 = 10_000_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Provided {
Text(String),
Markup(String),
List(Vec<String>),
}
#[derive(Clone, Copy)]
pub struct Capability {
pub name: &'static str,
pub answer: fn(&Path, &str) -> Result<Provided, String>,
}
#[cfg(feature = "evaluate")]
fn global_name(capability: &str) -> String {
format!("$build${capability}")
}
#[cfg(feature = "evaluate")]
fn provided(answer: Result<Provided, String>, context: &mut Context) -> JsResult<JsValue> {
match answer {
Ok(Provided::Text(text)) | Ok(Provided::Markup(text)) => {
Ok(JsValue::from(js_string!(text.as_str())))
}
Ok(Provided::List(items)) => {
let values: Vec<JsValue> = items
.iter()
.map(|item| JsValue::from(js_string!(item.as_str())))
.collect();
Ok(JsArray::from_iter(values, context).into())
}
Err(refusal) => Err(JsNativeError::typ().with_message(refusal).into()),
}
}
#[cfg(feature = "evaluate")]
pub struct Sandbox {
context: Context,
}
#[cfg(feature = "evaluate")]
impl Default for Sandbox {
fn default() -> Sandbox {
Sandbox::new()
}
}
#[cfg(feature = "evaluate")]
impl Sandbox {
pub fn new() -> Sandbox {
let mut context = Context::default();
context
.runtime_limits_mut()
.set_loop_iteration_limit(LOOP_ITERATION_BUDGET);
Sandbox { context }
}
pub fn load(&mut self, module: &str) -> Result<(), RuntimeError> {
let script = strip_exports(module);
self.context
.eval(Source::from_bytes(script.as_bytes()))
.map(|_| ())
.map_err(RuntimeError::from)
}
pub fn provide(
&mut self,
root: &Path,
capabilities: &[Capability],
) -> Result<(), RuntimeError> {
for capability in capabilities {
let answer = capability.answer;
self.context
.register_global_builtin_callable(
js_string!(global_name(capability.name).as_str()),
1,
NativeFunction::from_copy_closure_with_captures(
move |_this, args, root: &PathBuf, context| {
let argument = match args.first() {
Some(value) => value.to_string(context)?.to_std_string_escaped(),
None => {
return Err(JsNativeError::typ()
.with_message("a capability takes one argument")
.into())
}
};
provided(answer(root, &argument), context)
},
root.to_path_buf(),
),
)
.map_err(RuntimeError::from)?;
}
let fields: Vec<String> = capabilities
.iter()
.map(|capability| {
format!(
" {}: {}",
capability.name,
global_name(capability.name).as_str()
)
})
.collect();
self.load(&format!(
"const $build = {{\n{},\n}};\n",
fields.join(",\n")
))
}
pub fn text(&mut self, expression: &str) -> Result<String, RuntimeError> {
let value = self
.context
.eval(Source::from_bytes(expression.as_bytes()))
.map_err(RuntimeError::from)?;
let text = value
.to_string(&mut self.context)
.map_err(RuntimeError::from)?;
Ok(text.to_std_string_escaped())
}
}
#[cfg(feature = "evaluate")]
pub fn eval_with_signals(script: &str) -> Result<String, RuntimeError> {
let mut context = Context::default();
let core = strip_exports(SIGNAL_JS);
context
.eval(Source::from_bytes(core.as_bytes()))
.map_err(RuntimeError::from)?;
let value = context
.eval(Source::from_bytes(script.as_bytes()))
.map_err(RuntimeError::from)?;
Ok(value.display().to_string())
}
#[cfg(feature = "evaluate")]
fn strip_exports(source: &str) -> String {
source
.lines()
.map(|line| match line.strip_prefix("export ") {
Some(rest) => rest,
None => line,
})
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_embedded_sources_are_not_empty() {
assert!(SIGNAL_JS.contains("export function signal"));
assert!(DOM_JS.contains("export function el"));
assert!(FOREIGN_JS.contains("export function foreign"));
assert!(MARKUP_JS.contains("export function markup"));
assert!(MARKUP_JS.contains("export function bindMarkup"));
assert!(!DOM_JS.contains("export function markup("));
assert!(!DOM_JS.contains("export function bindMarkup("));
assert!(RPC_JS.contains("export function remoteCell"));
assert!(STORE_JS.contains("export function subscribe"));
assert!(WIRE_JS.contains("export function stringify"));
assert!(DOM_JS.contains("export function template"));
assert!(ELEMENTS_JS.contains("export function Column"));
assert!(BASE_CSS.contains(".zd-col"));
}
#[test]
fn a_release_build_drops_the_dev_blocks_a_development_build_keeps() {
let source = "keep one\n // $dev\n throw new Error('x');\n // $end\nkeep two\n";
assert_eq!(
for_mode_str(source, Mode::Release),
"keep one\nkeep two\n",
"a release build ships the assertion"
);
assert_eq!(
for_mode_str(source, Mode::Development),
source,
"a development build dropped one"
);
}
#[test]
fn stripping_only_ever_removes_whole_lines() {
let mut checked = 0;
for (name, source) in MODULES {
let release = strip_dev_blocks(source);
let mut development = source.lines();
for line in release.lines() {
checked += 1;
assert!(
development.any(|written| written == line),
"{name}: the release build has a line the development build does not: {line}"
);
}
}
assert!(
MODULES.len() >= 8 && checked > 2_000,
"{checked} lines compared across {} modules",
MODULES.len()
);
}
#[test]
fn dev_blocks_are_balanced() {
let mut blocks = 0;
for (name, source) in MODULES {
let mut inside = false;
for (number, line) in source.lines().enumerate() {
match line.trim() {
DEV_OPEN => {
assert!(!inside, "{name}:{}: a nested `{DEV_OPEN}`", number + 1);
inside = true;
blocks += 1;
}
DEV_CLOSE => {
assert!(inside, "{name}:{}: a stray `{DEV_CLOSE}`", number + 1);
inside = false;
}
_ => {}
}
}
assert!(!inside, "{name}: a `{DEV_OPEN}` block was never closed");
}
assert!(
blocks >= 2,
"only {blocks} dev blocks in the whole runtime; the mechanism is \
not carrying any assertions, so nothing it claims is tested"
);
}
fn for_mode_str(source: &str, mode: Mode) -> String {
match mode {
Mode::Development => source.to_string(),
Mode::Release => strip_dev_blocks(source),
}
}
#[cfg(feature = "evaluate")]
#[test]
fn stripping_exports_leaves_the_declaration() {
assert_eq!(
strip_exports("export function signal(x) {}"),
"function signal(x) {}"
);
assert_eq!(strip_exports(" indented stays"), " indented stays");
}
#[cfg(feature = "evaluate")]
#[test]
fn a_provided_capability_answers_the_code_it_is_running() {
fn shout(root: &Path, argument: &str) -> Result<Provided, String> {
Ok(Provided::Text(format!(
"{}/{}",
root.display(),
argument.to_uppercase()
)))
}
fn twice(_root: &Path, argument: &str) -> Result<Provided, String> {
Ok(Provided::List(vec![
argument.to_string(),
argument.to_string(),
]))
}
let mut sandbox = Sandbox::new();
sandbox
.provide(
Path::new("/project"),
&[
Capability {
name: "shout",
answer: shout,
},
Capability {
name: "twice",
answer: twice,
},
],
)
.expect("capabilities install");
assert_eq!(
sandbox.text("$build.shout(\"hi\")").expect("answers"),
"/project/HI"
);
assert_eq!(
sandbox
.text("$build.twice(\"a\").join(\",\")")
.expect("answers"),
"a,a"
);
}
#[cfg(feature = "evaluate")]
#[test]
fn a_refused_capability_stops_the_evaluation() {
fn always_refuses(_root: &Path, _argument: &str) -> Result<Provided, String> {
Err("no".to_string())
}
let mut sandbox = Sandbox::new();
sandbox
.provide(
Path::new("/project"),
&[Capability {
name: "nope",
answer: always_refuses,
}],
)
.expect("capabilities install");
let error = sandbox.text("$build.nope(\"x\")").expect_err("must refuse");
assert!(error.message.contains("no"), "{error}");
assert!(!error.budget_exceeded);
}
#[cfg(feature = "evaluate")]
#[test]
fn a_signal_round_trips_through_the_engine() {
let out = eval_with_signals(
r#"
const [get, set] = signal(1);
set(41);
get() + 1
"#,
)
.expect("evaluates");
assert_eq!(out, "42");
}
}