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::sync::{Arc, RwLock};
7
8/// Identifies the console stream that received the text.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum ConsoleStream {
11    Stdout,
12    Stderr,
13    ClearScreen,
14}
15
16/// Single console write (line or chunk) captured during execution.
17#[derive(Clone, Debug)]
18pub struct ConsoleEntry {
19    pub stream: ConsoleStream,
20    pub text: String,
21    pub timestamp_ms: u64,
22}
23
24type StreamForwarder = dyn Fn(&ConsoleEntry) + Send + Sync + 'static;
25
26runmat_thread_local! {
27    static THREAD_BUFFER: RefCell<Vec<ConsoleEntry>> = const { RefCell::new(Vec::new()) };
28    static LAST_VALUE_OUTPUT: RefCell<Option<Value>> = const { RefCell::new(None) };
29}
30
31static FORWARDER: OnceCell<RwLock<Option<Arc<StreamForwarder>>>> = OnceCell::new();
32
33fn now_ms() -> u64 {
34    unix_timestamp_ms().min(u64::MAX as u128) as u64
35}
36
37/// Record console output for the current thread while also forwarding it to any
38/// registered listener (used by wasm bindings for live streaming).
39pub fn record_console_output(stream: ConsoleStream, text: impl Into<String>) {
40    let entry = ConsoleEntry {
41        stream,
42        text: text.into(),
43        timestamp_ms: now_ms(),
44    };
45    THREAD_BUFFER.with(|buf| buf.borrow_mut().push(entry.clone()));
46
47    if let Some(forwarder) = FORWARDER
48        .get()
49        .and_then(|lock| lock.read().ok().map(|guard| guard.as_ref().cloned()))
50        .flatten()
51    {
52        forwarder(&entry);
53    }
54}
55
56/// Record a control event that asks the host to clear the visible console.
57pub fn record_clear_screen() {
58    record_console_output(ConsoleStream::ClearScreen, String::new());
59}
60
61/// Record a line-oriented console entry, ensuring the stream text ends with a newline.
62pub fn record_console_line(stream: ConsoleStream, text: impl Into<String>) {
63    let mut text = text.into();
64    if !text.ends_with('\n') {
65        text.push('\n');
66    }
67    record_console_output(stream, text);
68}
69
70/// Clears the per-thread console buffer. Call this before execution begins so
71/// each run only returns fresh output.
72pub fn reset_thread_buffer() {
73    THREAD_BUFFER.with(|buf| buf.borrow_mut().clear());
74    LAST_VALUE_OUTPUT.with(|value| value.borrow_mut().take());
75}
76
77/// Drain (and return) the buffered console entries for the current thread.
78pub fn take_thread_buffer() -> Vec<ConsoleEntry> {
79    THREAD_BUFFER.with(|buf| buf.borrow_mut().drain(..).collect())
80}
81
82/// Install (or remove) a global forwarder for console output. Passing `None`
83/// removes the current listener.
84pub fn install_forwarder(forwarder: Option<Arc<StreamForwarder>>) {
85    let lock = FORWARDER.get_or_init(|| RwLock::new(None));
86    if let Ok(mut guard) = lock.write() {
87        *guard = forwarder;
88    }
89}
90
91/// Convenience helper to record formatted value output (matching MATLAB's `name = value` layout).
92pub fn record_value_output(label: Option<&str>, value: &Value) {
93    LAST_VALUE_OUTPUT.with(|last| {
94        *last.borrow_mut() = Some(value.clone());
95    });
96    let value_text = match value {
97        Value::Object(obj) if obj.is_class("datetime") => {
98            crate::builtins::datetime::datetime_display_text(value)
99                .ok()
100                .flatten()
101                .unwrap_or_else(|| value.to_string())
102        }
103        Value::Object(obj) if obj.is_class("duration") => {
104            crate::builtins::duration::duration_display_text(value)
105                .ok()
106                .flatten()
107                .unwrap_or_else(|| value.to_string())
108        }
109        _ => value.to_string(),
110    };
111    let text = if let Some(name) = label {
112        if is_unlabeled_nd_page_display(&value_text) {
113            inject_label_into_nd_page_headers(name, &value_text)
114        } else if value_text.contains('\n') {
115            format!("{name} =\n{value_text}")
116        } else {
117            format!("{name} = {value_text}")
118        }
119    } else {
120        value_text
121    };
122    record_console_line(ConsoleStream::Stdout, text);
123}
124
125pub fn take_last_value_output() -> Option<Value> {
126    LAST_VALUE_OUTPUT.with(|value| value.borrow_mut().take())
127}
128
129fn is_unlabeled_nd_page_display(text: &str) -> bool {
130    text.lines()
131        .any(|line| line.trim_start().starts_with("(:, :") && line.trim_end().ends_with('='))
132}
133
134fn inject_label_into_nd_page_headers(label: &str, text: &str) -> String {
135    let mut out = String::new();
136    for (idx, line) in text.lines().enumerate() {
137        if idx > 0 {
138            out.push('\n');
139        }
140        let trimmed = line.trim_start();
141        if trimmed.starts_with("(:, :") && trimmed.trim_end().ends_with('=') {
142            out.push_str(label);
143            out.push_str(trimmed);
144        } else {
145            out.push_str(line);
146        }
147    }
148    out
149}