supports_color/
lib.rs

1//! Detects whether a terminal supports color, and gives details about that
2//! support. It takes into account the `NO_COLOR` environment variable.
3//!
4//! This crate is a Rust port of [@sindresorhus](https://github.com/sindresorhus)'
5//! [NPM package by the same name](https://npm.im/supports-color).
6//!
7//! ## Example
8//!
9//! ```rust
10//! use supports_color::Stream;
11//!
12//! if let Some(support) = supports_color::on(Stream::Stdout) {
13//!     if support.has_16m {
14//!         println!("16 million (RGB) colors are supported");
15//!     } else if support.has_256 {
16//!         println!("256-bit colors are supported.");
17//!     } else if support.has_basic {
18//!         println!("Only basic ANSI colors are supported.");
19//!     }
20//! } else {
21//!     println!("No color support.");
22//! }
23//! ```
24#![allow(clippy::bool_to_int_with_if)]
25
26pub use atty::Stream;
27
28use std::cell::UnsafeCell;
29use std::env;
30use std::sync::Once;
31
32fn env_force_color() -> usize {
33    if let Ok(force) = env::var("FORCE_COLOR") {
34        match force.as_ref() {
35            "true" | "" => 1,
36            "false" => 0,
37            f => std::cmp::min(f.parse().unwrap_or(1), 3),
38        }
39    } else if let Ok(cli_clr_force) = env::var("CLICOLOR_FORCE") {
40        if cli_clr_force != "0" {
41            1
42        } else {
43            0
44        }
45    } else {
46        0
47    }
48}
49
50fn env_no_color() -> bool {
51    match as_str(&env::var("NO_COLOR")) {
52        Ok("0") | Err(_) => false,
53        Ok(_) => true,
54    }
55}
56
57// same as Option::as_deref
58fn as_str<E>(option: &Result<String, E>) -> Result<&str, &E> {
59    match option {
60        Ok(inner) => Ok(inner),
61        Err(e) => Err(e),
62    }
63}
64
65fn translate_level(level: usize) -> Option<ColorLevel> {
66    if level == 0 {
67        None
68    } else {
69        Some(ColorLevel {
70            level,
71            has_basic: true,
72            has_256: level >= 2,
73            has_16m: level >= 3,
74        })
75    }
76}
77
78fn supports_color(stream: Stream) -> usize {
79    let force_color = env_force_color();
80    if force_color > 0 {
81        force_color
82    } else if env_no_color() || !atty::is(stream) || as_str(&env::var("TERM")) == Ok("dumb") {
83        0
84    } else if as_str(&env::var("COLORTERM")) == Ok("truecolor")
85        || as_str(&env::var("TERM_PROGRAM")) == Ok("iTerm.app")
86    {
87        3
88    } else if as_str(&env::var("TERM_PROGRAM")) == Ok("Apple_Terminal")
89        || env::var("TERM").map(|term| check_256_color(&term)) == Ok(true)
90    {
91        2
92    } else if env::var("COLORTERM").is_ok()
93        || env::var("TERM").map(|term| check_ansi_color(&term)) == Ok(true)
94        || env::consts::OS == "windows"
95        || env::var("CLICOLOR").map_or(false, |v| v != "0")
96        || is_ci::uncached()
97    {
98        1
99    } else {
100        0
101    }
102}
103
104fn check_ansi_color(term: &str) -> bool {
105    term.starts_with("screen")
106        || term.starts_with("xterm")
107        || term.starts_with("vt100")
108        || term.starts_with("vt220")
109        || term.starts_with("rxvt")
110        || term.contains("color")
111        || term.contains("ansi")
112        || term.contains("cygwin")
113        || term.contains("linux")
114}
115
116fn check_256_color(term: &str) -> bool {
117    term.ends_with("256") || term.ends_with("256color")
118}
119
120/**
121Returns a [ColorLevel] if a [Stream] supports terminal colors.
122*/
123pub fn on(stream: Stream) -> Option<ColorLevel> {
124    translate_level(supports_color(stream))
125}
126
127struct CacheCell(UnsafeCell<Option<ColorLevel>>);
128
129unsafe impl Sync for CacheCell {}
130
131static INIT: [Once; 3] = [Once::new(), Once::new(), Once::new()];
132static ON_CACHE: [CacheCell; 3] = [
133    CacheCell(UnsafeCell::new(None)),
134    CacheCell(UnsafeCell::new(None)),
135    CacheCell(UnsafeCell::new(None)),
136];
137
138macro_rules! assert_stream_in_bounds {
139    ($($variant:ident)*) => {
140        $(
141            const _: () = [(); 3][Stream::$variant as usize];
142        )*
143    };
144}
145
146// Compile-time assertion that the below indexing will never panic
147assert_stream_in_bounds!(Stdout Stderr Stdin);
148
149/**
150Returns a [ColorLevel] if a [Stream] supports terminal colors, caching the result to
151be returned from then on.
152
153If you expect your environment to change between calls, use [`on`]
154*/
155pub fn on_cached(stream: Stream) -> Option<ColorLevel> {
156    let stream_index = stream as usize;
157    INIT[stream_index].call_once(|| unsafe {
158        *ON_CACHE[stream_index].0.get() = translate_level(supports_color(stream));
159    });
160
161    unsafe { *ON_CACHE[stream_index].0.get() }
162}
163
164/**
165Color level support details.
166
167This type is returned from [on]. See documentation for its fields for more details.
168*/
169#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
170pub struct ColorLevel {
171    level: usize,
172    /// Basic ANSI colors are supported.
173    pub has_basic: bool,
174    /// 256-bit colors are supported.
175    pub has_256: bool,
176    /// 16 million (RGB) colors are supported.
177    pub has_16m: bool,
178}
179
180#[cfg(test)]
181mod tests {
182    use std::sync::Mutex;
183
184    use super::*;
185
186    // needed to prevent race conditions when mutating the environment
187    static TEST_LOCK: Mutex<()> = Mutex::new(());
188
189    fn set_up() {
190        // clears process env variable
191        env::vars().for_each(|(k, _v)| env::remove_var(k));
192    }
193
194    #[test]
195    #[cfg_attr(miri, ignore)]
196    fn test_empty_env() {
197        let _test_guard = TEST_LOCK.lock().unwrap();
198        set_up();
199
200        assert_eq!(on(atty::Stream::Stdout), None);
201    }
202
203    #[test]
204    #[cfg_attr(miri, ignore)]
205    fn test_clicolor_ansi() {
206        let _test_guard = TEST_LOCK.lock().unwrap();
207        set_up();
208
209        env::set_var("CLICOLOR", "1");
210        let expected = Some(ColorLevel {
211            level: 1,
212            has_basic: true,
213            has_256: false,
214            has_16m: false,
215        });
216        assert_eq!(on(atty::Stream::Stdout), expected);
217
218        env::set_var("CLICOLOR", "0");
219        assert_eq!(on(atty::Stream::Stdout), None);
220    }
221
222    #[test]
223    #[cfg_attr(miri, ignore)]
224    fn test_clicolor_force_ansi() {
225        let _test_guard = TEST_LOCK.lock().unwrap();
226        set_up();
227
228        env::set_var("CLICOLOR", "0");
229        env::set_var("CLICOLOR_FORCE", "1");
230        let expected = Some(ColorLevel {
231            level: 1,
232            has_basic: true,
233            has_256: false,
234            has_16m: false,
235        });
236        assert_eq!(on(atty::Stream::Stdout), expected);
237    }
238}