diff --git a/CHANGES.md b/CHANGES.md index 875b6c5..dc22e08 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,15 @@ # woad.Rust - Changes +## 0.1.0-alpha1 - 22nd October 2026 + +* Colour policy from `WOAD_COLOUR` / `WOAD_COLOR`, `NO_COLOR`, `CLICOLOR_FORCE`, and `CLICOLOR`, cached until `refresh()`; +* Stream-conditional sequences (`seq_for`, `supports_colour`, attribute helpers) for `IsTerminal` destinations, plus Unix `AsFd` equivalents of the descriptor API; +* Packed `version()` in the **woad** `WOAD_VER` layout (`0x41` alpha 1) and `version_string()`; +* Attribute sequences `BOLD` and `BLINKING`; +* Windows console-mode and handle currencies are left for a later alpha; + + ## 0.0.1 - 12th September 2026 * SGR colour and reset codes (`RESET`, `FG_*`, `BG_*`, including bright variants); diff --git a/Cargo.lock b/Cargo.lock index 5c479f6..08c6ee0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,4 +4,4 @@ version = 3 [[package]] name = "woad" -version = "0.0.1" +version = "0.1.0-alpha1" diff --git a/Cargo.toml b/Cargo.toml index 2e233f1..9ddd57f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ authors = [ categories = [ "command-line-interface", ] -description = "Minimal ANSI terminal colour codes (for Rust)" +description = "Minimal ANSI terminal colour codes, with stream-conditional emission (for Rust)" documentation = "https://docs.rs/woad" edition = "2021" exclude = [ @@ -26,6 +26,7 @@ homepage = "https://github.com/synesissoftware/woad.Rust" keywords = [ "ansi", "colour", + "no-color", "sgr", "terminal", "tty", @@ -35,7 +36,7 @@ name = "woad" readme = "README.md" repository = "https://github.com/synesissoftware/woad.Rust" rust-version = "1.74" -version = "0.0.1" +version = "0.1.0-alpha1" # ########################################################## @@ -45,6 +46,10 @@ version = "0.0.1" name = "woad" path = "src/lib.rs" +[[example]] +name = "colour_for" +path = "examples/colour_for/main.rs" + [[example]] name = "versions" path = "examples/versions/main.rs" diff --git a/EXAMPLES.md b/EXAMPLES.md index 89c78a2..6e70ae1 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1,8 +1,10 @@ # woad.Rust - Examples -| Name | Source | Summary | -| ---- | ------ | ------- | +| Name | Source | Summary | +| -------------- | -------------------------------------------------------------- | ---------------------------------------------------- | +| **colour_for** | [examples/colour_for/main.rs](./examples/colour_for/main.rs) | Emits colour only when the destination warrants it | +| **versions** | [examples/versions/main.rs](./examples/versions/main.rs) | Prints `version_string()` and packed `version()` | diff --git a/NEWS.md b/NEWS.md index 603a10d..8914cb2 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,10 +1,11 @@ # woad.Rust - News -| Date | News Item | Details | -| ---------------- | --------- | --------------------------- | -| 12th September 2026 | [**woad.Rust** 0.0.1](https://github.com/synesissoftware/woad.Rust/releases/tag/0.0.1) | SGR colour and reset codes | -| 15th August 2026 | [**woad.Rust** 0.0.0](https://github.com/synesissoftware/woad.Rust/releases/tag/0.0.0) | Initial project scaffolding | +| Date | News Item | Details | +| ------------------- | --------------------------- | ------- | +| 22nd October 2026 | [**woad.Rust** 0.1.0-alpha1](https://github.com/synesissoftware/woad.Rust/releases/tag/0.1.0-alpha1) | Stream-conditional `seq_for` and colour policy | +| 12th September 2026 | [**woad.Rust** 0.0.1](https://github.com/synesissoftware/woad.Rust/releases/tag/0.0.1) | SGR colour and reset codes | +| 15th August 2026 | [**woad.Rust** 0.0.0](https://github.com/synesissoftware/woad.Rust/releases/tag/0.0.0) | Initial project scaffolding | diff --git a/README.md b/README.md index c1a0c23..e307a87 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,13 @@ Minimal ANSI terminal colour codes, for Rust ## Table of Contents - [Introduction](#introduction) +- [Developer Experience](#developer-experience) - [Installation](#installation) - [Components](#components) + - [Fixed sequences](#fixed-sequences) + - [Stream-conditional sequences](#stream-conditional-sequences) + - [Colour policy](#colour-policy) + - [Version](#version) - [Project Information](#project-information) - [Where to get help](#where-to-get-help) - [Contribution guidelines](#contribution-guidelines) @@ -29,9 +34,21 @@ Minimal ANSI terminal colour codes, for Rust ## Introduction -**woad** provides the smallest useful set of fixed ANSI SGR colour sequences for library authors. It is not a console or TUI framework. +**woad** provides fixed ANSI SGR sequences, and stream-conditional counterparts that emit only when the destination warrants colour. It is not a console or TUI framework. -**woad.Rust** is the **Rust** implementation. +**woad.Rust** is the **Rust** implementation. This alpha matches the dynamic API of **woad** off Windows. Console-mode changes and Windows handle currencies are not included. + + +## Developer Experience + +**woad.Rust** builds with Cargo. The minimum supported Rust version is **1.74**. + +```bash +cargo test +cargo run --example colour_for +``` + +`colour_for` prints colour only when stdout or stderr warrants it. Pipe it through `cat`, then repeat with `WOAD_COLOUR=always` and `WOAD_COLOUR=never`. Fixed sequences need no policy check. The conditional helpers are under [Components](#components). ## Installation @@ -39,13 +56,16 @@ Minimal ANSI terminal colour codes, for Rust Reference in **Cargo.toml** in the usual way: ```toml -woad = { version = "0" } +woad = { version = "0.1.0-alpha1" } ``` ## Components -**woad.Rust** ships SGR string constants (`RESET`, `FG_*`, `BG_*`, including bright variants) and `VERSION`. TTY/stream gating and Windows virtual-terminal opt-in are not implemented yet. + +### Fixed sequences + +String constants: `RESET`, `BOLD`, `BLINKING`, `FG_*`, and `BG_*`, the colours each in normal and `BRIGHT` variants. They always emit. ```rust use woad::{FG_GREEN, RESET}; @@ -56,6 +76,44 @@ fn main() { ``` +### Stream-conditional sequences + +`seq_for`, `fg_for`, and `bg_for` yield the sequence when the destination warrants colour, and `""` when it does not. `reset_for`, `bold_for`, and `blinking_for` are the attribute forms. The destination is any [`std::io::IsTerminal`](https://doc.rust-lang.org/std/io/trait.IsTerminal.html) value (`Stdout`, `Stderr`, `Stdin`, `File`, and on Unix a borrowed file descriptor). + +```rust +let stdout = std::io::stdout(); + +println!( + "{}ok{}", + woad::seq_for(woad::FG_GREEN, &stdout), + woad::reset_for(&stdout), +); +``` + +On Unix, `fd_is_tty`, `fd_supports_colour`, and `seq_for_fd` take [`AsFd`](https://doc.rust-lang.org/std/os/fd/trait.AsFd.html). A missing destination is `supports_colour_absent` / `seq_for_absent` (the C null-stream case: the policy alone). + + +### Colour policy + +The environment is read once, on first need, until `refresh()`: + +| Variable | Value | Effect | +| --- | --- | --- | +| `WOAD_COLOUR`, or `WOAD_COLOR` | `1`, `always`, `force`, `on`, `true`, `yes` | Force colour | +| `WOAD_COLOUR`, or `WOAD_COLOR` | `0`, `false`, `never`, `no`, `none`, `off` | Inhibit colour | +| `WOAD_COLOUR`, or `WOAD_COLOR` | `auto`, or anything unrecognised | Defer to the destination | +| `NO_COLOR` | set and non-empty | Inhibit colour | +| `CLICOLOR_FORCE` | set, non-empty, and not `0` | Force colour | +| `CLICOLOR` | `0` | Inhibit colour | + +`WOAD_COLOUR` / `WOAD_COLOR` are case-insensitive. In the deferring case, a `TERM` of `dumb` also suppresses colour. `console_supports_ansi()` is always `true` in this alpha. + + +### Version + +`VERSION` and `version_string()` are the SemVer string, including the prerelease label (`0.1.0-alpha1`). `version()` is the packed **woad** `WOAD_VER` (`0x00010041`; low byte `0x41` is alpha 1). + + ## Project Information diff --git a/TODO.md b/TODO.md index 9b0322b..4ad32da 100644 --- a/TODO.md +++ b/TODO.md @@ -11,8 +11,8 @@ ## Functional improvements * [x] ~~~SGR colour and reset codes~~~ - ✅; -* [ ] TTY-conditional colour codes (process and per-stream); -* [ ] Windows virtual-terminal gating (OS build + `GetConsoleMode`); +* [x] ~~~TTY-conditional colour codes (process and per-stream)~~~ - ✅; +* [ ] Windows virtual-terminal gating (OS build + `GetConsoleMode`) and handle currencies; ## Performance improvements diff --git a/examples/colour_for/main.rs b/examples/colour_for/main.rs new file mode 100644 index 0000000..fe5680a --- /dev/null +++ b/examples/colour_for/main.rs @@ -0,0 +1,60 @@ +// Emits colour only when the destination warrants it. +// +// cargo run --example colour_for +// cargo run --example colour_for | cat +// WOAD_COLOUR=always cargo run --example colour_for | cat +// WOAD_COLOUR=never cargo run --example colour_for + +fn main() { + let stdout = std::io::stdout(); + let stderr = std::io::stderr(); + + println!( + "policy: {}; stdout is{} a tty; stderr is{} a tty", + woad::colour_policy(), + if woad::stream_is_tty(&stdout) { "" } else { " not" }, + if woad::stream_is_tty(&stderr) { "" } else { " not" }, + ); + + println!( + "{}this line is green if, and only if, stdout warrants colour{}", + woad::seq_for(woad::FG_GREEN, &stdout), + woad::reset_for(&stdout), + ); + + println!( + "{}this line is bold if, and only if, stdout warrants colour{}", + woad::bold_for(&stdout), + woad::reset_for(&stdout), + ); + + println!( + "{}this line blinks if, and only if, stdout warrants colour{}", + woad::blinking_for(&stdout), + woad::reset_for(&stdout), + ); + + eprintln!( + "{}{}this line is white-on-red if, and only if, stderr warrants colour{}", + woad::bg_for(woad::BG_RED, &stderr), + woad::fg_for(woad::FG_BRIGHT_WHITE, &stderr), + woad::reset_for(&stderr), + ); + + println!( + "{}this line is yellow if, and only if, stdout warrants colour{}", + woad::fg_for(woad::FG_YELLOW, &stdout), + woad::reset_for(&stdout), + ); + + #[cfg(unix)] + { + use std::os::fd::AsFd as _; + + println!( + "{}this line is cyan if, and only if, stdout's descriptor warrants colour{}", + woad::seq_for_fd(woad::FG_CYAN, stdout.as_fd()), + woad::seq_for_fd(woad::RESET, stdout.as_fd()), + ); + } +} diff --git a/examples/versions/main.rs b/examples/versions/main.rs index 9886976..ad22cf8 100644 --- a/examples/versions/main.rs +++ b/examples/versions/main.rs @@ -1,3 +1,11 @@ fn main() { - println!("{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"),); + let stdout = std::io::stdout(); + + println!( + "{}v{}{} ({:#010x})", + woad::seq_for(woad::FG_GREEN, &stdout), + woad::version_string(), + woad::reset_for(&stdout), + woad::version(), + ); } diff --git a/src/dynamic.rs b/src/dynamic.rs new file mode 100644 index 0000000..b8065b1 --- /dev/null +++ b/src/dynamic.rs @@ -0,0 +1,727 @@ +//! Stream-conditional colour: policy, terminal attachment, and sequence +//! selection. +//! +//! This is the dynamic half of **woad**. Fixed sequences live in the +//! crate root. Windows console handles and virtual-terminal opt-in are +//! not implemented; see [`console_supports_ansi`]. + +use std::{ + io::IsTerminal, + sync::Mutex, +}; + +#[cfg(unix)] +use std::os::fd::AsFd; + + +// The truey/falsey token sets come from to-be.Rust +// (https://github.com/synesissoftware/to-be.Rust). Classification is local +// so this crate stays dependency-free. woad adds `always` / `force` and +// `never` / `none`, compares case-insensitively, and does not trim. + + +const TRUEY : &[&str] = &["1", "always", "force", "on", "true", "yes"]; + +const FALSEY : &[&str] = &["0", "false", "never", "no", "none", "off"]; + + +/// Prevailing colour policy, matching `WOAD_COLOUR_POLICY_*`. +#[derive(Clone)] +#[derive(Copy)] +#[derive(Debug)] +#[derive(Eq, PartialEq)] +#[repr(i32)] +pub enum ColourPolicy { + /// Defer to the destination: a terminal that is not `TERM=dumb`. + Auto = 0, + /// Emit colour for every destination. + Force = 1, + /// Emit colour for no destination. + Inhibit = 2, +} + +impl ColourPolicy { + /// Stable policy name: `auto`, `force`, or `inhibit`. + pub const fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Force => "force", + Self::Inhibit => "inhibit", + } + } +} + +impl std::fmt::Display for ColourPolicy { + fn fmt( + &self, + f : &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + + +struct Cached { + policy : ColourPolicy, + term_is_dumb : bool, +} + +static CACHE : Mutex> = Mutex::new(None); + +#[cfg(test)] +mod test_env { + use std::{ + cell::RefCell, + collections::BTreeMap, + }; + + // `const { }` in `thread_local!` needs Rust 1.79; the crate MSRV is + // 1.74, and this overlay exists only for tests. + thread_local! { + #[allow(clippy::missing_const_for_thread_local)] + pub(super) static ENV_OVERLAY : RefCell>> = + RefCell::new(None); + } + + pub(super) fn overlay_active() -> bool { + ENV_OVERLAY.with(|slot| slot.borrow().is_some()) + } +} + + +fn lock_cache() -> std::sync::MutexGuard<'static, Option> { + CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn getenv_nonempty(name : &str) -> Option { + #[cfg(test)] + { + let overlaid = test_env::ENV_OVERLAY.with(|slot| { + slot.borrow() + .as_ref() + .map(|map| map.get(name).filter(|value| !value.is_empty()).cloned()) + }); + + if let Some(value) = overlaid { + return value; + } + } + + std::env::var(name).ok().filter(|value| !value.is_empty()) +} + +fn is_truey(value : &str) -> bool { + TRUEY.iter().any(|token| value.eq_ignore_ascii_case(token)) +} + +fn is_falsey(value : &str) -> bool { + FALSEY.iter().any(|token| value.eq_ignore_ascii_case(token)) +} + +fn policy_from_environment() -> ColourPolicy { + let value = getenv_nonempty("WOAD_COLOUR").or_else(|| getenv_nonempty("WOAD_COLOR")); + + if let Some(value) = value.as_deref() { + if is_truey(value) { + return ColourPolicy::Force; + } + + if is_falsey(value) { + return ColourPolicy::Inhibit; + } + + return ColourPolicy::Auto; + } + + if getenv_nonempty("NO_COLOR").is_some() { + return ColourPolicy::Inhibit; + } + + if let Some(value) = getenv_nonempty("CLICOLOR_FORCE") { + if value != "0" { + return ColourPolicy::Force; + } + } + + if let Some(value) = getenv_nonempty("CLICOLOR") { + if value == "0" { + return ColourPolicy::Inhibit; + } + } + + ColourPolicy::Auto +} + +fn term_is_dumb_from_environment() -> bool { + getenv_nonempty("TERM").is_some_and(|value| value.eq_ignore_ascii_case("dumb")) +} + +fn current_determination() -> (ColourPolicy, bool) { + #[cfg(test)] + if test_env::overlay_active() { + return (policy_from_environment(), term_is_dumb_from_environment()); + } + + let mut cache = lock_cache(); + + if let Some(cached) = cache.as_ref() { + return (cached.policy, cached.term_is_dumb); + } + + let policy = policy_from_environment(); + let term_is_dumb = term_is_dumb_from_environment(); + + *cache = Some(Cached { + policy, + term_is_dumb, + }); + + (policy, term_is_dumb) +} + +fn colour_is_warranted( + is_tty : bool, + policy : ColourPolicy, + term_is_dumb : bool, + console_ansi : bool, +) -> bool { + match policy { + ColourPolicy::Force => true, + ColourPolicy::Inhibit => false, + ColourPolicy::Auto => is_tty && !term_is_dumb && console_ansi, + } +} + +fn colour_for_attachment(is_tty : bool) -> bool { + let (policy, term_is_dumb) = current_determination(); + + colour_is_warranted(is_tty, policy, term_is_dumb, console_supports_ansi()) +} + +fn seq_when( + seq : &str, + warranted : bool, +) -> &str { + if warranted { + seq + } else { + "" + } +} + + +/// Obtains the prevailing colour policy, from the environment. +/// +/// The environment is read once, on first need, and cached until +/// [`refresh`]. Later queries in the process see that cached policy. +/// Variables are consulted in this order, and the first decisive one +/// wins: +/// +/// * `WOAD_COLOUR`, or, if that is unset or empty, `WOAD_COLOR`. A +/// truey value (`1`, `always`, `force`, `on`, `true`, `yes`) forces +/// colour. A falsey value (`0`, `false`, `never`, `no`, `none`, +/// `off`) inhibits it. `auto`, or any unrecognised value, selects +/// [`ColourPolicy::Auto`]. Comparison is case-insensitive; +/// * `NO_COLOR`, when set and non-empty, inhibits colour +/// (); +/// * `CLICOLOR_FORCE`, when set, non-empty, and not exactly `0`, forces +/// colour. This comparison is case-sensitive; +/// * `CLICOLOR`, when set to exactly `0`, inhibits colour; +/// +/// Under [`ColourPolicy::Auto`], a `TERM` of `dumb` +/// (case-insensitive) also suppresses colour for a terminal. +/// +/// An empty value is treated as unset. Values are not trimmed. +pub fn colour_policy() -> ColourPolicy { + current_determination().0 +} + +/// Discards the cached policy and `TERM` inspection. +/// +/// The next query reads the environment again. Queries are +/// synchronised with this call. Mutating the process environment +/// concurrently with a query is the caller's concern, as it is for +/// any other reader of the environment. +pub fn refresh() { + *lock_cache() = None; +} + +/// Whether ANSI SGR sequences are known to be interpreted. +/// +/// Off Windows, **woad** always reports that they are. +/// Virtual-terminal detection, and `WOAD_SET_CONSOLE_MODE`, are +/// Windows-only and are not implemented in this alpha, so this +/// function does not consult the environment. +pub fn console_supports_ansi() -> bool { + true +} + +/// Whether `target` is attached to a terminal. +/// +/// This is [`std::io::IsTerminal`] for the Rust stream currency +/// (`Stdout`, `Stderr`, `Stdin`, [`std::fs::File`], and, on Unix, a +/// borrowed file descriptor). +pub fn stream_is_tty(target : &impl IsTerminal) -> bool { + target.is_terminal() +} + +/// Whether `fd` is attached to a terminal. +/// +/// Unix file-descriptor currency, equivalent to `woad_fd_is_tty`. +/// Pass a [`std::os::fd::AsFd`] value, for example `&file` or +/// `stdout.as_fd()`. Windows handle currencies are not implemented. +#[cfg(unix)] +pub fn fd_is_tty(fd : impl AsFd) -> bool { + stream_is_tty(&fd.as_fd()) +} + +/// Whether colour should be emitted for `target`. +/// +/// Combines [`colour_policy`], terminal attachment, `TERM=dumb`, and +/// [`console_supports_ansi`]. +pub fn stream_supports_colour(target : &impl IsTerminal) -> bool { + colour_for_attachment(stream_is_tty(target)) +} + +/// Whether colour should be emitted for `fd`. +/// +/// Descriptor equivalent of [`stream_supports_colour`]. +#[cfg(unix)] +pub fn fd_supports_colour(fd : impl AsFd) -> bool { + stream_supports_colour(&fd.as_fd()) +} + +/// Whether colour should be emitted when no destination is available. +/// +/// This is `woad_stream_supports_colour(NULL)`: the policy alone, with +/// no terminal attachment. [`ColourPolicy::Force`] is the only policy +/// that yields `true`. +pub fn supports_colour_absent() -> bool { + colour_for_attachment(false) +} + +/// Type-dispatched form of [`stream_supports_colour`]. +/// +/// On Unix a borrowed file descriptor implements [`IsTerminal`], so +/// this accepts that currency as well as standard streams and files. +pub fn supports_colour(target : &impl IsTerminal) -> bool { + stream_supports_colour(target) +} + +/// Yields `seq` when `target` warrants colour, and `""` otherwise. +/// +/// The result is either `seq` or an empty string literal. An empty +/// `seq` stays empty. There is no null sequence in Rust. +pub fn seq_for_stream<'seq>( + seq : &'seq str, + target : &impl IsTerminal, +) -> &'seq str { + seq_when(seq, stream_supports_colour(target)) +} + +/// Descriptor equivalent of [`seq_for_stream`]. +#[cfg(unix)] +pub fn seq_for_fd( + seq : &str, + fd : impl AsFd, +) -> &str { + seq_for_stream(seq, &fd.as_fd()) +} + +/// Yields `seq` from the policy alone, as for a null stream. +/// +/// See [`supports_colour_absent`]. +pub fn seq_for_absent(seq : &str) -> &str { + seq_when(seq, supports_colour_absent()) +} + +/// Type-dispatched form of [`seq_for_stream`]. +/// +/// [`fg_for`] and [`bg_for`] are synonyms: the sequence already names +/// its own SGR parameter, and the split records intent at the call. +pub fn seq_for<'seq>( + seq : &'seq str, + target : &impl IsTerminal, +) -> &'seq str { + seq_for_stream(seq, target) +} + +/// Foreground-intent synonym of [`seq_for`]. +pub fn fg_for<'seq>( + seq : &'seq str, + target : &impl IsTerminal, +) -> &'seq str { + seq_for(seq, target) +} + +/// Background-intent synonym of [`seq_for`]. +pub fn bg_for<'seq>( + seq : &'seq str, + target : &impl IsTerminal, +) -> &'seq str { + seq_for(seq, target) +} + +/// Yields [`crate::RESET`] for `target`, or `""`. +pub fn reset_for(target : &impl IsTerminal) -> &'static str { + seq_for(crate::RESET, target) +} + +/// Yields [`crate::BOLD`] for `target`, or `""`. +pub fn bold_for(target : &impl IsTerminal) -> &'static str { + seq_for(crate::BOLD, target) +} + +/// Yields [`crate::BLINKING`] for `target`, or `""`. +pub fn blinking_for(target : &impl IsTerminal) -> &'static str { + seq_for(crate::BLINKING, target) +} + + +#[cfg(test)] +mod tests { + #![allow(non_snake_case)] + + use std::io::IsTerminal; + + use super::{ + colour_is_warranted, + colour_policy, + console_supports_ansi, + refresh, + seq_for, + seq_for_absent, + seq_for_stream, + stream_is_tty, + stream_supports_colour, + supports_colour, + supports_colour_absent, + test_env::ENV_OVERLAY, + ColourPolicy, + }; + use crate::{ + BLINKING, + BOLD, + FG_RED, + RESET, + }; + + struct ClearOverlay; + + impl Drop for ClearOverlay { + fn drop(&mut self) { + ENV_OVERLAY.with(|slot| { + *slot.borrow_mut() = None; + }); + } + } + + fn with_env( + pairs : &[(&str, &str)], + body : impl FnOnce() -> R, + ) -> R { + let map = pairs + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect(); + + ENV_OVERLAY.with(|slot| { + *slot.borrow_mut() = Some(map); + }); + + let _clear = ClearOverlay; + + body() + } + + struct TempNotATty { + file : Option, + path : std::path::PathBuf, + } + + impl TempNotATty { + fn create() -> Self { + use std::sync::atomic::{ + AtomicU64, + Ordering, + }; + + static N : AtomicU64 = AtomicU64::new(0); + + let id = N.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("woad-rust-{}-{}", std::process::id(), id,)); + let file = std::fs::File::create(&path).unwrap(); + + Self { + file : Some(file), + path, + } + } + + fn file(&self) -> &std::fs::File { + self.file.as_ref().unwrap() + } + } + + impl Drop for TempNotATty { + fn drop(&mut self) { + self.file.take(); + let _ = std::fs::remove_file(&self.path); + } + } + + #[test] + fn TEST_POLICY_EMPTY_ENVIRONMENT_IS_AUTO() { + with_env(&[], || { + assert_eq!(ColourPolicy::Auto, colour_policy()); + assert!(!super::term_is_dumb_from_environment()); + }); + } + + #[test] + fn TEST_POLICY_TRUEY_TOKENS_FORCE() { + for token in ["1", "always", "force", "on", "true", "yes", "ALWAYS", "Force"] { + with_env(&[("WOAD_COLOUR", token)], || { + assert_eq!(ColourPolicy::Force, colour_policy(), "{token}"); + }); + } + } + + #[test] + fn TEST_POLICY_FALSEY_TOKENS_INHIBIT() { + for token in ["0", "false", "never", "no", "none", "off", "NEVER", "Off"] { + with_env(&[("WOAD_COLOUR", token)], || { + assert_eq!(ColourPolicy::Inhibit, colour_policy(), "{token}"); + }); + } + } + + #[test] + fn TEST_POLICY_UNRECOGNISED_WOAD_COLOUR_IS_AUTO() { + for token in ["auto", "banana", " true", "2"] { + with_env(&[("WOAD_COLOUR", token), ("NO_COLOR", "1")], || { + assert_eq!(ColourPolicy::Auto, colour_policy(), "{token}"); + }); + } + } + + #[test] + fn TEST_POLICY_WOAD_COLOR_IS_ACCEPTED_WHEN_COLOUR_IS_ABSENT() { + with_env(&[("WOAD_COLOR", "1")], || { + assert_eq!(ColourPolicy::Force, colour_policy()); + }); + + with_env(&[("WOAD_COLOUR", ""), ("WOAD_COLOR", "never")], || { + assert_eq!(ColourPolicy::Inhibit, colour_policy()); + }); + + with_env(&[("WOAD_COLOUR", "never"), ("WOAD_COLOR", "always")], || { + assert_eq!(ColourPolicy::Inhibit, colour_policy()); + }); + } + + #[test] + fn TEST_POLICY_PRECEDENCE() { + with_env(&[("CLICOLOR", "0")], || { + assert_eq!(ColourPolicy::Inhibit, colour_policy()); + }); + + with_env(&[("CLICOLOR", "0"), ("CLICOLOR_FORCE", "1")], || { + assert_eq!(ColourPolicy::Force, colour_policy()); + }); + + with_env(&[("CLICOLOR_FORCE", "1"), ("NO_COLOR", "1")], || { + assert_eq!(ColourPolicy::Inhibit, colour_policy()); + }); + + with_env( + &[("CLICOLOR_FORCE", "1"), ("NO_COLOR", "1"), ("WOAD_COLOUR", "force")], + || { + assert_eq!(ColourPolicy::Force, colour_policy()); + }, + ); + + with_env(&[("NO_COLOR", "")], || { + assert_eq!(ColourPolicy::Auto, colour_policy()); + }); + + with_env(&[("CLICOLOR_FORCE", "0")], || { + assert_eq!(ColourPolicy::Auto, colour_policy()); + }); + + with_env(&[("CLICOLOR_FORCE", "00")], || { + assert_eq!(ColourPolicy::Force, colour_policy()); + }); + + with_env(&[("CLICOLOR", "00")], || { + assert_eq!(ColourPolicy::Auto, colour_policy()); + }); + + with_env(&[("CLICOLOR", "0 ")], || { + assert_eq!(ColourPolicy::Auto, colour_policy()); + }); + } + + #[test] + fn TEST_TERM_DUMB_IS_CASE_INSENSITIVE() { + with_env(&[("TERM", "dumb")], || { + assert!(super::term_is_dumb_from_environment()); + }); + + with_env(&[("TERM", "Dumb")], || { + assert!(super::term_is_dumb_from_environment()); + }); + + with_env(&[("TERM", "xterm-256color")], || { + assert!(!super::term_is_dumb_from_environment()); + }); + + with_env(&[("TERM", "")], || { + assert!(!super::term_is_dumb_from_environment()); + }); + } + + #[test] + fn TEST_COLOUR_IS_WARRANTED_MATRIX() { + let rows = [ + (true, ColourPolicy::Force, true, false, true), + (false, ColourPolicy::Force, true, false, true), + (true, ColourPolicy::Inhibit, false, true, false), + (false, ColourPolicy::Inhibit, false, true, false), + (false, ColourPolicy::Auto, false, true, false), + (true, ColourPolicy::Auto, false, true, true), + (true, ColourPolicy::Auto, true, true, false), + (true, ColourPolicy::Auto, false, false, false), + (true, ColourPolicy::Auto, true, false, false), + ]; + + for (is_tty, policy, dumb, ansi, expected) in rows { + assert_eq!( + expected, + colour_is_warranted(is_tty, policy, dumb, ansi), + "tty={is_tty} policy={policy:?} dumb={dumb} ansi={ansi}", + ); + } + } + + #[test] + fn TEST_NON_TTY_FOLLOWS_POLICY() { + let not_a_tty = TempNotATty::create(); + + assert!(!stream_is_tty(not_a_tty.file())); + + with_env(&[], || { + assert_eq!(ColourPolicy::Auto, colour_policy()); + assert!(!stream_supports_colour(not_a_tty.file())); + assert_eq!("", seq_for_stream(FG_RED, not_a_tty.file())); + assert_eq!("", super::bold_for(not_a_tty.file())); + assert_eq!("", super::blinking_for(not_a_tty.file())); + assert_eq!("", super::reset_for(not_a_tty.file())); + assert!(!supports_colour_absent()); + assert_eq!("", seq_for_absent(FG_RED)); + }); + + with_env(&[("WOAD_COLOUR", "always")], || { + assert!(stream_supports_colour(not_a_tty.file())); + assert_eq!(FG_RED, seq_for(FG_RED, not_a_tty.file())); + assert_eq!(FG_RED, seq_for_stream(FG_RED, not_a_tty.file())); + assert_eq!(BOLD, super::bold_for(not_a_tty.file())); + assert_eq!(BLINKING, super::blinking_for(not_a_tty.file())); + assert_eq!(RESET, super::reset_for(not_a_tty.file())); + assert_eq!(FG_RED, super::fg_for(FG_RED, not_a_tty.file())); + assert_eq!(crate::BG_BLACK, super::bg_for(crate::BG_BLACK, not_a_tty.file())); + assert!(supports_colour(not_a_tty.file())); + assert!(supports_colour_absent()); + assert_eq!(FG_RED, seq_for_absent(FG_RED)); + assert_eq!("", seq_for("", not_a_tty.file())); + }); + + with_env(&[("WOAD_COLOUR", "never")], || { + assert!(!stream_supports_colour(not_a_tty.file())); + assert_eq!("", seq_for(FG_RED, not_a_tty.file())); + assert_eq!("", seq_for_absent(FG_RED)); + }); + } + + #[test] + fn TEST_STREAM_IS_TTY_AGREES_WITH_IsTerminal() { + let stdout = std::io::stdout(); + let stderr = std::io::stderr(); + let stdin = std::io::stdin(); + + assert_eq!(stdout.is_terminal(), stream_is_tty(&stdout)); + assert_eq!(stderr.is_terminal(), stream_is_tty(&stderr)); + assert_eq!(stdin.is_terminal(), stream_is_tty(&stdin)); + } + + #[cfg(unix)] + #[test] + fn TEST_fd_is_tty_AGREES_WITH_stream_is_tty() { + use std::os::fd::AsFd as _; + + let not_a_tty = TempNotATty::create(); + + assert_eq!(stream_is_tty(not_a_tty.file()), super::fd_is_tty(not_a_tty.file())); + + with_env(&[("WOAD_COLOUR", "always")], || { + assert!(super::fd_supports_colour(not_a_tty.file())); + assert_eq!(FG_RED, super::seq_for_fd(FG_RED, not_a_tty.file().as_fd())); + assert_eq!( + seq_for(FG_RED, ¬_a_tty.file().as_fd()), + super::seq_for_fd(FG_RED, not_a_tty.file().as_fd()), + ); + }); + + with_env(&[], || { + assert!(!super::fd_supports_colour(not_a_tty.file())); + assert_eq!("", super::seq_for_fd(FG_RED, not_a_tty.file())); + }); + } + + #[test] + fn TEST_CONSOLE_SUPPORTS_ANSI_IS_TRUE() { + assert!(console_supports_ansi()); + assert_eq!(console_supports_ansi(), console_supports_ansi()); + } + + #[test] + fn TEST_WOAD_SET_CONSOLE_MODE_DOES_NOT_MOVE_POLICY() { + with_env(&[("WOAD_SET_CONSOLE_MODE", "never")], || { + assert!(console_supports_ansi()); + assert_eq!(ColourPolicy::Auto, colour_policy()); + }); + + with_env(&[("WOAD_SET_CONSOLE_MODE", "banana")], || { + assert!(console_supports_ansi()); + assert_eq!(ColourPolicy::Auto, colour_policy()); + }); + } + + #[test] + fn TEST_OVERLAY_DOES_NOT_POISON_THE_CACHE() { + let before = colour_policy(); + + with_env(&[("WOAD_COLOUR", "always")], || { + assert_eq!(ColourPolicy::Force, colour_policy()); + }); + + assert_eq!(before, colour_policy()); + } + + #[test] + fn TEST_REFRESH_KEEPS_A_STABLE_POLICY() { + let first = colour_policy(); + + refresh(); + + assert_eq!(first, colour_policy()); + } + + #[test] + fn TEST_POLICY_NAMES() { + assert_eq!("auto", ColourPolicy::Auto.as_str()); + assert_eq!("force", ColourPolicy::Force.as_str()); + assert_eq!("inhibit", ColourPolicy::Inhibit.as_str()); + assert_eq!("auto", ColourPolicy::Auto.to_string()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 0da927b..6c1fa46 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,87 @@ //! Minimal ANSI terminal colour codes, for Rust. //! -//! **woad** provides the smallest useful set of fixed SGR sequences for -//! library authors. It is not a console or TUI framework. +//! **woad** provides fixed SGR sequences, and stream-conditional +//! sequences that emit only when the destination warrants colour. The +//! decision follows the same environment policy as **woad** for C. +//! It is not a console or TUI framework. +//! +//! Windows console-mode changes and handle currencies are not part of +//! this alpha. [`console_supports_ansi`] is always `true`, which is the +//! non-Windows answer. +//! +//! ``` +//! use woad::{reset_for, seq_for, FG_GREEN}; +//! +//! let stdout = std::io::stdout(); +//! +//! println!("{}ok{}", seq_for(FG_GREEN, &stdout), reset_for(&stdout),); +//! ``` + +mod dynamic; + +pub use dynamic::{ + bg_for, + blinking_for, + bold_for, + colour_policy, + console_supports_ansi, + fg_for, + refresh, + reset_for, + seq_for, + seq_for_absent, + seq_for_stream, + stream_is_tty, + stream_supports_colour, + supports_colour, + supports_colour_absent, + ColourPolicy, +}; + +#[cfg(unix)] +pub use dynamic::{ + fd_is_tty, + fd_supports_colour, + seq_for_fd, +}; + + +/// Major component of [`version`]. +pub const VERSION_MAJOR : u32 = 0; + +/// Minor component of [`version`]. +pub const VERSION_MINOR : u32 = 1; + +/// Patch component of [`version`]. +pub const VERSION_PATCH : u32 = 0; + +/// Release-status byte of [`version`]. +/// +/// `0x41` is alpha 1. `0xFF` is a numbered release. The layout matches +/// **woad** `WOAD_VER`: bits 24-31 major, 16-23 minor, 8-15 patch, and +/// 0-7 release status. +pub const VERSION_RELSTATUS : u32 = 0x41; + +/// Crate version, SemVer, including the prerelease label. +pub const VERSION : &str = "0.1.0-alpha1"; + +/// Packed **woad** version, in the `WOAD_VER` layout. +/// +/// ``` +/// assert_eq!(0x0001_0041, woad::version()); +/// assert_eq!(woad::VERSION, woad::version_string()); +/// ``` +pub const fn version() -> u32 { + (VERSION_MAJOR << 24) | (VERSION_MINOR << 16) | (VERSION_PATCH << 8) | VERSION_RELSTATUS +} -/// Crate version. -pub const VERSION : &str = "0.0.1"; +/// Version string, including the prerelease label. +/// +/// This is [`VERSION`]. The C `WOAD_VER_STRING` omits the prerelease +/// label; the low byte of [`version`] still carries it. +pub const fn version_string() -> &'static str { + VERSION +} // Reset @@ -12,6 +89,12 @@ pub const VERSION : &str = "0.0.1"; /// Reset all attributes. pub const RESET : &str = "\x1b[0m"; +/// Bold / increased intensity. +pub const BOLD : &str = "\x1b[1m"; + +/// Slow blink. +pub const BLINKING : &str = "\x1b[5m"; + // Foreground (standard) @@ -100,27 +183,50 @@ mod tests { #[test] fn TEST_VERSION() { - assert_eq!(crate::VERSION, "0.0.1"); + assert_eq!("0.1.0-alpha1", crate::VERSION); + assert_eq!(crate::VERSION, crate::version_string()); + assert_eq!(crate::VERSION, env!("CARGO_PKG_VERSION")); + } + + #[test] + fn TEST_VERSION_PACKED() { + assert_eq!(0, crate::VERSION_MAJOR); + assert_eq!(1, crate::VERSION_MINOR); + assert_eq!(0, crate::VERSION_PATCH); + assert_eq!(0x41, crate::VERSION_RELSTATUS); + assert_eq!(0x0001_0041, crate::version()); } #[test] fn TEST_RESET() { - assert_eq!(crate::RESET, "\x1b[0m"); + assert_eq!("\x1b[0m", crate::RESET); + } + + #[test] + fn TEST_BOLD() { + assert_eq!("\x1b[1m", crate::BOLD); + } + + #[test] + fn TEST_BLINKING() { + assert_eq!("\x1b[5m", crate::BLINKING); } #[test] fn TEST_FG_RED() { - assert_eq!(crate::FG_RED, "\x1b[31m"); + assert_eq!("\x1b[31m", crate::FG_RED); } #[test] fn TEST_BG_BLUE() { - assert_eq!(crate::BG_BLUE, "\x1b[44m"); + assert_eq!("\x1b[44m", crate::BG_BLUE); } #[test] fn TEST_CODES_ARE_CSI_SGR() { let codes = [ + crate::BLINKING, + crate::BOLD, crate::RESET, crate::FG_BLACK, crate::FG_BRIGHT_WHITE,