Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Rust.
- Supports explicit runtime color modes: `Auto`, `Always`, and `Never`
- Detects if the output is NOT going to a terminal (e.g. is going to a file or a
pipe) and disables colors in `Auto` mode
- Supports explicit target-aware rendering for stdout, stderr, or custom
terminal-aware destinations
- Complete documentation and examples

## Installation
Expand Down Expand Up @@ -198,6 +200,20 @@ applications that want to force color on or off for a specific execution path.
`NO_COLOR` still takes precedence in `Auto` and `Always` mode. If `NO_COLOR` is
set, output is plain text.

For non-stdout destinations, use `StyledText::render` with a `RenderTarget` so
`Auto` mode evaluates the real output target:

```rust
use colored_text::{Colorize, RenderTarget};

let warning = "Warning".yellow().bold();

eprintln!("{}", warning.render(RenderTarget::Stderr));

let captured = warning.render(RenderTarget::Terminal(false));
assert_eq!(captured, "Warning");
```

## Terminal Compatibility

This library uses ANSI escape codes for coloring and styling text. Most modern
Expand Down
2 changes: 0 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# TODO

- Add a writer-aware rendering API so `ColorMode::Auto` can use the actual
output target instead of always consulting `stdout()`.
- Revisit whether to add `rust-version` to `Cargo.toml` once we want to commit
to an explicit MSRV policy.
- Support 3-character shorthand hex colors like `#f80` in addition to 6-digit
Expand Down
57 changes: 52 additions & 5 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ pub enum ColorMode {
Never,
}

/// Output target used when rendering styled text explicitly.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RenderTarget {
/// Resolve terminal capability from stdout.
Stdout,
/// Resolve terminal capability from stderr.
Stderr,
/// Use an explicit terminal capability for a custom destination.
Terminal(bool),
}

/// Configuration for controlling runtime color behavior.
///
/// The active configuration is stored per thread. This makes it straightforward
Expand All @@ -28,7 +39,11 @@ pub struct ColorizeConfig {
thread_local! {
static CONFIG: RefCell<ColorizeConfig> = RefCell::new(ColorizeConfig::default());
#[cfg(test)]
static TERMINAL_OVERRIDE: RefCell<Option<bool>> = RefCell::new(None);
#[allow(clippy::missing_const_for_thread_local)]
static STDOUT_TERMINAL_OVERRIDE: RefCell<Option<bool>> = RefCell::new(None);
#[cfg(test)]
#[allow(clippy::missing_const_for_thread_local)]
static STDERR_TERMINAL_OVERRIDE: RefCell<Option<bool>> = RefCell::new(None);
}

impl Default for ColorizeConfig {
Expand Down Expand Up @@ -74,28 +89,60 @@ impl ColorizeConfig {
/// This respects [`ColorizeConfig::color_mode()`], and `NO_COLOR` takes
/// precedence over both [`ColorMode::Auto`] and [`ColorMode::Always`].
pub(crate) fn should_colorize() -> bool {
should_colorize_for(RenderTarget::Stdout)
}

/// Evaluate the current runtime color policy for a specific render target.
pub(crate) fn should_colorize_for(target: RenderTarget) -> bool {
match ColorizeConfig::color_mode() {
ColorMode::Never => false,
ColorMode::Always => std::env::var_os("NO_COLOR").is_none(),
ColorMode::Auto => std::env::var_os("NO_COLOR").is_none() && stdout_is_terminal(),
ColorMode::Auto => std::env::var_os("NO_COLOR").is_none() && target_is_terminal(target),
}
}

fn target_is_terminal(target: RenderTarget) -> bool {
match target {
RenderTarget::Stdout => stdout_is_terminal(),
RenderTarget::Stderr => stderr_is_terminal(),
RenderTarget::Terminal(value) => value,
}
}

fn stdout_is_terminal() -> bool {
#[cfg(test)]
if let Some(value) = TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow()) {
if let Some(value) = STDOUT_TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow()) {
return value;
}

std::io::stdout().is_terminal()
}

fn stderr_is_terminal() -> bool {
#[cfg(test)]
if let Some(value) = STDERR_TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow()) {
return value;
}

std::io::stderr().is_terminal()
}

#[cfg(test)]
pub(crate) fn set_terminal_override_for_tests(value: Option<bool>) {
TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow_mut() = value);
STDOUT_TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow_mut() = value);
}

#[cfg(test)]
pub(crate) fn get_terminal_override_for_tests() -> Option<bool> {
TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow())
STDOUT_TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow())
}

#[cfg(test)]
pub(crate) fn set_stderr_terminal_override_for_tests(value: Option<bool>) {
STDERR_TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow_mut() = value);
}

#[cfg(test)]
pub(crate) fn get_stderr_terminal_override_for_tests() -> Option<bool> {
STDERR_TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow())
}
5 changes: 4 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@
//! println!("{}", "Never colored".red());
//! ```
//!
//! When you need `Auto` mode to follow a destination other than stdout, use
//! [`StyledText::render`] with a [`RenderTarget`].
//!
//! # Note
//!
//! Colors and styles are implemented using ANSI escape codes, which are
Expand All @@ -100,5 +103,5 @@ mod style;
#[cfg(test)]
mod tests;

pub use config::{ColorMode, ColorizeConfig};
pub use config::{ColorMode, ColorizeConfig, RenderTarget};
pub use style::{Colorize, StyledText};
28 changes: 21 additions & 7 deletions src/style.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::fmt::{self, Display};

use crate::color::{hex_to_rgb, hsl_to_rgb, ColorSpec, NamedColor};
use crate::config::should_colorize;
use crate::config::{should_colorize, should_colorize_for, RenderTarget};

