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        let bytes_read = stdin
189            .read_line(&mut line)
190            .map_err(|err| format!("input: failed to read from stdin ({err})"))?;
191        normalize_line_read(line, bytes_read, echo)
192    }
193}
194
195#[cfg(not(target_arch = "wasm32"))]
196fn normalize_line_read(line: String, bytes_read: usize, echo: bool) -> Result<String, String> {
197    if bytes_read == 0 {
198        return Err("input: stdin reached EOF before input was available".to_string());
199    }
200    if !echo {
201        // When echo is disabled we still read the full line; no additional handling needed.
202    }
203    Ok(line.trim_end_matches(&['\r', '\n'][..]).to_string())
204}
205
206pub fn default_wait_for_key(prompt: &str) -> Result<(), String> {
207    #[cfg(target_arch = "wasm32")]
208    {
209        let _ = prompt;
210        Err("keypress input is not available on wasm targets".to_string())
211    }
212    #[cfg(not(target_arch = "wasm32"))]
213    {
214        if !prompt.is_empty() {
215            let mut stdout = io::stdout();
216            write!(stdout, "{prompt}")
217                .map_err(|err| format!("pause: failed to write prompt ({err})"))?;
218            stdout
219                .flush()
220                .map_err(|err| format!("pause: failed to flush stdout ({err})"))?;
221        }
222        let stdin = io::stdin();
223        if !stdin.is_terminal() && !force_interactive_stdin() {
224            return Ok(());
225        }
226        let mut handle = stdin.lock();
227        let mut buf = [0u8; 1];
228        handle
229            .read(&mut buf)
230            .map_err(|err| format!("pause: failed to read from stdin ({err})"))?;
231        Ok(())
232    }
233}
234
235pub fn push_queued_response(response: Result<InteractionResponse, String>) {
236    QUEUED_RESPONSE.with(|slot| {
237        *slot.borrow_mut() = Some(response);
238    });
239}
240
241// NOTE: The old suspend/resume control flow has been removed.
242
243// ---------------------------------------------------------------------------
244// Eval hook – lets runmat-core install a stateless expression evaluator so
245// that `input()` can parse numeric responses through the full MATLAB pipeline
246// instead of falling back to `str2double` (which cannot handle matrix literals,
247// named constants like `pi`, arithmetic, etc.).
248// ---------------------------------------------------------------------------
249
250/// Future returned by the eval hook.
251pub type EvalHookFuture = Pin<Box<dyn Future<Output = Result<Value, RuntimeError>> + 'static>>;
252
253/// Function signature for the eval hook.
254pub type EvalHookFn = dyn Fn(String) -> EvalHookFuture + Send + Sync;
255
256static EVAL_HOOK: OnceCell<RwLock<Option<Arc<EvalHookFn>>>> = OnceCell::new();
257
258fn eval_hook_slot() -> &'static RwLock<Option<Arc<EvalHookFn>>> {
259    EVAL_HOOK.get_or_init(|| RwLock::new(None))
260}
261
262/// RAII guard that restores the previous eval hook on drop.
263pub struct EvalHookGuard {
264    previous: Option<Arc<EvalHookFn>>,
265}
266
267impl Drop for EvalHookGuard {
268    fn drop(&mut self) {
269        let mut slot = eval_hook_slot()
270            .write()
271            .unwrap_or_else(|_| panic!("interaction eval hook lock poisoned"));
272        *slot = self.previous.take();
273    }
274}
275
276/// Replace the global eval hook for the duration of the returned guard's
277/// lifetime. Mirrors the pattern used by `replace_async_handler`.
278pub fn replace_eval_hook(hook: Option<Arc<EvalHookFn>>) -> EvalHookGuard {
279    let mut slot = eval_hook_slot()
280        .write()
281        .unwrap_or_else(|_| panic!("interaction eval hook lock poisoned"));
282    let previous = std::mem::replace(&mut *slot, hook);
283    EvalHookGuard { previous }
284}
285
286/// Return the currently installed eval hook, if any.
287pub fn current_eval_hook() -> Option<Arc<EvalHookFn>> {
288    eval_hook_slot().read().ok().and_then(|slot| slot.clone())
289}
290
291#[cfg(all(test, not(target_arch = "wasm32")))]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn eof_line_read_is_interaction_error() {
297        let err = normalize_line_read(String::new(), 0, true).expect_err("EOF should fail");
298        assert!(err.contains("stdin reached EOF"));
299    }
300
301    #[test]
302    fn blank_line_with_bytes_is_valid_empty_response() {
303        let line = normalize_line_read("\n".to_string(), 1, true).expect("blank line");
304        assert_eq!(line, "");
305    }
306}