diff --git a/Cargo.toml b/Cargo.toml index 9004c95..476c6b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ features = ["Win32_Foundation", "Win32_System_Console"] [dev-dependencies] ansi_term = "0.12" insta = "1" +itertools = "0.14.0" rspec = "1" [lints.rust] diff --git a/src/color.rs b/src/color.rs index 5326124..6a97c59 100644 --- a/src/color.rs +++ b/src/color.rs @@ -1,4 +1,5 @@ -use std::{borrow::Cow, cmp, env, str::FromStr}; +use core::{cmp, fmt::Write}; +use std::{borrow::Cow, convert::Into, env, str::FromStr}; use Color::{ AnsiColor, Black, Blue, BrightBlack, BrightBlue, BrightCyan, BrightGreen, BrightMagenta, BrightRed, BrightWhite, BrightYellow, Cyan, Green, Magenta, Red, TrueColor, White, Yellow, @@ -34,58 +35,120 @@ fn truecolor_support() -> bool { #[allow(missing_docs)] impl Color { + /// Converts the foreground [`Color`] into a [`&'static str`](str) + /// + /// # Errors + /// + /// If the color is a `TrueColor` or `AnsiColor`, it will return [`NotStaticColor`] as an Error + const fn to_fg_static_str(self) -> Option<&'static str> { + match self { + Self::Black => Some("30"), + Self::Red => Some("31"), + Self::Green => Some("32"), + Self::Yellow => Some("33"), + Self::Blue => Some("34"), + Self::Magenta => Some("35"), + Self::Cyan => Some("36"), + Self::White => Some("37"), + Self::BrightBlack => Some("90"), + Self::BrightRed => Some("91"), + Self::BrightGreen => Some("92"), + Self::BrightYellow => Some("93"), + Self::BrightBlue => Some("94"), + Self::BrightMagenta => Some("95"), + Self::BrightCyan => Some("96"), + Self::BrightWhite => Some("97"), + Self::TrueColor { .. } | Self::AnsiColor(..) => None, + } + } + + /// Write [`to_fg_str`](Self::to_fg_str) to the given [`Formatter`](core::fmt::Formatter) without allocating + pub(crate) fn to_fg_write(self, f: &mut impl core::fmt::Write) -> Result<(), core::fmt::Error> { + match self.to_fg_static_str() { + Some(s) => f.write_str(s), + None => match self { + Black | Red | Green | Yellow | Blue | Magenta | Cyan | White | BrightBlack + | BrightRed | BrightGreen | BrightYellow | BrightBlue | BrightMagenta + | BrightCyan | BrightWhite => unreachable!(), + AnsiColor(code) => write!(f, "38;5;{code}"), + TrueColor { r, g, b } if !truecolor_support() => Self::TrueColor { r, g, b } + .closest_color_euclidean() + .to_fg_write(f), + TrueColor { r, g, b } => write!(f, "38;2;{r};{g};{b}"), + }, + } + } + #[must_use] pub fn to_fg_str(&self) -> Cow<'static, str> { - match *self { - Self::Black => "30".into(), - Self::Red => "31".into(), - Self::Green => "32".into(), - Self::Yellow => "33".into(), - Self::Blue => "34".into(), - Self::Magenta => "35".into(), - Self::Cyan => "36".into(), - Self::White => "37".into(), - Self::BrightBlack => "90".into(), - Self::BrightRed => "91".into(), - Self::BrightGreen => "92".into(), - Self::BrightYellow => "93".into(), - Self::BrightBlue => "94".into(), - Self::BrightMagenta => "95".into(), - Self::BrightCyan => "96".into(), - Self::BrightWhite => "97".into(), - Self::TrueColor { .. } if !truecolor_support() => { - self.closest_color_euclidean().to_fg_str() - } - Self::AnsiColor(code) => format!("38;5;{code}").into(), - Self::TrueColor { r, g, b } => format!("38;2;{r};{g};{b}").into(), + self.to_fg_static_str().map_or_else( + || { + // Not static, we can use the default formatter + let mut buf = String::new(); + // We write into a String, we do not expect an error. + let _ = self.to_fg_write(&mut buf); + buf.into() + }, + Into::into, + ) + } + + /// Converts the background [`Color`] into a [`&'static str`](str) + /// + /// # Errors + /// + /// If the color is a `TrueColor` or `AnsiColor`, it will return [`NotStaticColor`] as an Error + const fn to_bg_static_str(self) -> Option<&'static str> { + match self { + Self::Black => Some("40"), + Self::Red => Some("41"), + Self::Green => Some("42"), + Self::Yellow => Some("43"), + Self::Blue => Some("44"), + Self::Magenta => Some("45"), + Self::Cyan => Some("46"), + Self::White => Some("47"), + Self::BrightBlack => Some("100"), + Self::BrightRed => Some("101"), + Self::BrightGreen => Some("102"), + Self::BrightYellow => Some("103"), + Self::BrightBlue => Some("104"), + Self::BrightMagenta => Some("105"), + Self::BrightCyan => Some("106"), + Self::BrightWhite => Some("107"), + Self::TrueColor { .. } | Self::AnsiColor(..) => None, + } + } + + /// Write [`to_bg_str`](Self::to_fg_str) to the given [`Formatter`](core::fmt::Formatter) without allocating + pub(crate) fn to_bg_write(self, f: &mut impl Write) -> Result<(), core::fmt::Error> { + match self.to_bg_static_str() { + Some(s) => f.write_str(s), + None => match self { + Black | Red | Green | Yellow | Blue | Magenta | Cyan | White | BrightBlack + | BrightRed | BrightGreen | BrightYellow | BrightBlue | BrightMagenta + | BrightCyan | BrightWhite => unreachable!(), + AnsiColor(code) => write!(f, "48;5;{code}"), + TrueColor { r, g, b } if !truecolor_support() => Self::TrueColor { r, g, b } + .closest_color_euclidean() + .to_fg_write(f), + TrueColor { r, g, b } => write!(f, "48;2;{r};{g};{b}"), + }, } } #[must_use] pub fn to_bg_str(&self) -> Cow<'static, str> { - match *self { - Self::Black => "40".into(), - Self::Red => "41".into(), - Self::Green => "42".into(), - Self::Yellow => "43".into(), - Self::Blue => "44".into(), - Self::Magenta => "45".into(), - Self::Cyan => "46".into(), - Self::White => "47".into(), - Self::BrightBlack => "100".into(), - Self::BrightRed => "101".into(), - Self::BrightGreen => "102".into(), - Self::BrightYellow => "103".into(), - Self::BrightBlue => "104".into(), - Self::BrightMagenta => "105".into(), - Self::BrightCyan => "106".into(), - Self::BrightWhite => "107".into(), - Self::AnsiColor(code) => format!("48;5;{code}").into(), - Self::TrueColor { .. } if !truecolor_support() => { - self.closest_color_euclidean().to_bg_str() - } - Self::TrueColor { r, g, b } => format!("48;2;{r};{g};{b}").into(), - } + self.to_bg_static_str().map_or_else( + || { + // Not static, we can use the default formatter + let mut buf = String::new(); + // Writing into a String should be always valid. + let _ = self.to_bg_write(&mut buf); + buf.into() + }, + Into::into, + ) } /// Gets the closest plain color to the `TrueColor` @@ -96,7 +159,7 @@ impl Color { g: g1, b: b1, } => { - let colors = vec![ + let colors = [ Black, Red, Green, @@ -262,8 +325,77 @@ fn parse_hex(s: &str) -> Option { #[cfg(test)] mod tests { + pub use super::*; + #[test] + /// Test that `fmt` and `to_str` are the same + fn fmt_and_to_str_same() { + use core::fmt::Display; + use itertools::Itertools; + use Color::*; + + /// Helper structs to call the method + struct FmtFgWrapper(Color); + impl Display for FmtFgWrapper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.to_fg_write(f) + } + } + struct FmtBgWrapper(Color); + impl Display for FmtBgWrapper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.to_bg_write(f) + } + } + + // Actual test + + let colors = [ + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + White, + BrightBlack, + BrightRed, + BrightGreen, + BrightYellow, + BrightBlue, + BrightMagenta, + BrightCyan, + BrightWhite, + ] + .into_iter() + .chain( + // Iterator over TrueColors + // r g b + // 0 0 0 + // 0 0 1 + // 0 0 2 + // 0 0 3 + // 0 1 0 + // .. + // 3 3 3 + (0..4) + .combinations_with_replacement(3) + .map(|rgb| Color::TrueColor { + r: rgb[0], + g: rgb[1], + b: rgb[2], + }), + ) + .chain((0..4).map(Color::AnsiColor)); + + for color in colors { + assert_eq!(color.to_fg_str(), FmtFgWrapper(color).to_string()); + assert_eq!(color.to_bg_str(), FmtBgWrapper(color).to_string()); + } + } + mod from_str { pub use super::*; diff --git a/src/format.rs b/src/format.rs new file mode 100644 index 0000000..3bb543c --- /dev/null +++ b/src/format.rs @@ -0,0 +1,824 @@ +use core::fmt::{self, Write}; + +use crate::{style, Color, ColoredString}; + +/// Escape inner reset sequences, so we can represent [`ColoredString`] in a [`ColoredString`]. +fn escape_inner_reset_sequences( + input: &str, + fgcolor: Option, + bgcolor: Option, + style: style::Style, + is_plain: bool, + writer: &mut impl Write, +) -> std::fmt::Result { + const RESET: &str = "\x1B[0m"; + + if !ColoredString::has_colors() || is_plain { + return writer.write_str(input); + } + + // All reset markers in the input + let mut matches = input + .match_indices(RESET) + .map(|(idx, _)| idx + RESET.len()) + .peekable(); + if matches.peek().is_none() { + return writer.write_str(input); + } + + let mut start = 0; + for offset in matches { + // Write the text up to the end reset sequence + writer.write_str(&input[start..offset])?; + // Remember where the next text starts + start = offset; + // Write style + compute_style(fgcolor, bgcolor, style, is_plain, writer)?; + } + + // Write rest + writer.write_str(&input[start..])?; + + Ok(()) +} + +/// Compute the style, which needs to be inserted to color the [`ColoredString`]. +fn compute_style( + fgcolor: Option, + bgcolor: Option, + style: style::Style, + is_plain: bool, + writer: &mut impl Write, +) -> std::fmt::Result { + if !ColoredString::has_colors() || is_plain { + return Ok(()); + } + writer.write_str("\x1B[")?; + let mut has_wrote = if style == style::CLEAR { + false + } else { + style.private_write(writer)?; + true + }; + + if let Some(ref bgcolor) = bgcolor { + if has_wrote { + writer.write_char(';')?; + } + bgcolor.to_bg_write(writer)?; + has_wrote = true; + } + + if let Some(ref fgcolor) = fgcolor { + if has_wrote { + writer.write_char(';')?; + } + fgcolor.to_fg_write(writer)?; + } + + writer.write_char('m')?; + Ok(()) +} + +impl fmt::Display for ColoredString { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + // No color to format + if !Self::has_colors() || self.is_plain() { + return ::fmt(&self.input, f); + } + + let (actual_input, padding) = calculate_precision_and_padding(self, f); + + // Do the actual formatting + // XXX: see tests. Useful when nesting colored strings + compute_style(self.fgcolor, self.bgcolor, self.style, self.is_plain(), f)?; + escape_inner_reset_sequences( + actual_input, + self.fgcolor, + self.bgcolor, + self.style, + self.is_plain(), + f, + )?; + f.write_str("\x1B[0m")?; + + // Add padding + write_padding(f, padding)?; + + Ok(()) + } +} + +fn write_padding(f: &mut std::fmt::Formatter<'_>, padding: usize) -> Result<(), std::fmt::Error> { + for _ in 0..padding { + f.write_char(f.fill())?; + } + Ok(()) +} + +/// Calculates how long the input should be with the specified precision and how much padding is needed. +fn calculate_precision_and_padding<'a>( + colored_string: &'a str, + f: &std::fmt::Formatter<'_>, +) -> (&'a str, usize) { + let mut input = colored_string; + let mut padding = 0; + + // Calculate padding and input + if let Some(precision) = f.precision() { + let mut iter = input.char_indices(); + + // FIXME + // feature(iter_advance_by) is not stable + // CharIndices::offset is MSRV 1.82.0 + // the std impl uses this methods in it is a lot nicer. + let mut count = 0; + let mut offset = 0; + for _ in 0..precision { + if let Some((i, c)) = iter.next() { + offset = i + c.len_utf8(); + count += 1; + } + } + + // Short our input to the precision + input = &input[..offset]; + + // Calculate remaining padding in respect to characters and not bytes + if let Some(width) = f.width() { + padding = width.saturating_sub(count); + } + } else if let Some(width) = f.width() { + // Calculate padding in respect to characters and not bytes + padding = width.saturating_sub(colored_string.chars().take(width).count()); + } + (input, padding) +} + +/// Test the formatting +/// +/// Formatting *must* respect padding +/// +/// The added normal str in the input is for comparison to rust-std +#[cfg(test)] +mod tests { + use super::*; + mod formatting { + use core::fmt::Display; + + use crate::Colorize; + + #[test] + /// Insert padding on empty string + fn empty_padding() { + let inputs: &[&dyn Display] = &[&"".custom_color((126, 194, 218)), &"".blue(), &""]; + + for input in inputs { + assert_eq!( + format!("{input:40}") + .chars() + .filter(|c| c.is_whitespace()) + .count(), + 40 + ); + } + } + #[test] + /// Should do not any padding + fn no_padding() { + let inputs: &[&dyn Display] = &[&"".custom_color((126, 194, 218)), &"".blue(), &""]; + for input in inputs { + assert_eq!( + format!("{input}") + .chars() + .filter(|c| c.is_whitespace()) + .count(), + 0 + ); + } + } + + #[test] + /// Should do not any padding because it is less or equal the chars + fn not_enough_for_padding() { + let inputs: &[&dyn Display] = + &[&"πŸ¦€πŸ¦€".custom_color((126, 194, 218)), &"CS".blue(), &"CS"]; + for input in inputs { + assert_eq!( + format!("{input:0}") + .chars() + .filter(|c| c.is_whitespace()) + .count(), + 0 + ); + } + for input in inputs { + assert_eq!( + format!("{input:1}") + .chars() + .filter(|c| c.is_whitespace()) + .count(), + 0 + ); + } + for input in inputs { + assert_eq!( + format!("{input:2}") + .chars() + .filter(|c| c.is_whitespace()) + .count(), + 0 + ); + } + } + + #[test] + /// Should do padding whie having input + fn padding_with_input() { + let inputs: &[&dyn Display] = + &[&"πŸ¦€πŸ¦€".custom_color((126, 194, 218)), &"CS".blue(), &"CS"]; + for input in inputs { + assert_eq!( + format!("{input:3}") + .chars() + .filter(|c| c.is_whitespace()) + .count(), + 1 + ); + assert!(format!("{input:3}").contains(&input.to_string())); + + assert_eq!( + format!("{input:4}") + .chars() + .filter(|c| c.is_whitespace()) + .count(), + 2 + ); + assert!(format!("{input:4}").contains(&input.to_string())); + } + } + + #[test] + /// Precision should shorten the input + fn precision_less() { + let inputs: &[&dyn Display] = &[ + &"πŸ¦€πŸ¦€".custom_color((126, 194, 218)), + &"CC".blue(), + &"CC", + &"ColoredString".blue(), + ]; + for input in inputs { + assert_eq!( + format!("{input:.1}") + .chars() + .filter(|c| *c == 'πŸ¦€' || *c == 'C') + .count(), + 1, + "input: {input}" + ); + assert!(!format!("{input:.1}").contains(&input.to_string())); + } + } + + #[test] + /// Presision should not shorten the input + fn precision_eq() { + let inputs: &[&dyn Display] = + &[&"πŸ¦€πŸ¦€".custom_color((126, 194, 218)), &"CC".blue(), &"CC"]; + for input in inputs { + assert_eq!( + format!("{input:.2}") + .chars() + .filter(|c| *c == 'πŸ¦€' || *c == 'C') + .count(), + 2, + "input: {input}" + ); + assert!(format!("{input:.2}").contains(&input.to_string())); + } + } + + #[test] + /// Presision should not shorten the input + fn precision_more() { + let inputs: &[&dyn Display] = + &[&"πŸ¦€πŸ¦€".custom_color((126, 194, 218)), &"CC".blue(), &"CC"]; + for input in inputs { + assert_eq!( + format!("{input:.100}") + .chars() + .filter(|c| *c == 'πŸ¦€' || *c == 'C') + .count(), + 2, + "input: {input}" + ); + assert!(format!("{input:.100}").contains(&input.to_string())); + } + } + + #[test] + /// Testing padding and precision together + fn precision_padding() { + let inputs: &[&dyn Display] = + &[&"πŸ¦€πŸ¦€".custom_color((126, 194, 218)), &"CC".blue(), &"CC"]; + for input in inputs { + // precision less, padding more + assert_eq!( + format!("{input:40.1}") + .chars() + .filter(|c| *c == 'πŸ¦€' || *c == 'C') + .count(), + 1, + "input: {input}" + ); + assert_eq!( + format!("{input:40.1}") + .chars() + .filter(|c| c.is_whitespace()) + .count(), + 39, + "input: {input}" + ); + // precision less, padding less_or_equal + assert_eq!( + format!("{input:1.1}") + .chars() + .filter(|c| *c == 'πŸ¦€' || *c == 'C') + .count(), + 1, + "input: {input}" + ); + assert_eq!( + format!("{input:1.1}") + .chars() + .filter(|c| c.is_whitespace()) + .count(), + 0, + "input: {input}" + ); + assert_eq!( + format!("{input:0.1}") + .chars() + .filter(|c| *c == 'πŸ¦€' || *c == 'C') + .count(), + 1, + "input: {input}" + ); + assert_eq!( + format!("{input:0.1}") + .chars() + .filter(|c| c.is_whitespace()) + .count(), + 0, + "input: {input}" + ); + assert!(format!("{input:.100}").contains(&input.to_string())); + // precision eq, padding more + assert_eq!( + format!("{input:40.2}") + .chars() + .filter(|c| *c == 'πŸ¦€' || *c == 'C') + .count(), + 2, + "input: {input}" + ); + assert_eq!( + format!("{input:40.2}") + .chars() + .filter(|c| c.is_whitespace()) + .count(), + 38, + "input: {input}" + ); + // precision eq, padding less_or_equal + assert_eq!( + format!("{input:1.2}") + .chars() + .filter(|c| *c == 'πŸ¦€' || *c == 'C') + .count(), + 2, + "input: {input}" + ); + assert_eq!( + format!("{input:1.2}") + .chars() + .filter(|c| c.is_whitespace()) + .count(), + 0, + "input: {input}" + ); + assert!(format!("{input:1.2}").contains(&input.to_string())); + assert_eq!( + format!("{input:0.2}") + .chars() + .filter(|c| *c == 'πŸ¦€' || *c == 'C') + .count(), + 2, + "input: {input}" + ); + assert_eq!( + format!("{input:0.2}") + .chars() + .filter(|c| c.is_whitespace()) + .count(), + 0, + "input: {input}" + ); + assert!(format!("{input:0.2}").contains(&input.to_string())); + + // precision more, padding more + assert_eq!( + format!("{input:40.4}") + .chars() + .filter(|c| *c == 'πŸ¦€' || *c == 'C') + .count(), + 2, + "input: {input}" + ); + assert_eq!( + format!("{input:40.4}") + .chars() + .filter(|c| c.is_whitespace()) + .count(), + 38, + "input: {input}" + ); + // precision eq, padding less_or_equal + assert_eq!( + format!("{input:1.4}") + .chars() + .filter(|c| *c == 'πŸ¦€' || *c == 'C') + .count(), + 2, + "input: {input}" + ); + assert_eq!( + format!("{input:1.4}") + .chars() + .filter(|c| c.is_whitespace()) + .count(), + 0, + "input: {input}" + ); + assert!(format!("{input:1.4}").contains(&input.to_string())); + assert_eq!( + format!("{input:0.4}") + .chars() + .filter(|c| *c == 'πŸ¦€' || *c == 'C') + .count(), + 2, + "input: {input}" + ); + assert_eq!( + format!("{input:0.4}") + .chars() + .filter(|c| c.is_whitespace()) + .count(), + 0, + "input: {input}" + ); + assert!(format!("{input:0.4}").contains(&input.to_string())); + } + } + } + + mod compute_style { + use crate::{format::tests::compute_style, ColoredString, Colorize}; + + /// Into is not dyn compatible + trait IntoCS { + fn into(&self) -> ColoredString; + } + impl IntoCS for &str { + fn into(&self) -> ColoredString { + ColoredString::from(*self) + } + } + impl IntoCS for ColoredString { + fn into(&self) -> ColoredString { + self.clone() + } + } + + /// Helper to use a Colorize function on str or `ColoredString` + type ColorMapper = &'static dyn Fn(&dyn IntoCS) -> ColoredString; + macro_rules! helper_color { + ($f:ident, $r:expr) => { + (&(|i| Colorize::$f(i.into())), $r) + }; + } + /// Helper to use a truecolor method of Colorize on str or `ColoredString` + macro_rules! helper_truecolor { + ($f:ident, $r:expr, $g:expr,$b:expr,$res:expr) => { + ( + &(|i| Colorize::$f(i.into(), $r, $g, $b)), + concat!( + $res, + stringify!($r), + ";", + stringify!($g), + ";", + stringify!($b) + ), + ) + }; + } + /// The input of a `ColoredString` + const INPUTS: &[&str; 5] = &["πŸ¦€πŸ¦€", "CS", "CC", "ColoredString", "πŸ¦€ColoredStringπŸ¦€CC"]; + /// All background function mappings to their expected result + const BG_MAPPINGS: &[(ColorMapper, &str)] = &[ + helper_color!(on_black, "40"), + helper_color!(on_red, "41"), + helper_color!(on_green, "42"), + helper_color!(on_yellow, "43"), + helper_color!(on_blue, "44"), + helper_color!(on_magenta, "45"), + helper_color!(on_cyan, "46"), + helper_color!(on_white, "47"), + helper_color!(on_bright_black, "100"), + helper_color!(on_bright_red, "101"), + helper_color!(on_bright_green, "102"), + helper_color!(on_bright_yellow, "103"), + helper_color!(on_bright_blue, "104"), + helper_color!(on_bright_magenta, "105"), + helper_color!(on_bright_cyan, "106"), + helper_color!(on_bright_white, "107"), + helper_truecolor!(on_truecolor, 0, 0, 0, "48;2;"), + helper_truecolor!(on_truecolor, 128, 0, 0, "48;2;"), + helper_truecolor!(on_truecolor, 0, 128, 0, "48;2;"), + helper_truecolor!(on_truecolor, 0, 0, 128, "48;2;"), + helper_truecolor!(on_truecolor, 128, 127, 0, "48;2;"), + helper_truecolor!(on_truecolor, 128, 0, 127, "48;2;"), + helper_truecolor!(on_truecolor, 0, 128, 127, "48;2;"), + helper_truecolor!(on_truecolor, 126, 128, 127, "48;2;"), + ]; + /// All foreground function mappings to their expected result + const FG_MAPPINGS: &[(ColorMapper, &str)] = &[ + helper_color!(black, "30"), + helper_color!(red, "31"), + helper_color!(green, "32"), + helper_color!(yellow, "33"), + helper_color!(blue, "34"), + helper_color!(magenta, "35"), + helper_color!(cyan, "36"), + helper_color!(white, "37"), + helper_color!(bright_black, "90"), + helper_color!(bright_red, "91"), + helper_color!(bright_green, "92"), + helper_color!(bright_yellow, "93"), + helper_color!(bright_blue, "94"), + helper_color!(bright_magenta, "95"), + helper_color!(bright_cyan, "96"), + helper_color!(bright_white, "97"), + helper_truecolor!(truecolor, 0, 0, 0, "38;2;"), + helper_truecolor!(truecolor, 128, 0, 0, "38;2;"), + helper_truecolor!(truecolor, 0, 128, 0, "38;2;"), + helper_truecolor!(truecolor, 0, 0, 128, "38;2;"), + helper_truecolor!(truecolor, 128, 127, 0, "38;2;"), + helper_truecolor!(truecolor, 128, 0, 127, "38;2;"), + helper_truecolor!(truecolor, 0, 128, 127, "38;2;"), + helper_truecolor!(truecolor, 126, 128, 127, "38;2;"), + ]; + + const STYLE_MAPPINGS: &[(ColorMapper, &str)] = &[ + helper_color!(bold, "1"), + helper_color!(dimmed, "2"), + helper_color!(italic, "3"), + helper_color!(underline, "4"), + helper_color!(blink, "5"), + helper_color!(reversed, "7"), + helper_color!(hidden, "8"), + helper_color!(strikethrough, "9"), + ]; + + #[test] + /// Check for clear + fn clear() { + for input in INPUTS { + assert_eq!(&format!("{}", input.clear()), input); + } + } + + fn compute_style_colored(colored: &ColoredString) -> String { + let mut buf = String::new(); + compute_style( + colored.fgcolor, + colored.bgcolor, + colored.style, + colored.is_plain(), + &mut buf, + ) + .unwrap(); + buf + } + + #[test] + #[cfg_attr(feature = "no-color", ignore)] + /// Check for only a set foreground color + fn simple_fg() { + for input in INPUTS { + for (fun, res) in FG_MAPPINGS { + assert_eq!( + compute_style_colored(&fun(input)), + format!("\x1B[{res}m"), + "Input: '{input}'" + ); + } + } + } + + #[test] + #[cfg_attr(feature = "no-color", ignore)] + /// Check for only a set background color + fn simple_bg() { + for input in INPUTS { + for (fun, res) in BG_MAPPINGS { + assert_eq!( + compute_style_colored(&fun(input)), + format!("\x1B[{res}m"), + "Input: '{input}'" + ); + } + } + } + #[test] + #[cfg_attr(feature = "no-color", ignore)] + /// Check for only a set background AND foreground color + fn fg_and_bg() { + for input in INPUTS { + for (fg_fun, fg_res) in FG_MAPPINGS { + for (bg_fun, bg_res) in BG_MAPPINGS { + let tmp = bg_fun(input); + assert_eq!( + compute_style_colored(&fg_fun(&tmp)), + format!("\x1B[{bg_res};{fg_res}m"), + "Input: '{input}'" + ); + } + } + } + } + + #[test] + #[cfg_attr(feature = "no-color", ignore)] + fn simple_style() { + for input in INPUTS { + for (fun, res) in STYLE_MAPPINGS { + assert_eq!( + compute_style_colored(&fun(input)), + format!("\x1B[{res}m"), + "Input: '{input}'" + ); + } + } + } + #[test] + #[cfg_attr(feature = "no-color", ignore)] + #[ignore = "Not yet implemented"] + fn multiple_style() { + todo!() + } + #[test] + #[cfg_attr(feature = "no-color", ignore)] + fn style_fg() { + for input in INPUTS { + for (fg_fun, fg_res) in FG_MAPPINGS { + for (style_fun, style_res) in STYLE_MAPPINGS { + let tmp = style_fun(input); + assert_eq!( + compute_style_colored(&fg_fun(&tmp)), + format!("\x1B[{style_res};{fg_res}m"), + "Input: '{input}'" + ); + } + } + } + } + #[test] + #[cfg_attr(feature = "no-color", ignore)] + fn style_bg() { + for input in INPUTS { + for (bg_fun, bg_res) in BG_MAPPINGS { + for (style_fun, style_res) in STYLE_MAPPINGS { + let tmp = style_fun(input); + assert_eq!( + compute_style_colored(&bg_fun(&tmp)), + format!("\x1B[{style_res};{bg_res}m"), + "Input: '{input}'" + ); + } + } + } + } + #[test] + #[cfg_attr(feature = "no-color", ignore)] + fn style_fg_bg() { + for input in INPUTS { + for (fg_fun, fg_res) in FG_MAPPINGS { + for (bg_fun, bg_res) in BG_MAPPINGS { + for (style_fun, style_res) in STYLE_MAPPINGS { + let tmp = style_fun(input); + let tmp = fg_fun(&tmp); + assert_eq!( + compute_style_colored(&bg_fun(&tmp)), + format!("\x1B[{style_res};{bg_res};{fg_res}m"), + "Input: '{input}'" + ); + } + } + } + } + } + #[test] + #[cfg_attr(feature = "no-color", ignore)] + #[ignore = "Not yet implemented"] + fn multiple_style_fg() { + todo!() + } + #[test] + #[cfg_attr(feature = "no-color", ignore)] + #[ignore = "Not yet implemented"] + fn multiple_style_bg() { + todo!() + } + #[test] + #[cfg_attr(feature = "no-color", ignore)] + #[ignore = "Not yet implemented"] + fn multiple_style_fg_bg() { + todo!() + } + } + + mod escape_rest { + use crate::{format::escape_inner_reset_sequences, ColoredString, Colorize}; + + fn escape_inner_reset_sequences_colored(colored: &ColoredString) -> String { + let mut buf = String::new(); + escape_inner_reset_sequences( + &colored.input, + colored.fgcolor, + colored.bgcolor, + colored.style, + colored.is_plain(), + &mut buf, + ) + .unwrap(); + buf + } + + #[test] + fn do_nothing_on_empty_strings() { + let style = ColoredString::default(); + let expected = String::new(); + + let output = escape_inner_reset_sequences_colored(&style); + + assert_eq!(expected, output); + } + + #[test] + fn do_nothing_on_string_with_no_reset() { + let style = ColoredString { + input: String::from("hello world !"), + ..ColoredString::default() + }; + + let expected = String::from("hello world !"); + let output = escape_inner_reset_sequences_colored(&style); + + assert_eq!(expected, output); + } + + #[cfg_attr(feature = "no-color", ignore)] + #[test] + fn replace_inner_reset_sequence_with_current_style() { + let input = format!("start {} end", String::from("hello world !").red()); + let style = input.blue(); + + let output = escape_inner_reset_sequences_colored(&style); + let blue = "\x1B[34m"; + let red = "\x1B[31m"; + let reset = "\x1B[0m"; + let expected = format!("start {red}hello world !{reset}{blue} end"); + assert_eq!(expected, output); + } + + #[cfg_attr(feature = "no-color", ignore)] + #[test] + fn replace_multiple_inner_reset_sequences_with_current_style() { + let italic_str = String::from("yo").italic(); + let input = format!("start 1:{italic_str} 2:{italic_str} 3:{italic_str} end"); + let style = input.blue(); + + let output = escape_inner_reset_sequences_colored(&style); + let blue = "\x1B[34m"; + let italic = "\x1B[3m"; + let reset = "\x1B[0m"; + let expected = format!( + "start 1:{italic}yo{reset}{blue} 2:{italic}yo{reset}{blue} 3:{italic}yo{reset}{blue} end" + ); + + assert_eq!(expected, output, "first: {expected}\nsecond: {output}"); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index d6b1ddb..c51874a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,6 +35,7 @@ extern crate rspec; mod color; pub mod control; mod error; +mod format; mod style; pub use self::customcolors::CustomColor; @@ -45,9 +46,7 @@ pub mod customcolors; pub use color::*; use std::{ - borrow::Cow, error::Error, - fmt, ops::{Deref, DerefMut}, }; @@ -513,74 +512,6 @@ impl ColoredString { fn has_colors() -> bool { false } - - fn compute_style(&self) -> String { - if !Self::has_colors() || self.is_plain() { - return String::new(); - } - - let mut res = String::from("\x1B["); - let mut has_wrote = if self.style == style::CLEAR { - false - } else { - res.push_str(&self.style.to_str()); - true - }; - - if let Some(ref bgcolor) = self.bgcolor { - if has_wrote { - res.push(';'); - } - - res.push_str(&bgcolor.to_bg_str()); - has_wrote = true; - } - - if let Some(ref fgcolor) = self.fgcolor { - if has_wrote { - res.push(';'); - } - - res.push_str(&fgcolor.to_fg_str()); - } - - res.push('m'); - res - } - - fn escape_inner_reset_sequences(&self) -> Cow<'_, str> { - if !Self::has_colors() || self.is_plain() { - return self.input.as_str().into(); - } - - // TODO: BoyScoutRule - let reset = "\x1B[0m"; - let style = self.compute_style(); - let matches: Vec = self - .input - .match_indices(reset) - .map(|(idx, _)| idx) - .collect(); - if matches.is_empty() { - return self.input.as_str().into(); - } - - let mut input = self.input.clone(); - input.reserve(matches.len() * style.len()); - - for (idx_in_matches, offset) in matches.into_iter().enumerate() { - // shift the offset to the end of the reset sequence and take in account - // the number of matches we have escaped (which shift the index to insert) - let mut offset = offset + reset.len() + idx_in_matches * style.len(); - - for cchar in style.chars() { - input.insert(offset, cchar); - offset += 1; - } - } - - input.into() - } } impl Deref for ColoredString { @@ -726,22 +657,6 @@ impl Colorize for &str { } } -impl fmt::Display for ColoredString { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - if !Self::has_colors() || self.is_plain() { - return ::fmt(&self.input, f); - } - - // XXX: see tests. Useful when nesting colored strings - let escaped_input = self.escape_inner_reset_sequences(); - - f.write_str(&self.compute_style())?; - escaped_input.fmt(f)?; - f.write_str("\x1B[0m")?; - Ok(()) - } -} - impl From for Box { fn from(cs: ColoredString) -> Self { Box::from(error::ColoredStringError(cs)) @@ -753,17 +668,6 @@ mod tests { use super::*; use std::{error::Error, fmt::Write}; - #[test] - fn formatting() { - // respect the formatting. Escape sequence add some padding so >= 40 - assert!(format!("{:40}", "".blue()).len() >= 40); - // both should be truncated to 1 char before coloring - assert_eq!( - format!("{:1.1}", "toto".blue()).len(), - format!("{:1.1}", "1".blue()).len() - ); - } - #[test] fn it_works() -> Result<(), Box> { let mut buf = String::new(); @@ -812,165 +716,40 @@ mod tests { Ok(()) } - #[test] - fn compute_style_empty_string() { - assert_eq!("", "".clear().compute_style()); - } - - #[cfg_attr(feature = "no-color", ignore)] - #[test] - fn compute_style_simple_fg_blue() { - let blue = "\x1B[34m"; - - assert_eq!(blue, "".blue().compute_style()); - } - - #[cfg_attr(feature = "no-color", ignore)] - #[test] - fn compute_style_simple_bg_blue() { - let on_blue = "\x1B[44m"; - - assert_eq!(on_blue, "".on_blue().compute_style()); - } - - #[cfg_attr(feature = "no-color", ignore)] - #[test] - fn compute_style_blue_on_blue() { - let blue_on_blue = "\x1B[44;34m"; - - assert_eq!(blue_on_blue, "".blue().on_blue().compute_style()); - } - - #[cfg_attr(feature = "no-color", ignore)] - #[test] - fn compute_style_simple_fg_bright_blue() { - let blue = "\x1B[94m"; - - assert_eq!(blue, "".bright_blue().compute_style()); - } - - #[cfg_attr(feature = "no-color", ignore)] - #[test] - fn compute_style_simple_bg_bright_blue() { - let on_blue = "\x1B[104m"; - - assert_eq!(on_blue, "".on_bright_blue().compute_style()); - } - - #[cfg_attr(feature = "no-color", ignore)] - #[test] - fn compute_style_bright_blue_on_bright_blue() { - let blue_on_blue = "\x1B[104;94m"; - - assert_eq!( - blue_on_blue, - "".bright_blue().on_bright_blue().compute_style() - ); - } - - #[cfg_attr(feature = "no-color", ignore)] - #[test] - fn compute_style_simple_bold() { - let bold = "\x1B[1m"; - - assert_eq!(bold, "".bold().compute_style()); - } - - #[cfg_attr(feature = "no-color", ignore)] - #[test] - fn compute_style_blue_bold() { - let blue_bold = "\x1B[1;34m"; - - assert_eq!(blue_bold, "".blue().bold().compute_style()); - } - - #[cfg_attr(feature = "no-color", ignore)] - #[test] - fn compute_style_blue_bold_on_blue() { - let blue_bold_on_blue = "\x1B[1;44;34m"; - - assert_eq!( - blue_bold_on_blue, - "".blue().bold().on_blue().compute_style() - ); - } - - #[test] - fn escape_reset_sequence_spec_should_do_nothing_on_empty_strings() { - let style = ColoredString::default(); - let expected = String::new(); - - let output = style.escape_inner_reset_sequences(); - - assert_eq!(expected, output); - } - - #[test] - fn escape_reset_sequence_spec_should_do_nothing_on_string_with_no_reset() { - let style = ColoredString { - input: String::from("hello world !"), - ..ColoredString::default() - }; - - let expected = String::from("hello world !"); - let output = style.escape_inner_reset_sequences(); - - assert_eq!(expected, output); - } - - #[cfg_attr(feature = "no-color", ignore)] - #[test] - fn escape_reset_sequence_spec_should_replace_inner_reset_sequence_with_current_style() { - let input = format!("start {} end", String::from("hello world !").red()); - let style = input.blue(); - - let output = style.escape_inner_reset_sequences(); - let blue = "\x1B[34m"; - let red = "\x1B[31m"; - let reset = "\x1B[0m"; - let expected = format!("start {red}hello world !{reset}{blue} end"); - assert_eq!(expected, output); - } - - #[cfg_attr(feature = "no-color", ignore)] - #[test] - fn escape_reset_sequence_spec_should_replace_multiple_inner_reset_sequences_with_current_style() - { - let italic_str = String::from("yo").italic(); - let input = format!("start 1:{italic_str} 2:{italic_str} 3:{italic_str} end"); - let style = input.blue(); - - let output = style.escape_inner_reset_sequences(); - let blue = "\x1B[34m"; - let italic = "\x1B[3m"; - let reset = "\x1B[0m"; - let expected = format!( - "start 1:{italic}yo{reset}{blue} 2:{italic}yo{reset}{blue} 3:{italic}yo{reset}{blue} end" - ); - - println!("first: {expected}\nsecond: {output}"); - - assert_eq!(expected, output); - } - #[test] fn color_fn() { - assert_eq!("blue".blue(), "blue".color("blue")); - } - - #[test] - fn on_color_fn() { - assert_eq!("blue".on_blue(), "blue".on_color("blue")); - } - - #[test] - fn bright_color_fn() { - assert_eq!("blue".bright_blue(), "blue".color("bright blue")); - } - - #[test] - fn on_bright_color_fn() { - assert_eq!("blue".on_bright_blue(), "blue".on_color("bright blue")); + macro_rules! helper { + ($f:ident) => { + ("blue".$f(), "blue".color(stringify!($f))) + }; + } + macro_rules! helper_bright { + ($f:ident, $e:expr) => { + ("blue".$f(), "blue".color(concat!("bright ", $e))) + }; + } + let mappings = &[ + helper!(black), + helper!(red), + helper!(green), + helper!(yellow), + helper!(blue), + helper!(magenta), + helper!(cyan), + helper!(white), + helper_bright!(bright_black, "black"), + helper_bright!(bright_red, "red"), + helper_bright!(bright_green, "green"), + helper_bright!(bright_yellow, "yellow"), + helper_bright!(bright_blue, "blue"), + helper_bright!(bright_magenta, "magenta"), + helper_bright!(bright_cyan, "cyan"), + helper_bright!(bright_white, "white"), + ]; + + for (l, r) in mappings { + assert_eq!(l, r); + } } #[test] diff --git a/src/style.rs b/src/style.rs index 557dce0..2ca50ea 100644 --- a/src/style.rs +++ b/src/style.rs @@ -207,21 +207,21 @@ pub enum Styles { } impl Styles { - fn to_str<'a>(self) -> &'a str { + fn private_fmt(self, f: &mut dyn core::fmt::Write) -> std::fmt::Result { match self { - Self::Clear => "", // unreachable, but we don't want to panic - Self::Bold => "1", - Self::Dimmed => "2", - Self::Italic => "3", - Self::Underline => "4", - Self::Blink => "5", - Self::Reversed => "7", - Self::Hidden => "8", - Self::Strikethrough => "9", + Self::Clear => Ok(()), // unreachable, but we don't want to panic + Self::Bold => f.write_char('1'), + Self::Dimmed => f.write_char('2'), + Self::Italic => f.write_char('3'), + Self::Underline => f.write_char('4'), + Self::Blink => f.write_char('5'), + Self::Reversed => f.write_char('7'), + Self::Hidden => f.write_char('8'), + Self::Strikethrough => f.write_char('9'), } } - fn to_u8(self) -> u8 { + fn to_u8_mask(self) -> u8 { match self { Self::Clear => CLEARV, Self::Bold => BOLD, @@ -235,21 +235,11 @@ impl Styles { } } - fn from_u8(u: u8) -> Option> { - if u == CLEARV { - return None; - } - - let res: Vec = STYLES + fn from_u8(u: u8) -> impl Iterator + Clone { + STYLES .iter() - .filter(|&(mask, _)| (0 != (u & mask))) + .filter(move |&(mask, _)| 0 != (u & mask)) .map(|&(_, value)| value) - .collect(); - if res.is_empty() { - None - } else { - Some(res) - } } } @@ -257,7 +247,7 @@ impl BitAnd for Styles { type Output = Style; fn bitand(self, rhs: Self) -> Self::Output { - Style(self.to_u8() & rhs.to_u8()) + Style(self.to_u8_mask() & rhs.to_u8_mask()) } } @@ -267,7 +257,7 @@ impl BitAnd