use std::io::{StdoutLock, Write};
use termcolor::{Buffer, BufferWriter, ColorChoice, ColorSpec, WriteColor};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ColorMode {
#[default]
Auto,
Always,
Never,
}
impl ColorMode {
pub(crate) fn to_color_choice(self) -> ColorChoice {
match self {
Self::Auto => ColorChoice::Auto,
Self::Always => ColorChoice::Always,
Self::Never => ColorChoice::Never,
}
}
}
const STDOUT_CHUNK_BYTES: usize = 64 * 1_024;
pub(crate) trait ColorSink {
fn new_buffer(&self) -> Buffer;
fn emit(&self, buffer: &Buffer) -> std::io::Result<()>;
fn exclusive(&self) -> Option<StdoutLock<'static>> {
None
}
}
struct StdoutSink(BufferWriter);
impl ColorSink for StdoutSink {
fn new_buffer(&self) -> Buffer {
self.0.buffer()
}
fn emit(&self, buffer: &Buffer) -> std::io::Result<()> {
self.0.print(buffer)
}
fn exclusive(&self) -> Option<StdoutLock<'static>> {
Some(std::io::stdout().lock())
}
}
struct ChunkedSink<'s, S: ColorSink> {
sink: &'s S,
buffer: Buffer,
exclusive: Option<StdoutLock<'static>>,
}
impl<S: ColorSink> ChunkedSink<'_, S> {
fn emit_pending(&mut self) -> std::io::Result<()> {
if self.buffer.is_empty() {
return Ok(());
}
if self.exclusive.is_none() {
self.exclusive = self.sink.exclusive();
}
let emitted = self.sink.emit(&self.buffer);
self.buffer.clear();
emitted
}
}
impl<S: ColorSink> Write for ChunkedSink<'_, S> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let written = self.buffer.write(buf)?;
if self.buffer.len() >= STDOUT_CHUNK_BYTES {
self.emit_pending()?;
}
Ok(written)
}
fn flush(&mut self) -> std::io::Result<()> {
self.emit_pending()
}
}
impl<S: ColorSink> WriteColor for ChunkedSink<'_, S> {
fn supports_color(&self) -> bool {
self.buffer.supports_color()
}
fn set_color(&mut self, spec: &ColorSpec) -> std::io::Result<()> {
self.buffer.set_color(spec)
}
fn reset(&mut self) -> std::io::Result<()> {
self.buffer.reset()
}
}
pub(crate) fn print_to_stdout<F>(color_mode: ColorMode, render: F) -> std::io::Result<()>
where
F: FnOnce(&mut dyn WriteColor) -> std::io::Result<()>,
{
render_chunked(
&StdoutSink(BufferWriter::stdout(color_mode.to_color_choice())),
render,
)
}
fn render_chunked<S, F>(sink: &S, render: F) -> std::io::Result<()>
where
S: ColorSink,
F: FnOnce(&mut dyn WriteColor) -> std::io::Result<()>,
{
let mut chunked = ChunkedSink {
sink,
buffer: sink.new_buffer(),
exclusive: None,
};
let rendered = render(&mut chunked);
chunked.emit_pending()?;
rendered
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::fmt::Write as _;
use super::*;
#[derive(Default)]
struct CountingSink {
chunks: RefCell<Vec<usize>>,
bytes: RefCell<Vec<u8>>,
}
impl ColorSink for CountingSink {
fn new_buffer(&self) -> Buffer {
Buffer::no_color()
}
fn emit(&self, buffer: &Buffer) -> std::io::Result<()> {
self.chunks.borrow_mut().push(buffer.len());
self.bytes.borrow_mut().extend_from_slice(buffer.as_slice());
Ok(())
}
}
struct FailingSink;
impl ColorSink for FailingSink {
fn new_buffer(&self) -> Buffer {
Buffer::no_color()
}
fn emit(&self, _buffer: &Buffer) -> std::io::Result<()> {
Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe))
}
}
#[test]
fn a_small_render_costs_one_emission() {
const LINES: usize = 500;
let sink = CountingSink::default();
render_chunked(&sink, |out| {
for i in 0..LINES {
writeln!(out, "line {i}")?;
}
Ok(())
})
.expect("the counting sink never fails");
assert_eq!(sink.chunks.borrow().len(), 1);
let expected: String = (0..LINES).fold(String::new(), |mut acc, i| {
let _ = writeln!(acc, "line {i}");
acc
});
assert_eq!(sink.bytes.borrow().as_slice(), expected.as_bytes());
}
#[test]
fn a_large_render_stays_bounded_by_the_chunk_size() {
const LINE: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\n";
const LINES: usize = 64 * 1_024;
let sink = CountingSink::default();
render_chunked(&sink, |out| {
for _ in 0..LINES {
out.write_all(LINE.as_bytes())?;
}
Ok(())
})
.expect("the counting sink never fails");
let chunks = sink.chunks.borrow();
assert!(chunks.len() > 1, "expected chunking, got {}", chunks.len());
let largest = chunks.iter().copied().max().unwrap_or_default();
assert!(
largest <= STDOUT_CHUNK_BYTES + LINE.len(),
"chunk of {largest} B exceeds the {STDOUT_CHUNK_BYTES} B cap"
);
assert_eq!(sink.bytes.borrow().len(), LINES * LINE.len());
}
#[test]
fn a_render_error_still_emits_the_partial_buffer() {
let sink = CountingSink::default();
let err = render_chunked(&sink, |out| {
writeln!(out, "rendered before the failure")?;
Err(std::io::Error::from(std::io::ErrorKind::InvalidData))
})
.expect_err("the render error propagates");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert_eq!(
sink.bytes.borrow().as_slice(),
b"rendered before the failure\n"
);
}
#[test]
fn an_emission_error_reaches_the_caller() {
let err = render_chunked(&FailingSink, |out| writeln!(out, "anything"))
.expect_err("the sink always fails");
assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe);
}
#[test]
fn an_empty_render_emits_nothing() {
let sink = CountingSink::default();
render_chunked(&sink, |_| Ok(())).expect("no output, no failure");
assert!(sink.chunks.borrow().is_empty());
}
#[derive(Default)]
struct AnsiSink {
bytes: RefCell<Vec<u8>>,
}
impl ColorSink for AnsiSink {
fn new_buffer(&self) -> Buffer {
Buffer::ansi()
}
fn emit(&self, buffer: &Buffer) -> std::io::Result<()> {
self.bytes.borrow_mut().extend_from_slice(buffer.as_slice());
Ok(())
}
}
#[test]
fn chunked_sink_delegates_color_capability_to_its_buffer() {
let ansi = AnsiSink::default();
let mut ansi_supported = None;
render_chunked(&ansi, |out| {
ansi_supported = Some(out.supports_color());
out.set_color(ColorSpec::new().set_bold(true))?;
write!(out, "bold")?;
out.reset()?;
write!(out, "plain")
})
.expect("ansi render");
assert_eq!(ansi_supported, Some(true), "an ansi buffer supports color");
let text = String::from_utf8(ansi.bytes.borrow().clone()).expect("utf-8");
let (before, rest) = text.split_once("bold").expect("styled word present");
let (between, after) = rest.split_once("plain").expect("plain word present");
assert!(
before.contains('\u{1b}'),
"set_color must emit before the styled text: {text:?}"
);
assert!(
between.contains('\u{1b}'),
"reset must emit between the styled and plain text: {text:?}"
);
assert!(after.is_empty(), "nothing trails the render: {text:?}");
let plain = CountingSink::default();
let mut plain_supported = None;
render_chunked(&plain, |out| {
plain_supported = Some(out.supports_color());
out.set_color(ColorSpec::new().set_bold(true))?;
write!(out, "bold")?;
out.reset()
})
.expect("no-color render");
assert_eq!(
plain_supported,
Some(false),
"a no-color buffer reports no color support"
);
let text = String::from_utf8(plain.bytes.borrow().clone()).expect("utf-8");
assert_eq!(text, "bold", "a no-color buffer emits no escapes");
}
}