Skip to main content

candela_vm/
captured_output.rs

1// This file is derived from keel (https://github.com/horacehoff/keel),
2// Copyright 2026 Horace Hoff, licensed under the Apache License, Version 2.0.
3// It has been modified by the candela authors. See the NOTICE file.
4//! Where program output and error reports go.
5//!
6//! By default they go to the process's stdout and stderr. A host that embeds
7//! the runtime can redirect both into a per-thread buffer instead, run some
8//! candela code, and read the buffer back. Redirection is a runtime setting, so
9//! a single build serves both the command-line toolchain and an embedding host.
10//! wasm builds always redirect, since there are no process streams to write to.
11//!
12//! A stream that refuses a write costs the text, never the run: write through
13//! [`out!`] and [`outln!`] rather than unwrapping.
14
15use std::cell::Cell;
16use std::cell::RefCell;
17use std::io::Write;
18
19thread_local! {
20    pub static CAPTURED_OUTPUT: RefCell<String> = const { RefCell::new(String::new()) };
21    static CAPTURING: Cell<bool> = const { Cell::new(false) };
22}
23
24/// Whether output on this thread is redirected into [`CAPTURED_OUTPUT`].
25#[inline]
26pub fn is_capturing() -> bool {
27    cfg!(target_arch = "wasm32") || CAPTURING.with(Cell::get)
28}
29
30/// Starts or stops redirecting output on this thread, and returns the setting
31/// that was in effect, so a caller can put it back.
32pub fn set_capturing(on: bool) -> bool {
33    CAPTURING.replace(on)
34}
35
36/// Appends to this thread's capture buffer, whether or not redirection is on.
37pub fn print(s: &str) {
38    CAPTURED_OUTPUT.with(|o| o.borrow_mut().push_str(s));
39}
40
41/// The sink for program output (`print` and friends).
42pub fn stdout() -> OutputHandle {
43    if is_capturing() {
44        OutputHandle::Captured
45    } else {
46        OutputHandle::Stdout(std::io::stdout().lock())
47    }
48}
49
50/// The sink for error reports.
51pub fn stderr() -> OutputHandle {
52    if is_capturing() {
53        OutputHandle::Captured
54    } else {
55        OutputHandle::Stderr(std::io::stderr())
56    }
57}
58
59/// Writes to an [`OutputHandle`], and drops the text when the write fails.
60///
61/// A pipe whose reader has already exited fails every write it is given, and
62/// the same goes for a stream a supervisor closed. Raising there ends the run
63/// over output nobody reads: it kills a piped command mid-program, and it kills
64/// the thread an embedding host renders on. The line is worth less than the
65/// run, so it goes nowhere and execution carries on.
66macro_rules! out {
67    ($handle:expr, $($arg:tt)*) => {{
68        let _ = std::io::Write::write_fmt(&mut $handle, format_args!($($arg)*));
69    }};
70}
71
72/// [`out!`] with a trailing newline, for a whole line of program output.
73macro_rules! outln {
74    ($handle:expr, $($arg:tt)*) => {{
75        let _ = std::io::Write::write_fmt(
76            &mut $handle,
77            format_args!("{}\n", format_args!($($arg)*)),
78        );
79    }};
80}
81
82pub(crate) use out;
83pub(crate) use outln;
84
85/// A writer that resolves to a process stream or to the capture buffer,
86/// whichever is in effect when it is created.
87pub enum OutputHandle {
88    Stdout(std::io::StdoutLock<'static>),
89    Stderr(std::io::Stderr),
90    Captured,
91}
92
93impl Write for OutputHandle {
94    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
95        match self {
96            Self::Stdout(out) => out.write(buf),
97            Self::Stderr(out) => out.write(buf),
98            Self::Captured => {
99                if let Ok(s) = std::str::from_utf8(buf) {
100                    print(s);
101                }
102                Ok(buf.len())
103            }
104        }
105    }
106
107    fn flush(&mut self) -> std::io::Result<()> {
108        match self {
109            Self::Stdout(out) => out.flush(),
110            Self::Stderr(out) => out.flush(),
111            Self::Captured => Ok(()),
112        }
113    }
114
115    fn write_fmt(&mut self, fmt: std::fmt::Arguments<'_>) -> std::io::Result<()> {
116        match self {
117            Self::Stdout(out) => out.write_fmt(fmt),
118            Self::Stderr(out) => out.write_fmt(fmt),
119            Self::Captured => {
120                print(&fmt.to_string());
121                Ok(())
122            }
123        }
124    }
125}
126
127#[cfg(test)]
128mod output_tests {
129    use super::CAPTURED_OUTPUT;
130    use super::OutputHandle;
131    use std::io::Write;
132
133    /// A stream that fails every write, the way a pipe reads once its reader
134    /// has exited.
135    struct Refuses {
136        attempts: u32,
137    }
138
139    impl Write for Refuses {
140        fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
141            self.attempts += 1;
142            Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe))
143        }
144
145        fn flush(&mut self) -> std::io::Result<()> {
146            Ok(())
147        }
148    }
149
150    #[test]
151    fn a_refused_write_costs_the_text_and_nothing_else() {
152        let mut refuses = Refuses { attempts: 0 };
153
154        out!(refuses, "part of a line");
155        outln!(refuses, "a whole {}", "line");
156
157        // Getting this far is the property under test: neither write raised,
158        // so whatever was printing carries on to whatever it does next.
159        assert_eq!(
160            refuses.attempts, 2,
161            "both writes reach the stream before their errors are dropped"
162        );
163    }
164
165    #[test]
166    fn a_write_that_lands_keeps_its_text() {
167        CAPTURED_OUTPUT.with(|o| o.borrow_mut().clear());
168
169        let mut handle = OutputHandle::Captured;
170        out!(handle, "{}:", "key");
171        outln!(handle, "{}", 42);
172
173        let captured = CAPTURED_OUTPUT.with(|o| o.borrow().clone());
174        assert_eq!(captured, "key:42\n");
175    }
176}