Skip to main content

opy_macro_js/
runtime.rs

1//! The reusable macro/hook runtime: script assembly, invocation, and
2//! result/error mapping.
3
4use std::time::Instant;
5
6use crate::engine::quickjs_ng::QuickJsEngine;
7use crate::engine::{Completion, EngineError, JsEngine};
8use crate::error::{MacroError, ScriptError};
9use crate::helpers::Helpers;
10use crate::limits::Limits;
11
12/// One macro argument: the declared parameter name and the **raw** call-site
13/// argument text.
14///
15/// The value is injected verbatim as `var <name>=<value>;` ahead of the script
16/// source, exactly like the OverPy reference (`resolveMacro` in
17/// `src/compiler/tokenizer.ts`). Argument-count validation against the macro
18/// declaration belongs to the frontend, which knows the declared parameters.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct MacroArg {
21    /// Parameter name as declared in the macro.
22    pub name: String,
23    /// Raw textual argument from the call site.
24    pub value: String,
25}
26
27impl MacroArg {
28    /// Creates a macro argument from the declared parameter name and the raw
29    /// call-site text.
30    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
31        Self {
32            name: name.into(),
33            value: value.into(),
34        }
35    }
36}
37
38/// The outcome of a successful macro or hook invocation.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct MacroResult {
41    /// The script's string completion value: expanded text for macros, the
42    /// transformed content for hooks.
43    pub text: String,
44    /// Lines written via `console.log(...)`, in order, one entry per call
45    /// (arguments rendered with `String()` semantics and joined on `" "`).
46    pub console_output: Vec<String>,
47}
48
49/// Reusable runtime for JavaScript-backed macros and post-compile hooks.
50///
51/// Each invocation spins up a fresh embedded engine instance (runtime +
52/// context) with the configured [`Limits`] and helper surface, evaluates the
53/// script, and tears the engine down. The runtime itself is reusable, but no
54/// JavaScript state is shared across invocations.
55#[derive(Debug)]
56pub struct MacroRuntime {
57    limits: Limits,
58    helpers: Helpers,
59}
60
61impl MacroRuntime {
62    /// Creates a runtime with the given resource limits and an empty helper
63    /// set.
64    pub fn new(limits: Limits) -> Self {
65        Self {
66            limits,
67            helpers: Helpers::new(),
68        }
69    }
70
71    /// Replaces the helper surface (constant objects) used by subsequent
72    /// invocations.
73    pub fn set_helpers(&mut self, helpers: Helpers) {
74        self.helpers = helpers;
75    }
76
77    /// The configured resource limits.
78    pub fn limits(&self) -> &Limits {
79        &self.limits
80    }
81
82    /// Executes a macro script.
83    ///
84    /// `source` is the script's text (the file content the frontend resolved
85    /// from `__script__("...")`). `args` are injected as `var` declarations;
86    /// `script_name` is used for error attribution and must be the resolved
87    /// script path/name the frontend knows.
88    ///
89    /// Returns the string completion value as the expanded text, or a
90    /// structured error.
91    pub fn run_macro(
92        &self,
93        source: &str,
94        args: &[MacroArg],
95        script_name: &str,
96    ) -> Result<MacroResult, MacroError> {
97        let mut prologue = String::new();
98        for arg in args {
99            prologue.push_str("var ");
100            prologue.push_str(&arg.name);
101            prologue.push('=');
102            prologue.push_str(&arg.value);
103            prologue.push(';');
104        }
105        prologue.push('\n');
106        self.execute(
107            &prologue,
108            source,
109            script_name,
110            self.limits.macro_time_budget,
111        )
112    }
113
114    /// Executes a post-compile hook script against `content`.
115    ///
116    /// Mirrors the OverPy `#!postCompileHook` ABI (`src/compiler/tokenizer.ts`):
117    /// the script receives the content JSON-escaped as
118    /// `var content = "...";` and must return the transformed content as a
119    /// string. This works against synthetic/content inputs now; wiring to
120    /// actual Workshop emission is lowering-dependent.
121    pub fn run_hook(
122        &self,
123        source: &str,
124        content: &str,
125        script_name: &str,
126    ) -> Result<MacroResult, MacroError> {
127        let prologue = format!("var content = {};\n", json_string_literal(content));
128        self.execute(&prologue, source, script_name, self.limits.hook_time_budget)
129    }
130
131    /// Shared invocation path: builds the full script text, runs it on a fresh
132    /// engine, and maps the outcome.
133    fn execute(
134        &self,
135        prologue: &str,
136        source: &str,
137        script_name: &str,
138        time_budget: std::time::Duration,
139    ) -> Result<MacroResult, MacroError> {
140        // Stack line numbers count the prologue too; the reference subtracts
141        // the prepended block's line count, so we adjust the same way.
142        let line_adjust = prologue.matches('\n').count();
143        let script_text = format!("{prologue}{source}");
144
145        let mut engine =
146            QuickJsEngine::new(&self.limits).map_err(|e| MacroError::Internal(e.to_string()))?;
147        engine
148            .install_console()
149            .map_err(|e| MacroError::Internal(format!("failed to install console: {e}")))?;
150        engine
151            .evaluate(&builtins_source(&self.helpers), BUILTINS_FILENAME)
152            .map_err(|e| {
153                MacroError::Internal(format!("failed to evaluate builtin helpers: {e}"))
154            })?;
155        engine.set_interrupt_deadline(Some(Instant::now() + time_budget));
156        let completion = engine
157            .evaluate(&script_text, script_name)
158            .map_err(|e| map_engine_error(e, script_name, line_adjust))?;
159        let text = match completion {
160            Completion::String(text) => text,
161            Completion::NonString(type_name) => {
162                return Err(MacroError::InvalidResult {
163                    type_name: type_name.to_string(),
164                });
165            }
166        };
167        Ok(MacroResult {
168            text,
169            console_output: engine.console_output().to_vec(),
170        })
171    }
172}
173
174/// The constant objects the reference always defines (`src/globalVars.ts`).
175const BUILTIN_OBJECTS: [&str; 6] = ["Map", "Hero", "Gamemode", "Color", "Team", "Button"];
176
177/// Filename used for the internal helpers script; the helpers are static and
178/// cannot throw, so this never surfaces in errors.
179const BUILTINS_FILENAME: &str = "<opy-macro-js-builtins>";
180
181/// The `vect` helper from the reference's `builtInJsFunctions` block.
182const VECT_HELPER: &str = r#"function vect(x, y, z) {
183    return {
184        x: x,
185        y: y,
186        z: z,
187        toString: function () {
188            return "vect(" + this.x + "," + this.y + "," + this.z + ")";
189        },
190    };
191}
192"#;
193
194/// Builds the helpers script: `vect` plus the six constant objects, always
195/// defined and populated from [`Helpers`] entries.
196fn builtins_source(helpers: &Helpers) -> String {
197    let mut source = String::from(VECT_HELPER);
198    for object in BUILTIN_OBJECTS {
199        source.push_str("var ");
200        source.push_str(object);
201        source.push_str(" = {");
202        for (i, (key, value)) in helpers.entries(object).iter().enumerate() {
203            if i > 0 {
204                source.push(',');
205            }
206            source.push_str(&json_string_literal(key));
207            source.push(':');
208            source.push_str(&json_string_literal(value));
209        }
210        source.push_str("};\n");
211    }
212    source
213}
214
215/// Encodes `value` as a JavaScript string literal.
216///
217/// Uses JSON encoding (equivalent to the reference's `JSON.stringify`),
218/// including the ES2019 escape of U+2028/U+2029 which `JSON.stringify`
219/// produces and `serde_json` does not.
220fn json_string_literal(value: &str) -> String {
221    let mut literal = serde_json::to_string(value).expect("serializing a string is infallible");
222    if literal.contains('\u{2028}') || literal.contains('\u{2029}') {
223        literal = literal
224            .replace('\u{2028}', "\\u2028")
225            .replace('\u{2029}', "\\u2029");
226    }
227    literal
228}
229
230/// Maps an engine failure to a public error, adjusting stack line numbers so
231/// they refer to the user's script text (the injected prologue is subtracted,
232/// mirroring the reference's `normalizeScriptError`).
233fn map_engine_error(error: EngineError, script_name: &str, line_adjust: usize) -> MacroError {
234    match error {
235        EngineError::Exception { message, stack } => {
236            let position = first_frame_position(&stack, script_name, line_adjust);
237            let adjusted_stack =
238                (!stack.is_empty()).then(|| adjust_stack(&stack, script_name, line_adjust));
239            MacroError::Script(ScriptError {
240                message,
241                source_name: Some(script_name.to_string()),
242                line: position.map(|(line, _)| line),
243                column: position.map(|(_, column)| column),
244                stack: adjusted_stack,
245            })
246        }
247        EngineError::Internal(message) => MacroError::Internal(message),
248    }
249}
250
251/// Finds the first stack frame referencing `script_name` and returns its
252/// line/column, with the line adjusted to the user's script.
253fn first_frame_position(stack: &str, script_name: &str, line_adjust: usize) -> Option<(u32, u32)> {
254    let needle = format!("{script_name}:");
255    let pos = stack.find(&needle)?;
256    let after = &stack[pos + needle.len()..];
257    let line_digits = after.bytes().take_while(|b| b.is_ascii_digit()).count();
258    if line_digits == 0 {
259        return None;
260    }
261    let line = after[..line_digits].parse::<u32>().ok()?;
262    let after_line = after[line_digits..].strip_prefix(':')?;
263    let column_digits = after_line
264        .bytes()
265        .take_while(|b| b.is_ascii_digit())
266        .count();
267    if column_digits == 0 {
268        return None;
269    }
270    let column = after_line[..column_digits].parse::<u32>().ok()?;
271    Some((adjusted_line(line, line_adjust), column))
272}
273
274/// Rewrites every `script_name:LINE:COL` occurrence in the stack so line
275/// numbers refer to the user's script text.
276fn adjust_stack(stack: &str, script_name: &str, line_adjust: usize) -> String {
277    let needle = format!("{script_name}:");
278    let mut out = String::with_capacity(stack.len());
279    for line in stack.split_inclusive('\n') {
280        let Some(pos) = line.find(&needle) else {
281            out.push_str(line);
282            continue;
283        };
284        out.push_str(&line[..pos + needle.len()]);
285        let after = &line[pos + needle.len()..];
286        let digits = after.bytes().take_while(|b| b.is_ascii_digit()).count();
287        if digits == 0 {
288            out.push_str(after);
289            continue;
290        }
291        let line_number = after[..digits].parse::<u32>().unwrap_or(1);
292        out.push_str(&adjusted_line(line_number, line_adjust).to_string());
293        out.push_str(&after[digits..]);
294    }
295    out
296}
297
298/// Subtracts the prologue line count, keeping a minimum of 1 (the reference
299/// uses `Math.max(1, line - lineOffset)`).
300fn adjusted_line(line: u32, line_adjust: usize) -> u32 {
301    line.saturating_sub(line_adjust as u32).max(1)
302}