#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct StyleFlags {
Expand Down Expand Up @@ -307,16 +307,30 @@ impl StyledText {
self.raw_codes.clear();
self
}
}

impl Display for StyledText {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
/// Render the styled value for a specific output target.
///
/// This is useful when the caller knows the real destination is stderr or a
/// custom writer and wants [`crate::ColorMode::Auto`] to evaluate that
/// destination instead of the default stdout-based behavior used by
/// [`Display`].
pub fn render(&self, target: RenderTarget) -> String {
self.render_with_color_policy(should_colorize_for(target))
}

fn render_with_color_policy(&self, colorize: bool) -> String {
let codes = self.active_codes();
if !should_colorize() || codes.is_empty() {
return f.write_str(&self.text);
if !colorize || codes.is_empty() {
return self.text.clone();
}

write!(f, "\x1b[{}m{}\x1b[0m", codes.join(";"), self.text)
format!("\x1b[{}m{}\x1b[0m", codes.join(";"), self.text)
}
}

impl Display for StyledText {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.render_with_color_policy(should_colorize()))
}
}

Expand Down
71 changes: 70 additions & 1 deletion src/tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::color::{ColorSpec, NamedColor};
use crate::config::{
get_terminal_override_for_tests, set_terminal_override_for_tests, should_colorize,
get_stderr_terminal_override_for_tests, get_terminal_override_for_tests,
set_stderr_terminal_override_for_tests, set_terminal_override_for_tests, should_colorize,
};
use crate::*;
use rstest::*;
Expand All @@ -16,6 +17,7 @@ struct TestStateGuard {
previous_mode: ColorMode,
previous_no_color: Option<OsString>,
previous_terminal_override: Option<bool>,
previous_stderr_terminal_override: Option<bool>,
}

impl TestStateGuard {
Expand All @@ -42,6 +44,7 @@ impl TestStateGuard {
let previous_mode = ColorizeConfig::color_mode();
let previous_no_color = env::var_os("NO_COLOR");
let previous_terminal_override = get_terminal_override_for_tests();
let previous_stderr_terminal_override = get_stderr_terminal_override_for_tests();

match no_color {
Some(value) => env::set_var("NO_COLOR", value),
Expand All @@ -55,6 +58,7 @@ impl TestStateGuard {
previous_mode,
previous_no_color,
previous_terminal_override,
previous_stderr_terminal_override,
}
}
}
Expand All @@ -63,6 +67,7 @@ impl Drop for TestStateGuard {
fn drop(&mut self) {
ColorizeConfig::set_color_mode(self.previous_mode);
set_terminal_override_for_tests(self.previous_terminal_override);
set_stderr_terminal_override_for_tests(self.previous_stderr_terminal_override);
match self.previous_no_color.as_ref() {
Some(value) => env::set_var("NO_COLOR", value),
None => env::remove_var("NO_COLOR"),
Expand Down Expand Up @@ -409,12 +414,76 @@ fn test_color_mode_auto_uses_real_stdout_terminal_state_without_override() {
assert_eq!(should_colorize(), std::io::stdout().is_terminal());
}

#[test]
fn test_render_auto_uses_real_stderr_terminal_state_without_override() {
let _guard = TestStateGuard::with_state(ColorMode::Auto, None, None);
set_stderr_terminal_override_for_tests(None);

let expected = if std::io::stderr().is_terminal() {
"\x1b[31mtest\x1b[0m"
} else {
"test"
};

assert_eq!("test".red().render(RenderTarget::Stderr), expected);
}

#[test]
fn test_color_mode_auto_enables_color_for_terminal_output() {
let _guard = TestStateGuard::auto_terminal(true);
assert_eq!("test".red().to_string(), "\x1b[31mtest\x1b[0m");
}

#[test]
fn test_render_auto_uses_stderr_terminal_state() {
let _guard = TestStateGuard::with_state(ColorMode::Auto, None, Some(false));
set_stderr_terminal_override_for_tests(Some(true));
assert_eq!(
"test".red().render(RenderTarget::Stderr),
"\x1b[31mtest\x1b[0m"
);
}

#[test]
fn test_render_auto_uses_custom_terminal_state() {
let _guard = TestStateGuard::with_state(ColorMode::Auto, None, Some(true));
assert_eq!("test".red().render(RenderTarget::Terminal(false)), "test");
assert_eq!(
"test".red().render(RenderTarget::Terminal(true)),
"\x1b[31mtest\x1b[0m"
);
}

#[test]
fn test_render_always_ignores_target_terminal_state() {
let _guard = TestStateGuard::colors_enabled(ColorMode::Always);
assert_eq!(
"test".red().render(RenderTarget::Terminal(false)),
"\x1b[31mtest\x1b[0m"
);
}

#[test]
fn test_render_never_disables_color_for_all_targets() {
let _guard = TestStateGuard::colors_enabled(ColorMode::Never);
assert_eq!("test".red().render(RenderTarget::Stdout), "test");
assert_eq!("test".red().render(RenderTarget::Stderr), "test");
assert_eq!("test".red().render(RenderTarget::Terminal(true)), "test");
}

#[test]
fn test_render_respects_no_color_in_always_mode() {
let _guard = TestStateGuard::no_color(ColorMode::Always);
assert_eq!("test".red().render(RenderTarget::Terminal(true)), "test");
}

#[test]
fn test_display_remains_stdout_based_in_auto_mode() {
let _guard = TestStateGuard::with_state(ColorMode::Auto, None, Some(false));
set_stderr_terminal_override_for_tests(Some(true));
assert_eq!("test".red().to_string(), "test");
}

#[test]
fn test_color_mode_never_disables_color() {
let _guard = TestStateGuard::colors_enabled(ColorMode::Never);
Expand Down
Loading