1use 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#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct MacroArg {
21 pub name: String,
23 pub value: String,
25}
26
27impl MacroArg {
28 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#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct MacroResult {
41 pub text: String,
44 pub console_output: Vec<String>,
47}
48
49#[derive(Debug)]
56pub struct MacroRuntime {
57 limits: Limits,
58 helpers: Helpers,
59}
60
61impl MacroRuntime {
62 pub fn new(limits: Limits) -> Self {
65 Self {
66 limits,
67 helpers: Helpers::new(),
68 }
69 }
70
71 pub fn set_helpers(&mut self, helpers: Helpers) {
74 self.helpers = helpers;
75 }
76
77 pub fn limits(&self) -> &Limits {
79 &self.limits
80 }
81
82 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 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 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 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
174const BUILTIN_OBJECTS: [&str; 6] = ["Map", "Hero", "Gamemode", "Color", "Team", "Button"];
176
177const BUILTINS_FILENAME: &str = "<opy-macro-js-builtins>";
180
181const 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
194fn 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
215fn 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
230fn 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
251fn 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
274fn 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
298fn adjusted_line(line: u32, line_adjust: usize) -> u32 {
301 line.saturating_sub(line_adjust as u32).max(1)
302}