Skip to main content

outl_exec/runtimes/
js.rs

1//! `js` runtime — JavaScript via [Boa](https://boajs.dev).
2//!
3//! Boa is a JS engine in pure Rust, ES2015+ with ongoing work toward
4//! full ECMAScript conformance. Good enough for snippets in notes
5//! ("compute the slug of this title", "format this date"), nowhere
6//! near production V8.
7//!
8//! We expose a single native `__outl_log` and prepend a tiny shim that
9//! wires it into `console.log` / `.warn` / `.error`, so user code can
10//! call `console.log(...)` naturally and the output lands in our
11//! buffer.
12//!
13//! Gated behind the `lang-js` feature.
14
15// Boa's only way to register a *capturing* native function is
16// `NativeFunction::from_closure`, which is `unsafe` because the
17// closure must not capture data that's `!Send` in a way that escapes
18// `Context`'s lifetime. Our closure captures an `Rc<RefCell<String>>`
19// we own throughout `execute`, so the invariant holds trivially.
20#![allow(unsafe_code)]
21
22use std::cell::RefCell;
23use std::rc::Rc;
24use std::time::Instant;
25
26use boa_engine::{js_string, Context, JsValue, NativeFunction, Source};
27
28use crate::runtime::{ExecContext, ExecError, ExecOutput, ExitStatus, OutputFormat, Runtime};
29
30/// Boa-backed JavaScript runtime.
31pub struct JsRuntime;
32
33const CONSOLE_SHIM: &str = r#"
34globalThis.console = {
35    log:   (...a) => __outl_log(a.map(x => String(x)).join(' ') + '\n'),
36    warn:  (...a) => __outl_log(a.map(x => String(x)).join(' ') + '\n'),
37    error: (...a) => __outl_log(a.map(x => String(x)).join(' ') + '\n'),
38    info:  (...a) => __outl_log(a.map(x => String(x)).join(' ') + '\n'),
39};
40"#;
41
42impl Runtime for JsRuntime {
43    fn language(&self) -> &'static str {
44        "js"
45    }
46
47    fn execute(&self, source: &str, ctx: &ExecContext) -> Result<ExecOutput, ExecError> {
48        let start = Instant::now();
49        let mut context = Context::default();
50        // Prevent unused-variable warning when lang-query is off.
51        let _ = ctx;
52        let sink: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
53
54        // Register `__outl_log(string)` as a native fn that pushes
55        // into the shared buffer. The shim above turns `console.log`
56        // calls into invocations of this.
57        let log_sink = sink.clone();
58        let log_fn = unsafe {
59            NativeFunction::from_closure(move |_, args, ctx| {
60                if let Some(arg) = args.first() {
61                    let s = arg.to_string(ctx)?;
62                    log_sink.borrow_mut().push_str(&s.to_std_string_escaped());
63                }
64                Ok(JsValue::undefined())
65            })
66        };
67        context
68            .register_global_callable(js_string!("__outl_log"), 1, log_fn)
69            .map_err(|e| ExecError::Sandbox(format!("register __outl_log: {e}")))?;
70
71        // Register `outl.query(params)` — structured workspace query
72        // available to JS plugins and code blocks. Captures
73        // `workspace_root` so it can build a WorkspaceIndex lazily.
74        #[cfg(feature = "lang-query")]
75        {
76            let ws_root = Rc::new(ctx.workspace_root.clone());
77            let query_fn = unsafe {
78                NativeFunction::from_closure(move |_, args, js_ctx| {
79                    let root = ws_root.clone();
80                    let arg0 = args.first().cloned().unwrap_or(JsValue::undefined());
81                    let params = js_value_to_query_params(&arg0, js_ctx).map_err(|e| {
82                        boa_engine::JsError::from(
83                            boa_engine::error::JsNativeError::typ().with_message(e),
84                        )
85                    })?;
86                    let hits = super::query::run_query_structured(&params, &root).map_err(|e| {
87                        boa_engine::JsError::from(
88                            boa_engine::error::JsNativeError::typ().with_message(e),
89                        )
90                    })?;
91                    hits_to_js_array(&hits, js_ctx).map_err(|e| {
92                        boa_engine::JsError::from(
93                            boa_engine::error::JsNativeError::typ().with_message(e),
94                        )
95                    })
96                })
97            };
98            let outl_obj = boa_engine::object::ObjectInitializer::new(&mut context)
99                .function(query_fn, js_string!("query"), 1)
100                .build();
101            context
102                .register_global_property(
103                    js_string!("outl"),
104                    JsValue::from(outl_obj),
105                    boa_engine::property::Attribute::all(),
106                )
107                .map_err(|e| ExecError::Sandbox(format!("register outl: {e}")))?;
108        }
109        // Run the shim that wires console.log → __outl_log. Errors
110        // here would mean a broken Boa install, so just panic-via-?.
111        let _ = context
112            .eval(Source::from_bytes(CONSOLE_SHIM))
113            .map_err(|e| ExecError::Sandbox(format!("console shim: {e}")))?;
114
115        // Don't carry the shim's `undefined` over as the auto-print
116        // value — only the user script's last expression matters.
117        let value = match context.eval(Source::from_bytes(source)) {
118            Ok(v) => v,
119            Err(e) => {
120                return Ok(ExecOutput {
121                    stdout: sink.borrow().clone(),
122                    stderr: e.to_string(),
123                    duration: start.elapsed(),
124                    exit: ExitStatus::Trap("js-error".into()),
125                    format: OutputFormat::Text,
126                });
127            }
128        };
129
130        let mut stdout = sink.borrow().clone();
131        if stdout.is_empty() && !value.is_undefined() {
132            let s = value
133                .to_string(&mut context)
134                .map(|s| s.to_std_string_escaped())
135                .unwrap_or_else(|_| format!("{value:?}"));
136            stdout.push_str(&s);
137        }
138        Ok(ExecOutput {
139            stdout,
140            stderr: String::new(),
141            duration: start.elapsed(),
142            exit: ExitStatus::Ok,
143            format: OutputFormat::Text,
144        })
145    }
146}
147
148/// Convert a JS value (expected: plain object) into [`QueryParams`].
149#[cfg(feature = "lang-query")]
150fn js_value_to_query_params(
151    val: &JsValue,
152    ctx: &mut Context,
153) -> Result<super::query::QueryParams, String> {
154    let obj = val.as_object().ok_or("outl.query expects an object")?;
155    let mut params = super::query::QueryParams::default();
156    if let Some(v) = obj
157        .get(js_string!("status"), ctx)
158        .map_err(|e| e.to_string())?
159        .as_string()
160    {
161        params.status = Some(v.to_std_string_escaped());
162    }
163    if let Some(v) = obj
164        .get(js_string!("tag"), ctx)
165        .map_err(|e| e.to_string())?
166        .as_string()
167    {
168        params.tag = Some(v.to_std_string_escaped());
169    }
170    if let Some(v) = obj
171        .get(js_string!("kind"), ctx)
172        .map_err(|e| e.to_string())?
173        .as_string()
174    {
175        params.kind = Some(v.to_std_string_escaped());
176    }
177    if let Some(v) = obj
178        .get(js_string!("since"), ctx)
179        .map_err(|e| e.to_string())?
180        .as_string()
181    {
182        params.since = Some(v.to_std_string_escaped());
183    }
184    if let Some(v) = obj
185        .get(js_string!("text"), ctx)
186        .map_err(|e| e.to_string())?
187        .as_string()
188    {
189        params.text = Some(v.to_std_string_escaped());
190    }
191    if let Some(v) = obj
192        .get(js_string!("limit"), ctx)
193        .map_err(|e| e.to_string())?
194        .as_number()
195    {
196        if v.is_finite() && v >= 0.0 {
197            params.limit = Some(v as usize);
198        }
199    }
200    let sort_val = obj
201        .get(js_string!("sort"), ctx)
202        .map_err(|e| e.to_string())?;
203    if let Some(s) = sort_val.as_string() {
204        let raw = s.to_std_string_escaped();
205        for part in raw.split(',') {
206            let trimmed = part.trim();
207            if !trimmed.is_empty() {
208                params.sort.push(trimmed.to_string());
209            }
210        }
211    }
212    Ok(params)
213}
214
215/// Convert query hits into a JS array of objects.
216#[cfg(feature = "lang-query")]
217fn hits_to_js_array(hits: &[super::query::QueryHit], ctx: &mut Context) -> Result<JsValue, String> {
218    let arr = boa_engine::object::ObjectInitializer::new(ctx).build();
219    for (i, hit) in hits.iter().enumerate() {
220        let obj = boa_engine::object::ObjectInitializer::new(ctx)
221            .property(
222                js_string!("handle"),
223                js_string!(hit.handle.as_str()),
224                boa_engine::property::Attribute::all(),
225            )
226            .property(
227                js_string!("text"),
228                js_string!(hit.text.as_str()),
229                boa_engine::property::Attribute::all(),
230            )
231            .property(
232                js_string!("page"),
233                js_string!(hit.page.as_str()),
234                boa_engine::property::Attribute::all(),
235            )
236            .property(
237                js_string!("status"),
238                match hit.status.as_deref() {
239                    Some(s) => js_string!(s).into(),
240                    None => JsValue::null(),
241                },
242                boa_engine::property::Attribute::all(),
243            )
244            .build();
245        arr.set(i, obj, true, ctx).map_err(|e| e.to_string())?;
246    }
247    Ok(arr.into())
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    fn run(src: &str) -> String {
255        JsRuntime
256            .execute(src, &ExecContext::default())
257            .unwrap()
258            .stdout
259    }
260
261    #[test]
262    fn arithmetic_last_value_auto_printed() {
263        assert_eq!(run("1 + 2"), "3");
264    }
265
266    #[test]
267    fn console_log_writes_stdout() {
268        assert_eq!(run("console.log('hello')"), "hello\n");
269    }
270
271    #[test]
272    fn template_literals() {
273        assert_eq!(run("`x=${2+3}`"), "x=5");
274    }
275
276    #[test]
277    fn arrow_fn_and_map() {
278        assert_eq!(run("[1,2,3].map(n => n * n).join(',')"), "1,4,9");
279    }
280
281    #[test]
282    fn parse_error_returns_trap() {
283        let out = JsRuntime
284            .execute("function (", &ExecContext::default())
285            .unwrap();
286        assert!(matches!(out.exit, ExitStatus::Trap(_)));
287        assert!(!out.stderr.is_empty());
288    }
289}