Skip to main content

runmat_runtime/
interaction.rs

1use once_cell::sync::OnceCell;
2use runmat_thread_local::runmat_thread_local;
3use runmat_value::Value;
4use std::cell::RefCell;
5use std::future::Future;
6use std::pin::Pin;
7
8use crate::{build_runtime_error, RuntimeError};
9#[cfg(not(target_arch = "wasm32"))]
10use std::io::IsTerminal;
11#[cfg(not(target_arch = "wasm32"))]
12use std::io::{self, Read, Write};
13#[cfg(all(feature = "interaction-test-hooks", not(target_arch = "wasm32")))]
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::{Arc, RwLock};
16
17pub use runmat_async::InteractionKind;
18
19#[derive(Clone)]
20pub struct InteractionPromptOwned {
21    pub prompt: String,
22    pub kind: InteractionKind,
23}
24
25#[derive(Clone)]
26pub enum InteractionResponse {
27    Line(String),
28    KeyPress,
29}
30
31pub type AsyncInteractionFuture =
32    Pin<Box<dyn Future<Output = Result<InteractionResponse, String>> + 'static>>;
33
34pub type AsyncInteractionHandler =
35    dyn Fn(InteractionPromptOwned) -> AsyncInteractionFuture + Send + Sync;
36
37static ASYNC_HANDLER: OnceCell<RwLock<Option<Arc<AsyncInteractionHandler>>>> = OnceCell::new();
38runmat_thread_local! {
39    static QUEUED_RESPONSE: RefCell<Option<Result<InteractionResponse, String>>> =
40        const { RefCell::new(None) };
41}
42
43#[derive(Default)]
44pub(crate) struct InteractionState {
45    async_handler: Option<Arc<AsyncInteractionHandler>>,
46    queued_response: Option<Result<InteractionResponse, String>>,
47    eval_hook: Option<Arc<EvalHookFn>>,
48}
49
50impl std::fmt::Debug for InteractionState {
51    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        formatter
53            .debug_struct("InteractionState")
54            .field("async_handler", &self.async_handler.is_some())
55            .field("queued_response", &self.queued_response.is_some())
56            .field("eval_hook", &self.eval_hook.is_some())
57            .finish()
58    }
59}
60
61#[cfg(all(feature = "interaction-test-hooks", not(target_arch = "wasm32")))]
62static FORCE_INTERACTIVE_STDIN: AtomicBool = AtomicBool::new(false);
63
64#[cfg(all(feature = "interaction-test-hooks", not(target_arch = "wasm32")))]
65pub fn force_interactive_stdin_for_tests(enable: bool) {
66    FORCE_INTERACTIVE_STDIN.store(enable, Ordering::Relaxed);
67}
68
69#[cfg(all(not(feature = "interaction-test-hooks"), not(target_arch = "wasm32")))]
70#[inline]
71fn force_interactive_stdin() -> bool {
72    false
73}
74
75#[cfg(all(feature = "interaction-test-hooks", not(target_arch = "wasm32")))]
76#[inline]
77fn force_interactive_stdin() -> bool {
78    FORCE_INTERACTIVE_STDIN.load(Ordering::Relaxed)
79}
80
81fn async_handler_slot() -> &'static RwLock<Option<Arc<AsyncInteractionHandler>>> {
82    ASYNC_HANDLER.get_or_init(|| RwLock::new(None))
83}
84
85fn interaction_error(identifier: &str, message: impl Into<String>) -> RuntimeError {
86    build_runtime_error(message)
87        .with_identifier(identifier.to_string())
88        .build()
89}
90
91pub struct AsyncHandlerGuard {
92    previous: Option<Arc<AsyncInteractionHandler>>,
93    state: Option<std::rc::Rc<crate::context::RuntimeContextState>>,
94}
95
96impl Drop for AsyncHandlerGuard {
97    fn drop(&mut self) {
98        if let Some(state) = &self.state {
99            state.interaction.borrow_mut().async_handler = self.previous.take();
100        } else {
101            let mut slot = async_handler_slot()
102                .write()
103                .unwrap_or_else(|_| panic!("interaction async handler lock poisoned"));
104            *slot = self.previous.take();
105        }
106    }
107}
108
109pub fn replace_async_handler(handler: Option<Arc<AsyncInteractionHandler>>) -> AsyncHandlerGuard {
110    if let Some(state) = active_state() {
111        let previous =
112            std::mem::replace(&mut state.interaction.borrow_mut().async_handler, handler);
113        return AsyncHandlerGuard {
114            previous,
115            state: Some(state),
116        };
117    }
118    let mut slot = async_handler_slot()
119        .write()
120        .unwrap_or_else(|_| panic!("interaction async handler lock poisoned"));
121    let previous = std::mem::replace(&mut *slot, handler);
122    AsyncHandlerGuard {
123        previous,
124        state: None,
125    }
126}
127
128pub async fn request_line_async(prompt: &str, echo: bool) -> Result<String, RuntimeError> {
129    if let Some(response) = take_queued_response() {
130        return match response
131            .map_err(|err| interaction_error("RunMat:interaction:QueuedResponseError", err))?
132        {
133            InteractionResponse::Line(value) => Ok(value),
134            InteractionResponse::KeyPress => Err(interaction_error(
135                "RunMat:interaction:UnexpectedQueuedKeypress",
136                "queued keypress response used for line request",
137            )),
138        };
139    }
140
141    if let Some(context) = crate::context::legacy::active() {
142        if let Some(host) = context.service_ports().host() {
143            return match host
144                .interact(crate::context::HostInteraction::Line {
145                    prompt: prompt.to_string(),
146                    echo,
147                })
148                .await?
149            {
150                InteractionResponse::Line(line) => Ok(line),
151                InteractionResponse::KeyPress => Err(interaction_error(
152                    "RunMat:interaction:UnexpectedAsyncKeypress",
153                    "runtime host returned keypress for line request",
154                )),
155            };
156        }
157    }
158
159    if let Some(handler) = current_async_handler() {
160        let owned = InteractionPromptOwned {
161            prompt: prompt.to_string(),
162            kind: InteractionKind::Line { echo },
163        };
164        let value = handler(owned)
165            .await
166            .map_err(|err| interaction_error("RunMat:interaction:AsyncHandlerError", err))?;
167        return match value {
168            InteractionResponse::Line(line) => Ok(line),
169            InteractionResponse::KeyPress => Err(interaction_error(
170                "RunMat:interaction:UnexpectedAsyncKeypress",
171                "interaction async handler returned keypress for line request",
172            )),
173        };
174    }
175
176    default_read_line(prompt, echo)
177        .map_err(|err| interaction_error("RunMat:interaction:ReadLineFailed", err))
178}
179
180pub async fn wait_for_key_async(prompt: &str) -> Result<(), RuntimeError> {
181    if let Some(response) = take_queued_response() {
182        return match response
183            .map_err(|err| interaction_error("RunMat:interaction:QueuedResponseError", err))?
184        {
185            InteractionResponse::Line(_) => Err(interaction_error(
186                "RunMat:interaction:UnexpectedQueuedLine",
187                "queued line response used for keypress request",
188            )),
189            InteractionResponse::KeyPress => Ok(()),
190        };
191    }
192
193    if let Some(context) = crate::context::legacy::active() {
194        if let Some(host) = context.service_ports().host() {
195            return match host
196                .interact(crate::context::HostInteraction::KeyPress {
197                    prompt: prompt.to_string(),
198                })
199                .await?
200            {
201                InteractionResponse::Line(_) => Err(interaction_error(
202                    "RunMat:interaction:UnexpectedAsyncLine",
203                    "runtime host returned line value for keypress request",
204                )),
205                InteractionResponse::KeyPress => Ok(()),
206            };
207        }
208    }
209
210    if let Some(handler) = current_async_handler() {
211        let owned = InteractionPromptOwned {
212            prompt: prompt.to_string(),
213            kind: InteractionKind::KeyPress,
214        };
215        let value = handler(owned)
216            .await
217            .map_err(|err| interaction_error("RunMat:interaction:AsyncHandlerError", err))?;
218        return match value {
219            InteractionResponse::Line(_) => Err(interaction_error(
220                "RunMat:interaction:UnexpectedAsyncLine",
221                "interaction async handler returned line value for keypress request",
222            )),
223            InteractionResponse::KeyPress => Ok(()),
224        };
225    }
226
227    default_wait_for_key(prompt)
228        .map_err(|err| interaction_error("RunMat:interaction:WaitForKeyFailed", err))
229}
230
231pub fn default_read_line(prompt: &str, echo: bool) -> Result<String, String> {
232    #[cfg(target_arch = "wasm32")]
233    {
234        let _ = (prompt, echo);
235        Err("stdin input is not available on wasm targets".to_string())
236    }
237    #[cfg(not(target_arch = "wasm32"))]
238    {
239        if !prompt.is_empty() {
240            let mut stdout = io::stdout();
241            write!(stdout, "{prompt}")
242                .map_err(|err| format!("input: failed to write prompt ({err})"))?;
243            stdout
244                .flush()
245                .map_err(|err| format!("input: failed to flush stdout ({err})"))?;
246        }
247        let mut line = String::new();
248        let stdin = io::stdin();
249        let bytes_read = stdin
250            .read_line(&mut line)
251            .map_err(|err| format!("input: failed to read from stdin ({err})"))?;
252        normalize_line_read(line, bytes_read, echo)
253    }
254}
255
256#[cfg(not(target_arch = "wasm32"))]
257fn normalize_line_read(line: String, bytes_read: usize, echo: bool) -> Result<String, String> {
258    if bytes_read == 0 {
259        return Err("input: stdin reached EOF before input was available".to_string());
260    }
261    if !echo {
262        // When echo is disabled we still read the full line; no additional handling needed.
263    }
264    Ok(line.trim_end_matches(&['\r', '\n'][..]).to_string())
265}
266
267pub fn default_wait_for_key(prompt: &str) -> Result<(), String> {
268    #[cfg(target_arch = "wasm32")]
269    {
270        let _ = prompt;
271        Err("keypress input is not available on wasm targets".to_string())
272    }
273    #[cfg(not(target_arch = "wasm32"))]
274    {
275        if !prompt.is_empty() {
276            let mut stdout = io::stdout();
277            write!(stdout, "{prompt}")
278                .map_err(|err| format!("pause: failed to write prompt ({err})"))?;
279            stdout
280                .flush()
281                .map_err(|err| format!("pause: failed to flush stdout ({err})"))?;
282        }
283        let stdin = io::stdin();
284        if !stdin.is_terminal() && !force_interactive_stdin() {
285            return Ok(());
286        }
287        let mut handle = stdin.lock();
288        let mut buf = [0u8; 1];
289        handle
290            .read(&mut buf)
291            .map_err(|err| format!("pause: failed to read from stdin ({err})"))?;
292        Ok(())
293    }
294}
295
296pub fn push_queued_response(response: Result<InteractionResponse, String>) {
297    if let Some(state) = active_state() {
298        state.interaction.borrow_mut().queued_response = Some(response);
299        return;
300    }
301    QUEUED_RESPONSE.with(|slot| {
302        *slot.borrow_mut() = Some(response);
303    });
304}
305
306// NOTE: The old suspend/resume control flow has been removed.
307
308// ---------------------------------------------------------------------------
309// Eval hook – lets runmat-core install a stateless expression evaluator so
310// that `input()` can parse numeric responses through the full MATLAB pipeline
311// instead of falling back to `str2double` (which cannot handle matrix literals,
312// named constants like `pi`, arithmetic, etc.).
313// ---------------------------------------------------------------------------
314
315/// Future returned by the eval hook.
316pub type EvalHookFuture = Pin<Box<dyn Future<Output = Result<Value, RuntimeError>> + 'static>>;
317
318/// Function signature for the eval hook.
319pub type EvalHookFn = dyn Fn(String) -> EvalHookFuture + Send + Sync;
320
321static EVAL_HOOK: OnceCell<RwLock<Option<Arc<EvalHookFn>>>> = OnceCell::new();
322
323fn eval_hook_slot() -> &'static RwLock<Option<Arc<EvalHookFn>>> {
324    EVAL_HOOK.get_or_init(|| RwLock::new(None))
325}
326
327/// RAII guard that restores the previous eval hook on drop.
328pub struct EvalHookGuard {
329    previous: Option<Arc<EvalHookFn>>,
330    state: Option<std::rc::Rc<crate::context::RuntimeContextState>>,
331}
332
333impl Drop for EvalHookGuard {
334    fn drop(&mut self) {
335        if let Some(state) = &self.state {
336            state.interaction.borrow_mut().eval_hook = self.previous.take();
337        } else {
338            let mut slot = eval_hook_slot()
339                .write()
340                .unwrap_or_else(|_| panic!("interaction eval hook lock poisoned"));
341            *slot = self.previous.take();
342        }
343    }
344}
345
346/// Replace the global eval hook for the duration of the returned guard's
347/// lifetime. Mirrors the pattern used by `replace_async_handler`.
348pub fn replace_eval_hook(hook: Option<Arc<EvalHookFn>>) -> EvalHookGuard {
349    if let Some(state) = active_state() {
350        let previous = std::mem::replace(&mut state.interaction.borrow_mut().eval_hook, hook);
351        return EvalHookGuard {
352            previous,
353            state: Some(state),
354        };
355    }
356    let mut slot = eval_hook_slot()
357        .write()
358        .unwrap_or_else(|_| panic!("interaction eval hook lock poisoned"));
359    let previous = std::mem::replace(&mut *slot, hook);
360    EvalHookGuard {
361        previous,
362        state: None,
363    }
364}
365
366/// Return the currently installed eval hook, if any.
367pub fn current_eval_hook() -> Option<Arc<EvalHookFn>> {
368    if let Some(state) = active_state() {
369        return state.interaction.borrow().eval_hook.clone();
370    }
371    eval_hook_slot().read().ok().and_then(|slot| slot.clone())
372}
373
374fn current_async_handler() -> Option<Arc<AsyncInteractionHandler>> {
375    if let Some(state) = active_state() {
376        return state.interaction.borrow().async_handler.clone();
377    }
378    async_handler_slot()
379        .read()
380        .ok()
381        .and_then(|slot| slot.clone())
382}
383
384fn take_queued_response() -> Option<Result<InteractionResponse, String>> {
385    if let Some(state) = active_state() {
386        return state.interaction.borrow_mut().queued_response.take();
387    }
388    QUEUED_RESPONSE.with(|slot| slot.borrow_mut().take())
389}
390
391fn active_state() -> Option<std::rc::Rc<crate::context::RuntimeContextState>> {
392    crate::context::legacy::active().map(|context| std::rc::Rc::clone(context.state()))
393}
394
395#[cfg(all(test, not(target_arch = "wasm32")))]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn eof_line_read_is_interaction_error() {
401        let err = normalize_line_read(String::new(), 0, true).expect_err("EOF should fail");
402        assert!(err.contains("stdin reached EOF"));
403    }
404
405    #[test]
406    fn blank_line_with_bytes_is_valid_empty_response() {
407        let line = normalize_line_read("\n".to_string(), 1, true).expect("blank line");
408        assert_eq!(line, "");
409    }
410}