Skip to main content

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
26use std::env;
27use std::sync::OnceLock;
28
29/// possible stream sources
30#[derive(Clone, Copy, Debug)]
31pub enum Stream {
32    Stdout,
33    Stderr,
34}
35
36fn env_force_color() -> usize {
37    if let Ok(force) = env::var("FORCE_COLOR") {
38        match force.as_ref() {
39            "true" | "" => 1,
40            "false" => 0,
41            f => std::cmp::min(f.parse().unwrap_or(1), 3),
42        }
43    } else if let Ok(cli_clr_force) = env::var("CLICOLOR_FORCE") {
44        if cli_clr_force != "0" {
45            1
46        } else {
47            0
48        }
49    } else {
50        0
51    }
52}
53
54fn env_no_color() -> bool {
55    match as_str(&env::var("NO_COLOR")) {
56        Ok("0") | Err(_) => false,
57        Ok(_) => true,
58    }
59}
60
61// same as Option::as_deref
62fn as_str<E>(option: &Result<String, E>) -> Result<&str, &E> {
63    match option {
64        Ok(inner) => Ok(inner),
65        Err(e) => Err(e),
66    }
67}
68
69fn translate_level(level: usize) -> Option<ColorLevel> {
70    if level == 0 {
71        None
72    } else {
73        Some(ColorLevel {
74            level,
75            has_basic: true,
76            has_256: level >= 2,
77            has_16m: level >= 3,
78        })
79    }
80}
81
82fn is_a_tty(stream: Stream) -> bool {
83    use std::io::IsTerminal;
84    match stream {
85        Stream::Stdout => std::io::stdout().is_terminal(),
86        Stream::Stderr => std::io::stderr().is_terminal(),
87    }
88}
89
90fn supports_color(stream: Stream) -> usize {
91    let force_color = env_force_color();
92    if force_color > 0 {
93        force_color
94    } else if env_no_color()
95        || as_str(&env::var("TERM")) == Ok("dumb")
96        || !(is_a_tty(stream) || env::var("IGNORE_IS_TERMINAL").map_or(false, |v| v != "0"))
97    {
98        0
99    } else if env::var("COLORTERM").map(|colorterm| check_colorterm_16m(&colorterm)) == Ok(true)
100        || env::var("TERM").map(|term| check_term_16m(&term)) == Ok(true)
101        || as_str(&env::var("TERM_PROGRAM")) == Ok("iTerm.app")
102    {
103        3
104    } else if as_str(&env::var("TERM_PROGRAM")) == Ok("Apple_Terminal")
105        || env::var("TERM").map(|term| check_256_color(&term)) == Ok(true)
106    {
107        2
108    } else if env::var("COLORTERM").is_ok()
109        || check_ansi_color(env::var("TERM").ok().as_deref())
110        || env::var("CLICOLOR").map_or(false, |v| v != "0")
111        || is_ci::uncached()
112    {
113        1
114    } else {
115        0
116    }
117}
118
119#[cfg(windows)]
120fn check_ansi_color(term: Option<&str>) -> bool {
121    if let Some(term) = term {
122        // cygwin doesn't seem to support ANSI escape sequences and instead has its own variety.
123        term != "dumb" && term != "cygwin"
124    } else {
125        // TERM is generally not set on Windows. It's reasonable to assume that all Windows
126        // terminals support ANSI escape sequences (since Windows 10 version 1511).
127        true
128    }
129}
130
131#[cfg(not(windows))]
132fn check_ansi_color(term: Option<&str>) -> bool {
133    if let Some(term) = term {
134        // dumb terminals don't support ANSI escape sequences.
135        term != "dumb"
136    } else {
137        // TERM is not set, which is really weird on Unix systems.
138        false
139    }
140}
141
142fn check_colorterm_16m(colorterm: &str) -> bool {
143    colorterm == "truecolor" || colorterm == "24bit"
144}
145
146fn check_term_16m(term: &str) -> bool {
147    term.ends_with("direct") || term.ends_with("truecolor")
148}
149
150fn check_256_color(term: &str) -> bool {
151    term.ends_with("256") || term.ends_with("256color")
152}
153
154/**
155Returns a [ColorLevel] if a [Stream] supports terminal colors.
156*/
157pub fn on(stream: Stream) -> Option<ColorLevel> {
158    translate_level(supports_color(stream))
159}
160
161macro_rules! assert_stream_in_bounds {
162    ($($variant:ident)*) => {
163        $(
164            const _: () = [(); 2][Stream::$variant as usize];
165        )*
166    };
167}
168
169// Compile-time assertion that the below indexing will never panic
170assert_stream_in_bounds!(Stdout Stderr);
171
172/**
173Returns a [ColorLevel] if a [Stream] supports terminal colors, caching the result to
174be returned from then on.
175
176If you expect your environment to change between calls, use [`on`]
177*/
178pub fn on_cached(stream: Stream) -> Option<ColorLevel> {
179    static CACHE: [OnceLock<Option<ColorLevel>>; 2] = [OnceLock::new(), OnceLock::new()];
180
181    let stream_index = stream as usize;
182    *CACHE[stream_index].get_or_init(|| translate_level(supports_color(stream)))
183}
184
185/**
186Color level support details.
187
188This type is returned from [on]. See documentation for its fields for more details.
189*/
190#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
191pub struct ColorLevel {
192    level: usize,
193    /// Basic ANSI colors are supported.
194    pub has_basic: bool,
195    /// 256-bit colors are supported.
196    pub has_256: bool,
197    /// 16 million (RGB) colors are supported.
198    pub has_16m: bool,
199}
200
201#[cfg(test)]
202mod tests {
203    use std::sync::Mutex;
204
205    use super::*;
206
207    // needed to prevent race conditions when mutating the environment
208    static TEST_LOCK: Mutex<()> = Mutex::new(());
209
210    fn set_up() {
211        // clears process env variable
212        env::vars().for_each(|(k, _v)| env::remove_var(k));
213    }
214
215    #[test]
216    #[cfg_attr(miri, ignore)]
217    fn test_empty_env() {
218        let _test_guard = TEST_LOCK.lock().unwrap();
219        set_up();
220
221        assert_eq!(on(Stream::Stdout), None);
222    }
223
224    #[test]
225    #[cfg_attr(miri, ignore)]
226    fn test_clicolor_ansi() {
227        let _test_guard = TEST_LOCK.lock().unwrap();
228        set_up();
229
230        env::set_var("IGNORE_IS_TERMINAL", "1");
231        env::set_var("CLICOLOR", "1");
232        let expected = Some(ColorLevel {
233            level: 1,
234            has_basic: true,
235            has_256: false,
236            has_16m: false,
237        });
238        assert_eq!(on(Stream::Stdout), expected);
239
240        env::set_var("CLICOLOR", "0");
241        assert_eq!(on(Stream::Stdout), None);
242    }
243
244    #[test]
245    #[cfg_attr(miri, ignore)]
246    fn test_on_cached() {
247        let _test_guard = TEST_LOCK.lock().unwrap();
248        set_up();
249        env::set_var("IGNORE_IS_TERMINAL", "1");
250
251        env::set_var("CLICOLOR", "1");
252        assert!(on(Stream::Stdout).is_some());
253        assert!(on_cached(Stream::Stdout).is_some());
254
255        env::set_var("CLICOLOR", "0");
256        assert!(on(Stream::Stdout).is_none());
257        assert!(on_cached(Stream::Stdout).is_some());
258    }
259
260    #[test]
261    #[cfg_attr(miri, ignore)]
262    fn test_clicolor_force_ansi() {
263        let _test_guard = TEST_LOCK.lock().unwrap();
264        set_up();
265
266        env::set_var("CLICOLOR", "0");
267        env::set_var("CLICOLOR_FORCE", "1");
268        let expected = Some(ColorLevel {
269            level: 1,
270            has_basic: true,
271            has_256: false,
272            has_16m: false,
273        });
274        assert_eq!(on(Stream::Stdout), expected);
275    }
276}