Skip to main content

runmat_runtime/
interaction.rs

1use once_cell::sync::OnceCell;
2use runmat_builtins::Value;
3use runmat_thread_local::runmat_thread_local;
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#[cfg(all(feature = "interaction-test-hooks", not(target_arch = "wasm32")))]
44static FORCE_INTERACTIVE_STDIN: AtomicBool = AtomicBool::new(false);
45
46#[cfg(all(feature = "interaction-test-hooks", not(target_arch = "wasm32")))]
47pub fn force_interactive_stdin_for_tests(enable: bool) {
48    FORCE_INTERACTIVE_STDIN.store(enable, Ordering::Relaxed);
49}
50
51#[cfg(all(not(feature = "interaction-test-hooks"), not(target_arch = "wasm32")))]
52#[inline]
53fn force_interactive_stdin() -> bool {
54    false
55}
56
57#[cfg(all(feature = "interaction-test-hooks", not(target_arch = "wasm32")))]
58#[inline]
59fn force_interactive_stdin() -> bool {
60    FORCE_INTERACTIVE_STDIN.load(Ordering::Relaxed)
61}
62
63fn async_handler_slot() -> &'static RwLock<Option<Arc<AsyncInteractionHandler>>> {
64    ASYNC_HANDLER.get_or_init(|| RwLock::new(None))
65}
66
67fn interaction_error(identifier: &str, message: impl Into<String>) -> RuntimeError {
68    build_runtime_error(message)
69        .with_identifier(identifier.to_string())
70        .build()
71}
72
73pub struct AsyncHandlerGuard {
74    previous: Option<Arc<AsyncInteractionHandler>>,
75}
76
77impl Drop for AsyncHandlerGuard {
78    fn drop(&mut self) {
79        let mut slot = async_handler_slot()
80            .write()
81            .unwrap_or_else(|_| panic!("interaction async handler lock poisoned"));
82        *slot = self.previous.take();
83    }
84}
85
86pub fn replace_async_handler(handler: Option<Arc<AsyncInteractionHandler>>) -> AsyncHandlerGuard {
87    let mut slot = async_handler_slot()
88        .write()
89        .unwrap_or_else(|_| panic!("interaction async handler lock poisoned"));
90    let previous = std::mem::replace(&mut *slot, handler);
91    AsyncHandlerGuard { previous }
92}
93
94pub async fn request_line_async(prompt: &str, echo: bool) -> Result<String, RuntimeError> {
95    if let Some(response) = QUEUED_RESPONSE.with(|slot| slot.borrow_mut().take()) {
96        return match response
97            .map_err(|err| interaction_error("RunMat:interaction:QueuedResponseError", err))?
98        {
99            InteractionResponse::Line(value) => Ok(value),
100            InteractionResponse::KeyPress => Err(interaction_error(
101                "RunMat:interaction:UnexpectedQueuedKeypress",
102                "queued keypress response used for line request",
103            )),
104        };
105    }
106
107    if let Some(handler) = async_handler_slot()
108        .read()
109        .ok()
110        .and_then(|slot| slot.clone())
111    {
112        let owned = InteractionPromptOwned {
113            prompt: prompt.to_string(),
114            kind: InteractionKind::Line { echo },
115        };
116        let value = handler(owned)
117            .await
118            .map_err(|err| interaction_error("RunMat:interaction:AsyncHandlerError", err))?;
119        return match value {
120            InteractionResponse::Line(line) => Ok(line),
121            InteractionResponse::KeyPress => Err(interaction_error(
122                "RunMat:interaction:UnexpectedAsyncKeypress",
123                "interaction async handler returned keypress for line request",
124            )),
125        };
126    }
127
128    default_read_line(prompt, echo)
129        .map_err(|err| interaction_error("RunMat:interaction:ReadLineFailed", err))
130}
131
132pub async fn wait_for_key_async(prompt: &str) -> Result<(), RuntimeError> {
133    if let Some(response) = QUEUED_RESPONSE.with(|slot| slot.borrow_mut().take()) {
134        return match response
135            .map_err(|err| interaction_error("RunMat:interaction:QueuedResponseError", err))?
136        {
137            InteractionResponse::Line(_) => Err(interaction_error(
138                "RunMat:interaction:UnexpectedQueuedLine",
139                "queued line response used for keypress request",
140            )),
141            InteractionResponse::KeyPress => Ok(()),
142        };
143    }
144
145    if let Some(handler) = async_handler_slot()
146        .read()
147        .ok()
148        .and_then(|slot| slot.clone())
149    {
150        let owned = InteractionPromptOwned {
151            prompt: prompt.to_string(),
152            kind: InteractionKind::KeyPress,
153        };
154        let value = handler(owned)
155            .await
156            .map_err(|err| interaction_error("RunMat:interaction:AsyncHandlerError", err))?;
157        return match value {
158            InteractionResponse::Line(_) => Err(interaction_error(
159                "RunMat:interaction:UnexpectedAsyncLine",
160                "interaction async handler returned line value for keypress request",
161            )),
162            InteractionResponse::KeyPress => Ok(()),
163        };
164    }
165
166    default_wait_for_key(prompt)
167        .map_err(|err| interaction_error("RunMat:interaction:WaitForKeyFailed", err))
168}
169
170pub fn default_read_line(prompt: &str, echo: bool) -> Result<String, String> {
171    #[cfg(target_arch = "wasm32")]
172    {
173        let _ = (prompt, echo);
174        Err("stdin input is not available on wasm targets".to_string())
175    }
176    #[cfg(not(target_arch = "wasm32"))]
177    {
178        if !prompt.is_empty() {
179            let mut stdout = io::stdout();
180            write!(stdout, "{prompt}")
181                .map_err(|err| format!("input: failed to write prompt ({err})"))?;
182            stdout
183                .flush()
184                .map_err(|err| format!("input: failed to flush stdout ({err})"))?;
185        }
186        let mut line = String::new();
187        let stdin = io::stdin();
188        stdin
189            .read_line(&mut line)
190            .map_err(|err| format!("input: failed to read from stdin ({err})"))?;
191        if !echo {
192            // When echo is disabled we still read the full line; no additional handling needed.
193        }
194        Ok(line.trim_end_matches(&['\r', '\n'][..]).to_string())
195    }
196}
197
198pub fn default_wait_for_key(prompt: &str) -> Result<(), String> {
199    #[cfg(target_arch = "wasm32")]
200    {
201        let _ = prompt;
202        Err("keypress input is not available on wasm targets".to_string())
203    }
204    #[cfg(not(target_arch = "wasm32"))]
205    {
206        if !prompt.is_empty() {
207            let mut stdout = io::stdout();
208            write!(stdout, "{prompt}")
209                .map_err(|err| format!("pause: failed to write prompt ({err})"))?;
210            stdout
211                .flush()
212                .map_err(|err| format!("pause: failed to flush stdout ({err})"))?;
213        }
214        let stdin = io::stdin();
215        if !stdin.is_terminal() && !force_interactive_stdin() {
216            return Ok(());
217        }
218        let mut handle = stdin.lock();
219        let mut buf = [0u8; 1];
220        handle
221            .read(&mut buf)
222            .map_err(|err| format!("pause: failed to read from stdin ({err})"))?;
223        Ok(())
224    }
225}
226
227pub fn push_queued_response(response: Result<InteractionResponse, String>) {
228    QUEUED_RESPONSE.with(|slot| {
229        *slot.borrow_mut() = Some(response);
230    });
231}
232
233// NOTE: The old suspend/resume control flow has been removed.
234
235// ---------------------------------------------------------------------------
236// Eval hook – lets runmat-core install a stateless expression evaluator so
237// that `input()` can parse numeric responses through the full MATLAB pipeline
238// instead of falling back to `str2double` (which cannot handle matrix literals,
239// named constants like `pi`, arithmetic, etc.).
240// ---------------------------------------------------------------------------
241
242/// Future returned by the eval hook.
243pub type EvalHookFuture = Pin<Box<dyn Future<Output = Result<Value, RuntimeError>> + 'static>>;
244
245/// Function signature for the eval hook.
246pub type EvalHookFn = dyn Fn(String) -> EvalHookFuture + Send + Sync;
247
248static EVAL_HOOK: OnceCell<RwLock<Option<Arc<EvalHookFn>>>> = OnceCell::new();
249
250fn eval_hook_slot() -> &'static RwLock<Option<Arc<EvalHookFn>>> {
251    EVAL_HOOK.get_or_init(|| RwLock::new(None))
252}
253
254/// RAII guard that restores the previous eval hook on drop.
255pub struct EvalHookGuard {
256    previous: Option<Arc<EvalHookFn>>,
257}
258
259impl Drop for EvalHookGuard {
260    fn drop(&mut self) {
261        let mut slot = eval_hook_slot()
262            .write()
263            .unwrap_or_else(|_| panic!("interaction eval hook lock poisoned"));
264        *slot = self.previous.take();
265    }
266}
267
268/// Replace the global eval hook for the duration of the returned guard's
269/// lifetime. Mirrors the pattern used by `replace_async_handler`.
270pub fn replace_eval_hook(hook: Option<Arc<EvalHookFn>>) -> EvalHookGuard {
271    let mut slot = eval_hook_slot()
272        .write()
273        .unwrap_or_else(|_| panic!("interaction eval hook lock poisoned"));
274    let previous = std::mem::replace(&mut *slot, hook);
275    EvalHookGuard { previous }
276}
277
278/// Return the currently installed eval hook, if any.
279pub fn current_eval_hook() -> Option<Arc<EvalHookFn>> {
280    eval_hook_slot().read().ok().and_then(|slot| slot.clone())
281}