Skip to main content

brep_script_core/
lib.rs

1//! The interpreter core: evaluate one script, call one global function with
2//! one JSON argument, and hand back its JSON result or its refusal.
3//!
4//! This crate depends on Boa and `serde_json` and nothing else — no file system,
5//! no network, no store. That is deliberate: the operator chose Boa so the same
6//! interpreter runs inside the CAD app, including its wasm build
7//! (plm-cad-integration-todo §3 S11). It was the PLM's `scripting::engine` module
8//! and is still reached under that name there. Everything that reaches outside the
9//! interpreter is installed by the caller through [`call`]'s `install` argument:
10//! the server's bindings are in `BREP_plm/src/scripting/host.rs`, the CAD app's in
11//! `BREP_app/src/scripting.rs`.
12//!
13//! # A fresh interpreter per call
14//!
15//! Every call builds a new [`Context`], evaluates the file, and drops it. No
16//! state leaks from one hook call into the next, a script edited on disk is
17//! simply the script the next call evaluates, and a `Context` (which is not
18//! `Send`) never has to cross a thread.
19//!
20//! # What is bounded, honestly
21//!
22//! Boa's [`RuntimeLimits`](boa_engine::vm::RuntimeLimits) bound three things,
23//! and they are set here:
24//!
25//! * each LOOP may run at most [`LOOP_ITERATION_LIMIT`] iterations — an
26//!   infinite `while (true)` throws instead of hanging;
27//! * call depth is at most [`RECURSION_LIMIT`] — runaway recursion throws;
28//! * the VM stack size.
29//!
30//! There is NO wall-clock limit: Boa cannot pre-empt a running script. A script
31//! of many individually-bounded loops can still run long, and a script blocked
32//! inside a host function (an HTTP call, a child process) is bounded only by
33//! that host function's own timeout.
34
35use std::cell::RefCell;
36use std::rc::Rc;
37
38use boa_engine::object::ObjectInitializer;
39use boa_engine::property::Attribute;
40use boa_engine::{js_string, Context, JsError, JsResult, JsString, JsValue, NativeFunction, Source};
41use serde_json::Value;
42
43/// Boa itself, so a host names the same engine's types (`Context`, `JsValue`,
44/// `ObjectInitializer`) its bindings are built from without a Boa line of its
45/// own that could drift to another version.
46pub use boa_engine;
47
48/// The most iterations any one loop may run before it throws.
49pub const LOOP_ITERATION_LIMIT: u64 = 10_000_000;
50
51/// The deepest call stack a script may build before it throws.
52pub const RECURSION_LIMIT: usize = 512;
53
54/// Lines a script wrote through `console.log`, kept for the caller — the test
55/// run shows them, and a failing hook's log goes to the server log.
56pub type Logs = Rc<RefCell<Vec<String>>>;
57
58/// A call that returned.
59#[derive(Debug, Clone)]
60pub struct Success {
61    /// What the function returned, as JSON. `undefined` reads as `null`.
62    pub value: Value,
63    pub logs: Vec<String>,
64}
65
66/// A call that threw, failed to parse, or named a function the file does not
67/// define. `message` is what a person reads: for `throw new Error("x")` it is
68/// `x`, for `throw "x"` it is `x`.
69#[derive(Debug, Clone)]
70pub struct Failure {
71    pub message: String,
72    pub logs: Vec<String>,
73}
74
75/// Evaluate `source` (named `file` in messages), then call the global
76/// function `function` with `input` as its single argument.
77///
78/// `install` runs after `console` is defined and before the file is
79/// evaluated, and is where a caller adds its host objects.
80pub fn call(
81    source: &str,
82    file: &str,
83    function: &str,
84    input: &Value,
85    install: &dyn Fn(&mut Context, &Logs) -> JsResult<()>,
86) -> Result<Success, Failure> {
87    let mut context = Context::default();
88    let limits = context.runtime_limits_mut();
89    limits.set_loop_iteration_limit(LOOP_ITERATION_LIMIT);
90    limits.set_recursion_limit(RECURSION_LIMIT);
91
92    let logs: Logs = Rc::new(RefCell::new(Vec::new()));
93    let fail = |message: String, logs: &Logs| Failure {
94        message,
95        logs: logs.borrow().clone(),
96    };
97
98    if let Err(error) = install_console(&mut context, &logs).and_then(|()| install(&mut context, &logs)) {
99        return Err(fail(format!("setting up the script host failed: {}", describe(error, &mut context)), &logs));
100    }
101
102    let path = std::path::Path::new(file);
103    if let Err(error) = context.eval(Source::from_bytes(source).with_path(path)) {
104        let message = describe(error, &mut context);
105        return Err(fail(format!("{file}: {message}"), &logs));
106    }
107
108    let global = context.global_object();
109    let callee = match global.get(JsString::from(function), &mut context) {
110        Ok(value) => value,
111        Err(error) => return Err(fail(describe(error, &mut context), &logs)),
112    };
113    let Some(callee) = callee.as_callable() else {
114        return Err(fail(format!("{file} defines no function {function}()"), &logs));
115    };
116
117    let argument = match JsValue::from_json(input, &mut context) {
118        Ok(value) => value,
119        Err(error) => return Err(fail(describe(error, &mut context), &logs)),
120    };
121    let returned = match callee.call(&JsValue::undefined(), &[argument], &mut context) {
122        Ok(value) => value,
123        Err(error) => return Err(fail(describe(error, &mut context), &logs)),
124    };
125    // A script may have queued promise jobs; run them so their side effects
126    // (a log line, a write) are not silently dropped.
127    let _ = context.run_jobs();
128
129    let value = match returned.to_json(&mut context) {
130        Ok(json) => json.unwrap_or(Value::Null),
131        Err(error) => {
132            return Err(fail(
133                format!("{function}() returned a value that is not JSON: {}", describe(error, &mut context)),
134                &logs,
135            ))
136        }
137    };
138    let logs = logs.borrow().clone();
139    Ok(Success { value, logs })
140}
141
142/// The message a person should read for `error`.
143pub fn describe(error: JsError, context: &mut Context) -> String {
144    if let Some(value) = error.as_opaque() {
145        if let Some(text) = value.as_string() {
146            return text.to_std_string_escaped();
147        }
148    }
149    match error.try_native(context) {
150        Ok(native) if !native.message().is_empty() => native.message().to_string(),
151        Ok(native) => native.to_string(),
152        Err(_) => {
153            // A thrown plain object: show it as JSON if it is JSON.
154            if let Some(value) = error.as_opaque() {
155                if let Ok(Some(json)) = value.to_json(context) {
156                    return json.to_string();
157                }
158            }
159            error.to_string()
160        }
161    }
162}
163
164/// One argument as the text a log line shows: strings verbatim, everything
165/// else as JSON.
166pub fn text_of(value: &JsValue, context: &mut Context) -> String {
167    if let Some(text) = value.as_string() {
168        return text.to_std_string_escaped();
169    }
170    match value.to_json(context) {
171        Ok(Some(json)) => json.to_string(),
172        Ok(None) => "undefined".to_string(),
173        Err(_) => value.display().to_string(),
174    }
175}
176
177/// `console.log / info / warn / error`, all writing into `logs`.
178fn install_console(context: &mut Context, logs: &Logs) -> JsResult<()> {
179    let make = |prefix: &'static str| {
180        let logs = logs.clone();
181        // SAFETY: the closure captures an `Rc<RefCell<Vec<String>>>`, which
182        // holds no garbage-collected value, so the collector has nothing in it
183        // to trace.
184        unsafe {
185            NativeFunction::from_closure(move |_, args, context| {
186                let line = args
187                    .iter()
188                    .map(|value| text_of(value, context))
189                    .collect::<Vec<_>>()
190                    .join(" ");
191                logs.borrow_mut().push(format!("{prefix}{line}"));
192                Ok(JsValue::undefined())
193            })
194        }
195    };
196    let console = ObjectInitializer::new(context)
197        .function(make(""), js_string!("log"), 0)
198        .function(make(""), js_string!("info"), 0)
199        .function(make("warn: "), js_string!("warn"), 0)
200        .function(make("error: "), js_string!("error"), 0)
201        .build();
202    context.register_global_property(js_string!("console"), console, Attribute::all())
203}
204