Skip to main content

runmat_runtime/
console.rs

1use once_cell::sync::OnceCell;
2use runmat_builtins::Value;
3use runmat_thread_local::runmat_thread_local;
4use runmat_time::unix_timestamp_ms;
5use std::cell::RefCell;
6use std::io::Write;
7use std::path::PathBuf;
8use std::sync::{Arc, RwLock};
9
10/// Identifies the console stream that received the text.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum ConsoleStream {
13    Stdout,
14    Stderr,
15    ClearScreen,
16}
17
18/// Single console write (line or chunk) captured during execution.
19#[derive(Clone, Debug)]
20pub struct ConsoleEntry {
21    pub stream: ConsoleStream,
22    pub text: String,
23    pub timestamp_ms: u64,
24}
25
26type StreamForwarder = dyn Fn(&ConsoleEntry) + Send + Sync + 'static;
27
28runmat_thread_local! {
29    static THREAD_BUFFER: RefCell<Vec<ConsoleEntry>> = const { RefCell::new(Vec::new()) };
30    static LAST_VALUE_OUTPUT: RefCell<Option<Value>> = const { RefCell::new(None) };
31    static CAPTURE_STACK: RefCell<Vec<Vec<ConsoleEntry>>> = const { RefCell::new(Vec::new()) };
32    static DIARY_STATE: RefCell<DiaryState> = RefCell::new(DiaryState::default());
33}
34
35static FORWARDER: OnceCell<RwLock<Option<Arc<StreamForwarder>>>> = OnceCell::new();
36
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct DiaryStateSnapshot {
39    pub enabled: bool,
40    pub filename: PathBuf,
41}
42
43impl Default for DiaryStateSnapshot {
44    fn default() -> Self {
45        Self {
46            enabled: false,
47            filename: PathBuf::from("diary"),
48        }
49    }
50}
51
52#[derive(Debug)]
53struct DiaryState {
54    enabled: bool,
55    filename: PathBuf,
56    last_error: Option<String>,
57}
58
59impl Default for DiaryState {
60    fn default() -> Self {
61        Self {
62            enabled: false,
63            filename: PathBuf::from("diary"),
64            last_error: None,
65        }
66    }
67}
68
69impl From<DiaryStateSnapshot> for DiaryState {
70    fn from(snapshot: DiaryStateSnapshot) -> Self {
71        Self {
72            enabled: snapshot.enabled,
73            filename: snapshot.filename,
74            last_error: None,
75        }
76    }
77}
78
79/// Guard for a dynamic command-window capture scope such as `evalc`.
80pub struct ConsoleCaptureGuard {
81    active: bool,
82}
83
84fn now_ms() -> u64 {
85    unix_timestamp_ms().min(u64::MAX as u128) as u64
86}
87
88/// Record console output for the current thread while also forwarding it to any
89/// registered listener (used by wasm bindings for live streaming).
90pub fn record_console_output(stream: ConsoleStream, text: impl Into<String>) {
91    let entry = ConsoleEntry {
92        stream,
93        text: text.into(),
94        timestamp_ms: now_ms(),
95    };
96    if CAPTURE_STACK.with(|captures| {
97        let mut captures = captures.borrow_mut();
98        if let Some(current) = captures.last_mut() {
99            current.push(entry.clone());
100            true
101        } else {
102            false
103        }
104    }) {
105        return;
106    }
107
108    THREAD_BUFFER.with(|buf| buf.borrow_mut().push(entry.clone()));
109    write_diary_entry(&entry);
110
111    if let Some(forwarder) = FORWARDER
112        .get()
113        .and_then(|lock| lock.read().ok().map(|guard| guard.as_ref().cloned()))
114        .flatten()
115    {
116        forwarder(&entry);
117    }
118}
119
120/// Record a control event that asks the host to clear the visible console.
121pub fn record_clear_screen() {
122    record_console_output(ConsoleStream::ClearScreen, String::new());
123}
124
125/// Record a line-oriented console entry, ensuring the stream text ends with a newline.
126pub fn record_console_line(stream: ConsoleStream, text: impl Into<String>) {
127    let mut text = text.into();
128    if !text.ends_with('\n') {
129        text.push('\n');
130    }
131    record_console_output(stream, text);
132}
133
134/// Clears the per-thread console buffer. Call this before execution begins so
135/// each run only returns fresh output.
136pub fn reset_thread_buffer() {
137    THREAD_BUFFER.with(|buf| buf.borrow_mut().clear());
138    LAST_VALUE_OUTPUT.with(|value| value.borrow_mut().take());
139}
140
141/// Drain (and return) the buffered console entries for the current thread.
142pub fn take_thread_buffer() -> Vec<ConsoleEntry> {
143    THREAD_BUFFER.with(|buf| buf.borrow_mut().drain(..).collect())
144}
145
146/// Append console entries captured on another execution thread without
147/// forwarding them again to live stream listeners.
148pub fn append_thread_buffer(entries: impl IntoIterator<Item = ConsoleEntry>) {
149    THREAD_BUFFER.with(|buf| {
150        let mut buf = buf.borrow_mut();
151        buf.extend(entries);
152        buf.sort_by_key(|entry| entry.timestamp_ms);
153    });
154}
155
156/// Install (or remove) a global forwarder for console output. Passing `None`
157/// removes the current listener.
158pub fn install_forwarder(forwarder: Option<Arc<StreamForwarder>>) {
159    let lock = FORWARDER.get_or_init(|| RwLock::new(None));
160    if let Ok(mut guard) = lock.write() {
161        *guard = forwarder;
162    }
163}
164
165/// Convenience helper to record formatted value output (matching MATLAB's `name = value` layout).
166pub fn record_value_output(label: Option<&str>, value: &Value) {
167    LAST_VALUE_OUTPUT.with(|last| {
168        *last.borrow_mut() = Some(value.clone());
169    });
170    let value_text = match value {
171        Value::Object(obj) if obj.is_class("datetime") => {
172            crate::builtins::datetime::datetime_display_text(value)
173                .ok()
174                .flatten()
175                .unwrap_or_else(|| value.to_string())
176        }
177        Value::Object(obj) if obj.is_class("duration") => {
178            crate::builtins::duration::duration_display_text(value)
179                .ok()
180                .flatten()
181                .unwrap_or_else(|| value.to_string())
182        }
183        _ => value.to_string(),
184    };
185    let text = if let Some(name) = label {
186        if is_unlabeled_nd_page_display(&value_text) {
187            inject_label_into_nd_page_headers(name, &value_text)
188        } else if value_text.contains('\n') {
189            format!("{name} =\n{value_text}")
190        } else {
191            format!("{name} = {value_text}")
192        }
193    } else {
194        value_text
195    };
196    record_console_line(ConsoleStream::Stdout, text);
197}
198
199pub fn take_last_value_output() -> Option<Value> {
200    LAST_VALUE_OUTPUT.with(|value| value.borrow_mut().take())
201}
202
203/// Begin capturing command-window text for the current execution context.
204///
205/// While a capture is active, stdout/stderr entries are diverted into the
206/// capture buffer instead of the normal execution stream, live forwarder, or
207/// diary log. This matches MATLAB's `evalc` behavior, where `diary` is disabled
208/// inside the captured evaluation.
209pub fn begin_capture() -> ConsoleCaptureGuard {
210    CAPTURE_STACK.with(|captures| captures.borrow_mut().push(Vec::new()));
211    ConsoleCaptureGuard { active: true }
212}
213
214impl ConsoleCaptureGuard {
215    pub fn finish(mut self) -> String {
216        self.active = false;
217        let entries =
218            CAPTURE_STACK.with(|captures| captures.borrow_mut().pop().unwrap_or_default());
219        captured_text(entries)
220    }
221}
222
223impl Drop for ConsoleCaptureGuard {
224    fn drop(&mut self) {
225        if self.active {
226            CAPTURE_STACK.with(|captures| {
227                captures.borrow_mut().pop();
228            });
229        }
230    }
231}
232
233fn captured_text(entries: Vec<ConsoleEntry>) -> String {
234    let mut out = String::new();
235    for entry in entries {
236        if matches!(entry.stream, ConsoleStream::Stdout | ConsoleStream::Stderr) {
237            out.push_str(&entry.text);
238        }
239    }
240    out
241}
242
243pub fn diary_enabled() -> bool {
244    DIARY_STATE.with(|state| state.borrow().enabled)
245}
246
247pub fn diary_filename() -> PathBuf {
248    DIARY_STATE.with(|state| state.borrow().filename.clone())
249}
250
251pub fn diary_state_snapshot() -> DiaryStateSnapshot {
252    DIARY_STATE.with(|state| {
253        let state = state.borrow();
254        DiaryStateSnapshot {
255            enabled: state.enabled,
256            filename: state.filename.clone(),
257        }
258    })
259}
260
261pub fn replace_diary_state(snapshot: DiaryStateSnapshot) -> DiaryStateSnapshot {
262    DIARY_STATE.with(|state| {
263        let previous = {
264            let state = state.borrow();
265            DiaryStateSnapshot {
266                enabled: state.enabled,
267                filename: state.filename.clone(),
268            }
269        };
270        *state.borrow_mut() = DiaryState::from(snapshot);
271        previous
272    })
273}
274
275pub fn take_diary_error() -> Option<String> {
276    DIARY_STATE.with(|state| state.borrow_mut().last_error.take())
277}
278
279pub fn set_diary_filename(filename: impl Into<PathBuf>) {
280    DIARY_STATE.with(|state| {
281        let mut state = state.borrow_mut();
282        state.filename = filename.into();
283        state.enabled = true;
284        state.last_error = None;
285    });
286}
287
288pub fn set_diary_filename_checked(filename: impl Into<PathBuf>) -> std::io::Result<()> {
289    let filename = filename.into();
290    open_diary_append(&filename).map(|_| ())?;
291    set_diary_filename(filename);
292    Ok(())
293}
294
295pub fn set_diary_enabled(enabled: bool) {
296    DIARY_STATE.with(|state| {
297        let mut state = state.borrow_mut();
298        state.enabled = enabled;
299        if enabled {
300            state.last_error = None;
301        }
302    });
303}
304
305pub fn set_diary_enabled_checked(enabled: bool) -> std::io::Result<()> {
306    if enabled {
307        ensure_diary_writable()?;
308    }
309    set_diary_enabled(enabled);
310    Ok(())
311}
312
313pub fn toggle_diary() -> std::io::Result<()> {
314    if diary_enabled() {
315        set_diary_enabled(false);
316    } else {
317        set_diary_enabled_checked(true)?;
318    }
319    Ok(())
320}
321
322pub fn ensure_diary_writable() -> std::io::Result<()> {
323    let filename = diary_filename();
324    open_diary_append(&filename).map(|_| ())
325}
326
327/// Append the top-level source text to the diary without echoing it to stdout.
328pub fn record_diary_command(text: &str) {
329    if !diary_enabled() {
330        return;
331    }
332    let mut owned = text.to_string();
333    if !owned.ends_with('\n') {
334        owned.push('\n');
335    }
336    if let Err(err) = write_diary_text(&owned) {
337        record_diary_failure(err);
338    }
339}
340
341fn write_diary_entry(entry: &ConsoleEntry) {
342    if !matches!(entry.stream, ConsoleStream::Stdout | ConsoleStream::Stderr) {
343        return;
344    }
345    if diary_enabled() {
346        if let Err(err) = write_diary_text(&entry.text) {
347            record_diary_failure(err);
348        }
349    }
350}
351
352fn record_diary_failure(err: std::io::Error) {
353    DIARY_STATE.with(|state| {
354        let mut state = state.borrow_mut();
355        state.enabled = false;
356        state.last_error = Some(format!("diary: failed to write diary file ({err})"));
357    });
358}
359
360fn write_diary_text(text: &str) -> std::io::Result<()> {
361    let filename = diary_filename();
362    let mut file = open_diary_append(&filename)?;
363    file.write_all(text.as_bytes())?;
364    file.flush()
365}
366
367fn open_diary_append(path: &PathBuf) -> std::io::Result<runmat_filesystem::File> {
368    let mut options = runmat_filesystem::OpenOptions::new();
369    options.create(true).append(true);
370    options.open(path)
371}
372
373fn is_unlabeled_nd_page_display(text: &str) -> bool {
374    text.lines()
375        .any(|line| line.trim_start().starts_with("(:, :") && line.trim_end().ends_with('='))
376}
377
378fn inject_label_into_nd_page_headers(label: &str, text: &str) -> String {
379    let mut out = String::new();
380    for (idx, line) in text.lines().enumerate() {
381        if idx > 0 {
382            out.push('\n');
383        }
384        let trimmed = line.trim_start();
385        if trimmed.starts_with("(:, :") && trimmed.trim_end().ends_with('=') {
386            out.push_str(label);
387            out.push_str(trimmed);
388        } else {
389            out.push_str(line);
390        }
391    }
392    out
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    fn entry(text: &str, timestamp_ms: u64) -> ConsoleEntry {
400        ConsoleEntry {
401            stream: ConsoleStream::Stdout,
402            text: text.to_string(),
403            timestamp_ms,
404        }
405    }
406
407    #[test]
408    fn append_thread_buffer_orders_entries_by_timestamp_stably() {
409        reset_thread_buffer();
410        append_thread_buffer(vec![entry("late", 20)]);
411        append_thread_buffer(vec![entry("early", 10), entry("same-time", 20)]);
412
413        let entries = take_thread_buffer();
414        let texts = entries
415            .into_iter()
416            .map(|entry| entry.text)
417            .collect::<Vec<_>>();
418        assert_eq!(texts, vec!["early", "late", "same-time"]);
419    }
420
421    #[test]
422    fn capture_diverts_console_text_from_thread_buffer() {
423        let _lock = runmat_filesystem::provider_override_lock();
424        set_diary_enabled(false);
425        reset_thread_buffer();
426        let capture = begin_capture();
427        record_console_line(ConsoleStream::Stdout, "inside");
428        let text = capture.finish();
429        record_console_line(ConsoleStream::Stdout, "outside");
430
431        assert_eq!(text, "inside\n");
432        let entries = take_thread_buffer();
433        assert_eq!(entries.len(), 1);
434        assert_eq!(entries[0].text, "outside\n");
435    }
436}