Skip to main content

mittens_engine/scripting/
runner.rs

1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3
4use crate::engine::ecs::{ComponentId, IntentValue, RxWorld, SignalEmitter, World};
5use crate::engine::graphics::render_assets::RenderAssets;
6use crate::scripting::object::{HeapHandle, MaterializedCE, Value};
7use crate::scripting::world_evaluator::{
8    EvalRequest, EvalResponse, HostCallKind, HostValue, MeowMeowEvaluator, eval_mms_fn,
9    eval_module_source,
10};
11
12/// The result of evaluating an MMS script: collected intents and any errors.
13#[derive(Debug, Default)]
14pub struct EvalOutput {
15    pub intents: Vec<IntentValue>,
16    pub errors: Vec<String>,
17}
18
19#[derive(Debug, Clone)]
20pub struct LoadedMmsModule {
21    pub named_exports: HashMap<String, Value>,
22    pub sequence: Vec<MaterializedCE>,
23    pub heap: HeapHandle,
24    pub source_path: Option<String>,
25}
26
27impl LoadedMmsModule {
28    pub fn named_export(&self, name: &str) -> Option<&Value> {
29        self.named_exports.get(name)
30    }
31}
32
33/// Synchronous wrapper around [`MeowMeowEvaluator`].
34///
35/// Spawns an evaluator thread, sends a script, drains all responses to
36/// completion, and returns the collected [`EvalOutput`]. The thread is shut
37/// down and joined before returning.
38pub struct MeowMeowRunner;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum ModuleFactoryEvalMode {
42    Template,
43    Live,
44}
45
46fn headers_to_value(headers: &[(String, String)]) -> Value {
47    Value::Map(
48        headers
49            .iter()
50            .map(|(name, value)| (name.clone(), Value::String(value.clone())))
51            .collect(),
52    )
53}
54
55pub(crate) fn event_arg_value(signal: &crate::engine::ecs::Signal) -> Value {
56    match signal.event.as_ref() {
57        Some(crate::engine::ecs::EventSignal::DataEvent { name, .. }) => {
58            Value::String(name.clone())
59        }
60        Some(crate::engine::ecs::EventSignal::XrButtonDown {
61            hand,
62            control,
63            value,
64            ..
65        })
66        | Some(crate::engine::ecs::EventSignal::XrButtonUp {
67            hand,
68            control,
69            value,
70            ..
71        })
72        | Some(crate::engine::ecs::EventSignal::XrButtonChanged {
73            hand,
74            control,
75            value,
76            ..
77        }) => Value::Map(HashMap::from([
78            ("hand".to_string(), Value::String(format!("{hand:?}"))),
79            ("control".to_string(), Value::String(format!("{control:?}"))),
80            ("value".to_string(), Value::Number(*value as f64)),
81        ])),
82        Some(crate::engine::ecs::EventSignal::XrAxisChanged {
83            hand,
84            control,
85            value,
86            ..
87        }) => Value::Map(HashMap::from([
88            ("hand".to_string(), Value::String(format!("{hand:?}"))),
89            ("control".to_string(), Value::String(format!("{control:?}"))),
90            (
91                "value".to_string(),
92                Value::Array(vec![
93                    Value::Number(value[0] as f64),
94                    Value::Number(value[1] as f64),
95                ]),
96            ),
97        ])),
98        Some(crate::engine::ecs::EventSignal::TextInputChanged { text, caret, .. }) => {
99            Value::Map(HashMap::from([
100                ("text".to_string(), Value::String(text.clone())),
101                ("caret".to_string(), Value::Number(*caret as f64)),
102            ]))
103        }
104        Some(crate::engine::ecs::EventSignal::HttpRequest {
105            request_id,
106            method,
107            path,
108            query,
109            url,
110            headers,
111            body_text,
112            remote_addr,
113        }) => Value::Map(HashMap::from([
114            ("request_id".to_string(), Value::Number(*request_id as f64)),
115            ("method".to_string(), Value::String(method.clone())),
116            ("path".to_string(), Value::String(path.clone())),
117            (
118                "query".to_string(),
119                query
120                    .as_ref()
121                    .map(|query| Value::String(query.clone()))
122                    .unwrap_or(Value::Null),
123            ),
124            ("url".to_string(), Value::String(url.clone())),
125            ("target".to_string(), Value::String(url.clone())),
126            ("headers".to_string(), headers_to_value(headers)),
127            ("body_text".to_string(), Value::String(body_text.clone())),
128            (
129                "remote_addr".to_string(),
130                remote_addr
131                    .as_ref()
132                    .map(|addr| Value::String(addr.clone()))
133                    .unwrap_or(Value::Null),
134            ),
135        ])),
136        Some(crate::engine::ecs::EventSignal::HttpResponse {
137            request_id,
138            status,
139            ok,
140            headers,
141            body_text,
142            url,
143        }) => Value::Map(HashMap::from([
144            ("request_id".to_string(), Value::Number(*request_id as f64)),
145            ("status".to_string(), Value::Number(*status as f64)),
146            ("ok".to_string(), Value::Bool(*ok)),
147            ("headers".to_string(), headers_to_value(headers)),
148            ("body_text".to_string(), Value::String(body_text.clone())),
149            ("url".to_string(), Value::String(url.clone())),
150        ])),
151        Some(crate::engine::ecs::EventSignal::HttpError {
152            request_id,
153            phase,
154            message,
155            url,
156            bind_addr,
157        }) => Value::Map(HashMap::from([
158            (
159                "request_id".to_string(),
160                request_id
161                    .map(|request_id| Value::Number(request_id as f64))
162                    .unwrap_or(Value::Null),
163            ),
164            ("phase".to_string(), Value::String(phase.clone())),
165            ("message".to_string(), Value::String(message.clone())),
166            (
167                "url".to_string(),
168                url.as_ref()
169                    .map(|url| Value::String(url.clone()))
170                    .unwrap_or(Value::Null),
171            ),
172            (
173                "bind_addr".to_string(),
174                bind_addr
175                    .as_ref()
176                    .map(|bind_addr| Value::String(bind_addr.clone()))
177                    .unwrap_or(Value::Null),
178            ),
179        ])),
180        _ => Value::Null,
181    }
182}
183
184impl MeowMeowRunner {
185    /// Evaluate `source` without a live ECS world, collecting emitted intents
186    /// and errors.
187    ///
188    /// This mode cannot allocate live `ComponentId`s during evaluation, so
189    /// let-bound component expressions stay as `ComponentExpr` values rather
190    /// than becoming live `ComponentObject` handles.
191    ///
192    /// Times out after 2 seconds if the evaluator stalls.
193    pub fn eval(source: &str) -> EvalOutput {
194        Self::eval_impl(source, None, Duration::from_secs(2))
195    }
196
197    /// Evaluate `source` with a caller-provided timeout.
198    pub fn eval_with_timeout(source: &str, timeout: Duration) -> EvalOutput {
199        Self::eval_impl(source, None, timeout)
200    }
201
202    /// Evaluate `source` knowing it came from `path` (enables relative imports).
203    pub fn eval_with_path(source: &str, path: &str) -> EvalOutput {
204        Self::eval_impl(source, Some(path), Duration::from_secs(2))
205    }
206
207    /// Read `path` from disk and evaluate it (enables relative imports).
208    pub fn eval_file(path: &str) -> EvalOutput {
209        Self::eval_file_with_timeout(path, Duration::from_secs(2))
210    }
211
212    /// Read `path` from disk and evaluate it (enables relative imports) with a caller-provided timeout.
213    pub fn eval_file_with_timeout(path: &str, timeout: Duration) -> EvalOutput {
214        match std::fs::read_to_string(path) {
215            Ok(source) => Self::eval_impl(&source, Some(path), timeout),
216            Err(e) => {
217                let mut output = EvalOutput::default();
218                output
219                    .errors
220                    .push(format!("cannot read file '{}': {}", path, e));
221                output
222            }
223        }
224    }
225
226    pub fn load_module_source(
227        source: &str,
228        source_path: Option<&str>,
229    ) -> Result<LoadedMmsModule, String> {
230        let module = match eval_module_source(source, source_path)? {
231            Value::Module {
232                named,
233                sequence,
234                heap,
235            } => Ok(LoadedMmsModule {
236                named_exports: named,
237                sequence,
238                heap,
239                source_path: source_path.map(|s| s.to_string()),
240            }),
241            other => Err(format!(
242                "load_module_source: expected module result, got {:?}",
243                other
244            )),
245        }?;
246        Ok(module)
247    }
248
249    pub fn load_module_file(path: &str) -> Result<LoadedMmsModule, String> {
250        let source = std::fs::read_to_string(path)
251            .map_err(|e| format!("cannot read module '{}': {}", path, e))?;
252        Self::load_module_source(&source, Some(path))
253    }
254
255    pub fn call_mms_module_fn(
256        module: &LoadedMmsModule,
257        name: &str,
258        args: Vec<Value>,
259        channels: Option<&mut crate::scripting::world_evaluator::EvalChannels>,
260        world_host: Option<&mut World>,
261        emit: Option<&mut dyn SignalEmitter>,
262    ) -> Result<Value, String> {
263        let Some(export) = module.named_export(name) else {
264            return Err(format!("call_mms_module_fn: export '{}' not found", name));
265        };
266        if !matches!(export, Value::Function { .. }) {
267            return Err(format!(
268                "call_mms_module_fn: export '{}' is not a function",
269                name
270            ));
271        }
272        eval_mms_fn(export, args, channels, world_host, emit)
273    }
274
275    pub fn materialize_mms_module_component(
276        module: &LoadedMmsModule,
277        name: &str,
278        args: Vec<Value>,
279        world_host: Option<&mut World>,
280        emit: Option<&mut dyn SignalEmitter>,
281    ) -> Result<MaterializedCE, String> {
282        Self::materialize_mms_module_component_in_mode(
283            module,
284            name,
285            args,
286            world_host,
287            emit,
288            ModuleFactoryEvalMode::Template,
289        )
290    }
291
292    pub fn materialize_mms_module_component_in_mode(
293        module: &LoadedMmsModule,
294        name: &str,
295        args: Vec<Value>,
296        world_host: Option<&mut World>,
297        emit: Option<&mut dyn SignalEmitter>,
298        mode: ModuleFactoryEvalMode,
299    ) -> Result<MaterializedCE, String> {
300        match mode {
301            ModuleFactoryEvalMode::Template => {}
302            ModuleFactoryEvalMode::Live => {
303                return Err(
304                    "materialize_mms_module_component_in_mode: live mode does not return a stable MaterializedCE; use a spawn/instantiate helper instead".to_string()
305                )
306            }
307        }
308        let _ = world_host;
309        let _ = emit;
310        let value = Self::call_mms_module_fn(module, name, args, None, None, None)?;
311        let Value::ComponentExpr(component_expr) = value else {
312            return Err(format!(
313                "materialize_mms_module_component: export '{}' did not return a component tree",
314                name
315            ));
316        };
317        Ok(*component_expr)
318    }
319
320    pub fn materialize_mms_module_component_from_file(
321        path: &str,
322        name: &str,
323        args: Vec<Value>,
324        world_host: Option<&mut World>,
325        emit: Option<&mut dyn SignalEmitter>,
326    ) -> Result<MaterializedCE, String> {
327        let module = Self::load_module_file(path)?;
328        Self::materialize_mms_module_component(&module, name, args, world_host, emit)
329    }
330
331    pub fn spawn_mms_module_component_uninitialized(
332        module: &LoadedMmsModule,
333        name: &str,
334        args: Vec<Value>,
335        world: &mut World,
336        emit: &mut dyn SignalEmitter,
337    ) -> Result<ComponentId, String> {
338        Self::spawn_mms_module_component_uninitialized_with_assets(
339            module, name, args, world, None, emit,
340        )
341    }
342
343    pub fn spawn_mms_module_component_uninitialized_with_assets(
344        module: &LoadedMmsModule,
345        name: &str,
346        args: Vec<Value>,
347        world: &mut World,
348        render_assets: Option<&mut RenderAssets>,
349        emit: &mut dyn SignalEmitter,
350    ) -> Result<ComponentId, String> {
351        Self::spawn_mms_module_component_value(
352            module,
353            name,
354            args,
355            None,
356            world,
357            render_assets,
358            emit,
359            false,
360        )
361    }
362
363    pub fn spawn_mms_module_component_uninitialized_from_file(
364        path: &str,
365        name: &str,
366        args: Vec<Value>,
367        world: &mut World,
368        emit: &mut dyn SignalEmitter,
369    ) -> Result<ComponentId, String> {
370        let module = Self::load_module_file(path)?;
371        Self::spawn_mms_module_component_uninitialized(&module, name, args, world, emit)
372    }
373
374    pub fn spawn_mms_module_component(
375        module: &LoadedMmsModule,
376        name: &str,
377        args: Vec<Value>,
378        parent: Option<ComponentId>,
379        world: &mut World,
380        emit: &mut dyn SignalEmitter,
381    ) -> Result<ComponentId, String> {
382        Self::spawn_mms_module_component_value(module, name, args, parent, world, None, emit, true)
383    }
384
385    pub fn spawn_mms_module_component_from_file(
386        path: &str,
387        name: &str,
388        args: Vec<Value>,
389        parent: Option<ComponentId>,
390        world: &mut World,
391        emit: &mut dyn SignalEmitter,
392    ) -> Result<ComponentId, String> {
393        let module = Self::load_module_file(path)?;
394        Self::spawn_mms_module_component(&module, name, args, parent, world, emit)
395    }
396
397    fn spawn_mms_module_component_value(
398        module: &LoadedMmsModule,
399        name: &str,
400        args: Vec<Value>,
401        parent: Option<ComponentId>,
402        world: &mut World,
403        mut render_assets: Option<&mut RenderAssets>,
404        emit: &mut dyn SignalEmitter,
405        initialize: bool,
406    ) -> Result<ComponentId, String> {
407        let value = Self::eval_mms_module_component_live(
408            module,
409            name,
410            args,
411            world,
412            render_assets.as_deref_mut(),
413            emit,
414        )?;
415        match value {
416            Value::ComponentObject { id, .. } => {
417                if let Some(p) = parent {
418                    world
419                        .add_child(p, id)
420                        .map_err(|e| format!("attach live module component failed: {e}"))?;
421                }
422                if initialize {
423                    let should_init = parent.map(|p| world.is_initialized(p)).unwrap_or(true);
424                    if should_init {
425                        world.init_component_tree(id, emit);
426                    }
427                }
428                Ok(id)
429            }
430            Value::ComponentExpr(component_expr) => {
431                if let Some(render_assets) = render_assets.as_deref_mut() {
432                    crate::scripting::component_registry::with_live_render_assets(
433                        render_assets,
434                        || {
435                            if initialize {
436                                crate::scripting::component_registry::spawn_tree(
437                                    &component_expr,
438                                    parent,
439                                    world,
440                                    emit,
441                                )
442                            } else {
443                                crate::scripting::component_registry::spawn_tree_uninitialized(
444                                    &component_expr,
445                                    world,
446                                    emit,
447                                )
448                            }
449                        },
450                    )
451                } else if initialize {
452                    crate::scripting::component_registry::spawn_tree(
453                        &component_expr,
454                        parent,
455                        world,
456                        emit,
457                    )
458                } else {
459                    crate::scripting::component_registry::spawn_tree_uninitialized(
460                        &component_expr,
461                        world,
462                        emit,
463                    )
464                }
465            }
466            other => Err(format!(
467                "spawn_mms_module_component: export '{}' did not return a component tree, got {:?}",
468                name, other
469            )),
470        }
471    }
472
473    fn eval_mms_module_component_live(
474        module: &LoadedMmsModule,
475        name: &str,
476        args: Vec<Value>,
477        world: &mut World,
478        render_assets: Option<&mut RenderAssets>,
479        emit: &mut dyn SignalEmitter,
480    ) -> Result<Value, String> {
481        if let Some(render_assets) = render_assets {
482            crate::scripting::component_registry::with_live_render_assets(render_assets, || {
483                Self::call_mms_module_fn(module, name, args, None, Some(world), Some(emit))
484            })
485        } else {
486            Self::call_mms_module_fn(module, name, args, None, Some(world), Some(emit))
487        }
488    }
489
490    /// Evaluate `source` with live world access.
491    ///
492    /// Handles two HostCall kinds during evaluation:
493    /// - `Spawn`: spawns the component tree into `world` and returns the root `ComponentId`.
494    ///   `let x = T {}` binds a `ComponentObject(id)` instead of a dead `ComponentExpr`.
495    /// - `RegisterHandler`: installs an MMS function as a scoped signal handler in `rx`.
496    ///   `on(obj, "Click", fn(e) { ... })` registers without blocking the evaluator.
497    pub fn eval_with_world(
498        source: &str,
499        world: &mut World,
500        rx: &mut RxWorld,
501        emit: &mut dyn SignalEmitter,
502    ) -> EvalOutput {
503        Self::eval_with_world_at_path(source, None, world, rx, emit)
504    }
505
506    /// Like `eval_with_world`, but also records the source file path so
507    /// `import` statements resolve relative to it.
508    pub fn eval_with_world_at_path(
509        source: &str,
510        source_path: Option<&str>,
511        world: &mut World,
512        rx: &mut RxWorld,
513        emit: &mut dyn SignalEmitter,
514    ) -> EvalOutput {
515        Self::eval_with_world_and_assets_at_path(source, source_path, world, rx, None, emit)
516    }
517
518    /// Evaluate `source` with live world + render-asset access.
519    pub fn eval_with_world_and_assets(
520        source: &str,
521        world: &mut World,
522        rx: &mut RxWorld,
523        render_assets: &mut RenderAssets,
524        emit: &mut dyn SignalEmitter,
525    ) -> EvalOutput {
526        Self::eval_with_world_and_assets_at_path(source, None, world, rx, Some(render_assets), emit)
527    }
528
529    /// Like `eval_with_world_and_assets`, but also records the source file path so
530    /// `import` statements resolve relative to it.
531    pub fn eval_with_world_and_assets_at_path(
532        source: &str,
533        source_path: Option<&str>,
534        world: &mut World,
535        rx: &mut RxWorld,
536        mut render_assets: Option<&mut RenderAssets>,
537        emit: &mut dyn SignalEmitter,
538    ) -> EvalOutput {
539        let mut handle = MeowMeowEvaluator::spawn(64);
540        handle
541            .requests
542            .push(EvalRequest::EvalScript {
543                source: source.to_string(),
544                source_path: source_path.map(|s| s.to_string()),
545            })
546            .expect("MeowMeowRunner: push EvalScript");
547        handle
548            .requests
549            .push(EvalRequest::Shutdown)
550            .expect("MeowMeowRunner: push Shutdown");
551
552        let mut output = EvalOutput::default();
553        let deadline = Instant::now() + Duration::from_secs(5);
554
555        loop {
556            match handle.responses.pop() {
557                Ok(EvalResponse::Intent(iv)) => output.intents.push(iv),
558                Ok(EvalResponse::Error { message }) => output.errors.push(message),
559                Ok(EvalResponse::ParsedOk { .. }) => {}
560                Ok(EvalResponse::SnippetComplete { .. }) => {}
561                Ok(EvalResponse::NavigationComplete { .. } | EvalResponse::ReplReset) => {}
562                Ok(EvalResponse::ShutdownAck) => break,
563                Ok(EvalResponse::HostCall { id, kind }) => {
564                    let reply = match kind {
565                        HostCallKind::Spawn(ce) => {
566                            let result = if let Some(render_assets) = render_assets.as_deref_mut() {
567                                crate::scripting::component_registry::with_live_render_assets(
568                                    render_assets,
569                                    || {
570                                        crate::scripting::component_registry::spawn_tree(
571                                            &ce, None, world, emit,
572                                        )
573                                    },
574                                )
575                            } else {
576                                crate::scripting::component_registry::spawn_tree(
577                                    &ce, None, world, emit,
578                                )
579                            };
580                            match result {
581                                Ok(component_id) => HostValue::ComponentId(component_id),
582                                Err(e) => {
583                                    output.errors.push(format!("HostCall::Spawn error: {e}"));
584                                    HostValue::Null
585                                }
586                            }
587                        }
588                        HostCallKind::Register(ce) => {
589                            let result = if let Some(render_assets) = render_assets.as_deref_mut() {
590                                crate::scripting::component_registry::with_live_render_assets(
591                                    render_assets,
592                                    || {
593                                        crate::scripting::component_registry::spawn_tree_uninitialized(
594                                            &ce, world, emit,
595                                        )
596                                    },
597                                )
598                            } else {
599                                crate::scripting::component_registry::spawn_tree_uninitialized(
600                                    &ce, world, emit,
601                                )
602                            };
603                            match result {
604                                Ok(component_id) => HostValue::ComponentId(component_id),
605                                Err(e) => {
606                                    output.errors.push(format!("HostCall::Register error: {e}"));
607                                    HostValue::Null
608                                }
609                            }
610                        }
611                        HostCallKind::Attach { parent, child } => {
612                            if let Some(p) = parent {
613                                if let Err(e) = world.add_child(p, child) {
614                                    output.errors.push(format!("HostCall::Attach error: {e}"));
615                                }
616                            }
617                            // Run the deferred init walk on the (now-attached, or root) subtree.
618                            world.init_component_tree(child, emit);
619                            HostValue::Null
620                        }
621                        HostCallKind::Query {
622                            selector,
623                            scope,
624                            multiple,
625                        } => {
626                            let roots: Vec<crate::engine::ecs::ComponentId> = match scope {
627                                Some(id) => vec![id],
628                                None => world
629                                    .all_components()
630                                    .filter(|&id| world.parent_of(id).is_none())
631                                    .collect(),
632                            };
633                            let mut all_ids: Vec<crate::engine::ecs::ComponentId> = Vec::new();
634                            for r in roots {
635                                if multiple {
636                                    all_ids.extend(world.find_all_components(r, &selector));
637                                } else if let Some(found) = world.find_component(r, &selector) {
638                                    all_ids.push(found);
639                                    break;
640                                }
641                            }
642                            if multiple {
643                                let list = all_ids
644                                    .into_iter()
645                                    .filter_map(|id| {
646                                        world.component_name(id).map(|t| (id, t.to_string()))
647                                    })
648                                    .collect();
649                                HostValue::ComponentList(list)
650                            } else {
651                                match all_ids.into_iter().next() {
652                                    Some(id) => match world.component_name(id) {
653                                        Some(t) => HostValue::Component {
654                                            id,
655                                            component_type: t.to_string(),
656                                        },
657                                        None => HostValue::Null,
658                                    },
659                                    None => HostValue::Null,
660                                }
661                            }
662                        }
663                        HostCallKind::RegisterHandler {
664                            scope,
665                            signal_kind,
666                            name,
667                            handler,
668                        } => {
669                            let callback =
670                                move |world: &mut World,
671                                      emit: &mut dyn SignalEmitter,
672                                      signal: &crate::engine::ecs::Signal| {
673                                    let arg = event_arg_value(signal);
674                                    if let Err(e) = eval_mms_fn(
675                                        &handler,
676                                        vec![arg],
677                                        None,
678                                        Some(world),
679                                        Some(emit),
680                                    ) {
681                                        eprintln!("[mms] handler error: {e}");
682                                    }
683                                };
684                            if let Some(name) = name {
685                                rx.add_handler_closure_named(
686                                    signal_kind,
687                                    scope,
688                                    Some(name),
689                                    callback,
690                                );
691                            } else {
692                                rx.add_handler_closure(signal_kind, scope, callback);
693                            }
694                            HostValue::Null
695                        }
696                        HostCallKind::AudioClipInstance {
697                            source,
698                            start_beat,
699                            stop_beat,
700                        } => {
701                            use crate::engine::ecs::component::AudioClipComponent;
702                            match world.get_component_by_id_as::<AudioClipComponent>(source) {
703                                Some(src) => {
704                                    let mut c = AudioClipComponent::instance_of(src);
705                                    if let Some(sb) = start_beat {
706                                        c.start_beat = sb;
707                                    }
708                                    if let Some(eb) = stop_beat {
709                                        c.stop_beat = Some(eb);
710                                    }
711                                    let id = world.add_component(c);
712                                    HostValue::ComponentId(id)
713                                }
714                                None => {
715                                    output.errors.push(
716                                        "HostCall::AudioClipInstance: source is not an AudioClip"
717                                            .to_string(),
718                                    );
719                                    HostValue::Null
720                                }
721                            }
722                        }
723                        HostCallKind::InvokeComponentMethod {
724                            id,
725                            component_type,
726                            method,
727                            args,
728                        } => match crate::scripting::component_method_registry::invoke_component_method(
729                            world,
730                            id,
731                            &component_type,
732                            &method,
733                            &args,
734                            |intent| output.intents.push(intent),
735                        ) {
736                            Ok(value) => match value {
737                                Value::Null => HostValue::Null,
738                                Value::ComponentObject { id, component_type } => {
739                                    HostValue::Component { id, component_type }
740                                }
741                                other => {
742                                    output.errors.push(format!(
743                                        "HostCall::InvokeComponentMethod returned unsupported value: {:?}",
744                                        other
745                                    ));
746                                    HostValue::Null
747                                }
748                            },
749                            Err(e) => {
750                                output
751                                    .errors
752                                    .push(format!("HostCall::InvokeComponentMethod error: {e}"));
753                                HostValue::Null
754                            }
755                        },
756                        HostCallKind::ReplTree { .. }
757                        | HostCallKind::ReplDump { .. }
758                        | HostCallKind::ReplHelp
759                        | HostCallKind::ReplClear => HostValue::Null,
760                    };
761                    let _ = handle
762                        .requests
763                        .push(EvalRequest::HostCallResult { id, value: reply });
764                }
765                Err(rtrb::PopError::Empty) => {
766                    if Instant::now() > deadline {
767                        output
768                            .errors
769                            .push("MeowMeowRunner: timed out waiting for evaluator".into());
770                        break;
771                    }
772                    std::thread::yield_now();
773                }
774            }
775        }
776
777        handle.shutdown_and_join();
778        output
779    }
780
781    fn eval_impl(source: &str, source_path: Option<&str>, timeout: Duration) -> EvalOutput {
782        let mut handle = MeowMeowEvaluator::spawn(64);
783
784        handle
785            .requests
786            .push(EvalRequest::EvalScript {
787                source: source.to_string(),
788                source_path: source_path.map(|s| s.to_string()),
789            })
790            .expect("MeowMeowRunner: push EvalScript");
791        handle
792            .requests
793            .push(EvalRequest::Shutdown)
794            .expect("MeowMeowRunner: push Shutdown");
795
796        let mut output = EvalOutput::default();
797        let deadline = Instant::now() + timeout;
798
799        loop {
800            match handle.responses.pop() {
801                Ok(EvalResponse::Intent(iv)) => output.intents.push(iv),
802                Ok(EvalResponse::Error { message }) => output.errors.push(message),
803                Ok(EvalResponse::ParsedOk { .. }) => {}
804                Ok(EvalResponse::SnippetComplete { .. }) => {}
805                Ok(EvalResponse::NavigationComplete { .. } | EvalResponse::ReplReset) => {}
806                Ok(EvalResponse::ShutdownAck) => break,
807                // Fire-and-forget runner has no world — reply null so the evaluator
808                // falls back to ComponentExpr and continues without blocking.
809                Ok(EvalResponse::HostCall { id, .. }) => {
810                    let _ = handle.requests.push(EvalRequest::HostCallResult {
811                        id,
812                        value: HostValue::Null,
813                    });
814                }
815                Err(rtrb::PopError::Empty) => {
816                    if Instant::now() > deadline {
817                        output
818                            .errors
819                            .push("MeowMeowRunner: timed out waiting for evaluator".into());
820                        break;
821                    }
822                    std::thread::yield_now();
823                }
824            }
825        }
826
827        handle.shutdown_and_join();
828        output
829    }
830}