diff --git a/Cargo.toml b/Cargo.toml index 8c35489..c98ca4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ readme = "README.md" repository = "https://github.com/sharkdp/hexyl" version = "0.16.0" edition = "2021" -rust-version = "1.74" +rust-version = "1.80" [dependencies] anyhow = "1.0" diff --git a/README.md b/README.md index 66d631c..6d54cf3 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,22 @@ scoop install hexyl x env use hexyl ``` +## Configuration + +`hexyl` colors can be configured via environment variables. The variables used are as follows: + + * `HEXYL_COLOR_ASCII_PRINTABLE`: Any non-whitespace printable ASCII character + * `HEXYL_COLOR_ASCII_WHITESPACE`: Whitespace such as space or newline (only visible in middle panel with byte values) + * `HEXYL_COLOR_ASCII_OTHER`: Any other ASCII character (< `0x80`) besides null + * `HEXYL_COLOR_NULL`: The null byte (`0x00`) + * `HEXYL_COLOR_NONASCII`: Any non-ASCII byte (> `0x7F`) + * `HEXYL_COLOR_OFFSET`: The lefthand file offset + +The colors can be any of the 8 standard terminal colors: `black`, `blue`, `cyan`, `green`, `magenta`, `red`, +`yellow` and `white`. The "bright" variants are also supported (e.g., `bright blue`). Additionally, you can use +the RGB hex format, `#abcdef`. For example, `HEXYL_COLOR_ASCII_PRINTABLE=blue HEXYL_COLOR_ASCII_WHITESPACE="bright green" +HEXYL_COLOR_ASCII_OTHER="#ff7f99"`. + ## License Licensed under either of diff --git a/doc/hexyl.1.md b/doc/hexyl.1.md index 09cbb4f..08730ee 100644 --- a/doc/hexyl.1.md +++ b/doc/hexyl.1.md @@ -106,6 +106,22 @@ _FILE_ **-V**, **\--version** : Prints version information. +# ENVIRONMENT VARIABLES + +**hexyl** colors can be configured via environment variables. The variables used are as follows: + +: - **HEXYL_COLOR_ASCII_PRINTABLE**: Any non-whitespace printable ASCII character + - **HEXYL_COLOR_ASCII_WHITESPACE**: Whitespace such as space or newline (only visible in middle panel with byte values) + - **HEXYL_COLOR_ASCII_OTHER**: Any other ASCII character (< **0x80**) besides null + - **HEXYL_COLOR_NULL**: The null byte (**0x00**) + - **HEXYL_COLOR_NONASCII**: Any non-ASCII byte (> **0x7F**) + - **HEXYL_COLOR_OFFSET**: The lefthand file offset + +The colors can be any of the 8 standard terminal colors: **black**, **blue**, **cyan**, **green**, **magenta**, **red**, +**yellow** and **white**. The "bright" variants are also supported (e.g., **bright blue**). Additionally, you can use +the RGB hex format, **#abcdef**. For example, **HEXYL_COLOR_ASCII_PRINTABLE=blue HEXYL_COLOR_ASCII_WHITESPACE="bright green" +HEXYL_COLOR_ASCII_OTHER="#ff7f99"**. + # NOTES Source repository: diff --git a/src/colors.rs b/src/colors.rs index b76e641..a56e0b0 100644 --- a/src/colors.rs +++ b/src/colors.rs @@ -1,12 +1,39 @@ -use owo_colors::{colors, Color}; +use owo_colors::{colors, AnsiColors, Color, DynColors, OwoColorize}; +use std::str::FromStr; +use std::sync::LazyLock; -pub const COLOR_NULL: &[u8] = colors::BrightBlack::ANSI_FG.as_bytes(); -pub const COLOR_OFFSET: &[u8] = colors::BrightBlack::ANSI_FG.as_bytes(); -pub const COLOR_ASCII_PRINTABLE: &[u8] = colors::Cyan::ANSI_FG.as_bytes(); -pub const COLOR_ASCII_WHITESPACE: &[u8] = colors::Green::ANSI_FG.as_bytes(); -pub const COLOR_ASCII_OTHER: &[u8] = colors::Green::ANSI_FG.as_bytes(); -pub const COLOR_NONASCII: &[u8] = colors::Yellow::ANSI_FG.as_bytes(); -pub const COLOR_RESET: &[u8] = colors::Default::ANSI_FG.as_bytes(); +pub static COLOR_NULL: LazyLock = + LazyLock::new(|| init_color("NULL", AnsiColors::BrightBlack)); +pub static COLOR_OFFSET: LazyLock = + LazyLock::new(|| init_color("OFFSET", AnsiColors::BrightBlack)); +pub static COLOR_ASCII_PRINTABLE: LazyLock = + LazyLock::new(|| init_color("ASCII_PRINTABLE", AnsiColors::Cyan)); +pub static COLOR_ASCII_WHITESPACE: LazyLock = + LazyLock::new(|| init_color("ASCII_WHITESPACE", AnsiColors::Green)); +pub static COLOR_ASCII_OTHER: LazyLock = + LazyLock::new(|| init_color("ASCII_OTHER", AnsiColors::Green)); +pub static COLOR_NONASCII: LazyLock = + LazyLock::new(|| init_color("NONASCII", AnsiColors::Yellow)); +pub const COLOR_RESET: &str = colors::Default::ANSI_FG; + +fn init_color(name: &str, default_ansi: AnsiColors) -> String { + let default = DynColors::Ansi(default_ansi); + let env_var = format!("HEXYL_COLOR_{}", name); + let color = match std::env::var(env_var).as_deref() { + Ok(color) => match DynColors::from_str(color) { + Ok(color) => color, + _ => default, + }, + _ => default, + }; + // owo_colors' API isn't designed to get the terminal codes directly for + // dynamic colors, so we use this hack to get them from the LHS of some text. + format!("{}", "|".color(color)) + .split_once("|") + .unwrap() + .0 + .to_owned() +} #[rustfmt::skip] pub const CP437: [char; 256] = [ diff --git a/src/lib.rs b/src/lib.rs index c452927..6d5f569 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,11 +84,11 @@ impl Byte { fn color(self) -> &'static [u8] { use crate::ByteCategory::*; match self.category() { - Null => COLOR_NULL, - AsciiPrintable => COLOR_ASCII_PRINTABLE, - AsciiWhitespace => COLOR_ASCII_WHITESPACE, - AsciiOther => COLOR_ASCII_OTHER, - NonAscii => COLOR_NONASCII, + Null => COLOR_NULL.as_bytes(), + AsciiPrintable => COLOR_ASCII_PRINTABLE.as_bytes(), + AsciiWhitespace => COLOR_ASCII_WHITESPACE.as_bytes(), + AsciiOther => COLOR_ASCII_OTHER.as_bytes(), + NonAscii => COLOR_NONASCII.as_bytes(), } } @@ -440,14 +440,14 @@ impl<'a, Writer: Write> Printer<'a, Writer> { .as_bytes(), )?; if self.show_color { - self.writer.write_all(COLOR_OFFSET)?; + self.writer.write_all(COLOR_OFFSET.as_bytes())?; } if self.show_position_panel { match self.squeezer { Squeezer::Print => { self.writer.write_all(b"*")?; if self.show_color { - self.writer.write_all(COLOR_RESET)?; + self.writer.write_all(COLOR_RESET.as_bytes())?; } self.writer.write_all(b" ")?; } @@ -462,7 +462,7 @@ impl<'a, Writer: Write> Printer<'a, Writer> { .write_all(self.byte_hex_panel_g[byte as usize].as_bytes())?; } if self.show_color { - self.writer.write_all(COLOR_RESET)?; + self.writer.write_all(COLOR_RESET.as_bytes())?; } } } @@ -494,7 +494,7 @@ impl<'a, Writer: Write> Printer<'a, Writer> { } if i == 8 * self.panels - 1 { if self.show_color { - self.writer.write_all(COLOR_RESET)?; + self.writer.write_all(COLOR_RESET.as_bytes())?; self.curr_color = None; } self.writer.write_all( @@ -505,7 +505,7 @@ impl<'a, Writer: Write> Printer<'a, Writer> { )?; } else if i % 8 == 7 { if self.show_color { - self.writer.write_all(COLOR_RESET)?; + self.writer.write_all(COLOR_RESET.as_bytes())?; self.curr_color = None; } self.writer.write_all( @@ -531,12 +531,12 @@ impl<'a, Writer: Write> Printer<'a, Writer> { Squeezer::Print => { if !self.show_position_panel && i == 0 { if self.show_color { - self.writer.write_all(COLOR_OFFSET)?; + self.writer.write_all(COLOR_OFFSET.as_bytes())?; } self.writer .write_all(self.byte_char_panel[b'*' as usize].as_bytes())?; if self.show_color { - self.writer.write_all(COLOR_RESET)?; + self.writer.write_all(COLOR_RESET.as_bytes())?; } } else if i % (self.group_size as usize) == 0 { self.writer.write_all(b" ")?; @@ -562,7 +562,7 @@ impl<'a, Writer: Write> Printer<'a, Writer> { if i % 8 == 7 { if self.show_color { self.curr_color = None; - self.writer.write_all(COLOR_RESET)?; + self.writer.write_all(COLOR_RESET.as_bytes())?; } self.writer.write_all(b" ")?; // byte is last in last panel diff --git a/src/main.rs b/src/main.rs index 24e8b7a..3ef7c05 100644 --- a/src/main.rs +++ b/src/main.rs @@ -493,32 +493,32 @@ fn print_color_table() -> io::Result<()> { writeln!(stdout, "hexyl color reference:\n")?; // NULL bytes - stdout.write_all(COLOR_NULL)?; + stdout.write_all(COLOR_NULL.as_bytes())?; writeln!(stdout, "⋄ NULL bytes (0x00)")?; - stdout.write_all(COLOR_RESET)?; + stdout.write_all(COLOR_RESET.as_bytes())?; // ASCII printable - stdout.write_all(COLOR_ASCII_PRINTABLE)?; + stdout.write_all(COLOR_ASCII_PRINTABLE.as_bytes())?; writeln!(stdout, "a ASCII printable characters (0x20 - 0x7E)")?; - stdout.write_all(COLOR_RESET)?; + stdout.write_all(COLOR_RESET.as_bytes())?; // ASCII whitespace - stdout.write_all(COLOR_ASCII_WHITESPACE)?; + stdout.write_all(COLOR_ASCII_WHITESPACE.as_bytes())?; writeln!(stdout, "_ ASCII whitespace (0x09 - 0x0D, 0x20)")?; - stdout.write_all(COLOR_RESET)?; + stdout.write_all(COLOR_RESET.as_bytes())?; // ASCII other - stdout.write_all(COLOR_ASCII_OTHER)?; + stdout.write_all(COLOR_ASCII_OTHER.as_bytes())?; writeln!( stdout, "• ASCII control characters (except NULL and whitespace)" )?; - stdout.write_all(COLOR_RESET)?; + stdout.write_all(COLOR_RESET.as_bytes())?; // Non-ASCII - stdout.write_all(COLOR_NONASCII)?; + stdout.write_all(COLOR_NONASCII.as_bytes())?; writeln!(stdout, "× Non-ASCII bytes (0x80 - 0xFF)")?; - stdout.write_all(COLOR_RESET)?; + stdout.write_all(COLOR_RESET.as_bytes())?; Ok(()) } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index a586319..557bd29 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -785,3 +785,180 @@ mod character_table { ); } } + +mod colors { + use super::hexyl; + use owo_colors::{colors, Color}; + use std::collections::HashMap; + + // This is a helper for testing color in output. Writing tests to expect + // raw color codes is ugly and hard to look at. Loading expected output + // from files works fine, but you end up with a lot of files for all the + // tests, and you have to cross-reference the file with the test that uses + // it. The files also suffer from the same problem of being hard to + // visually inspect. Just catting the file to see the colorized output + // loses the nuance of where exactly the color codes appear (before or + // after spaces, for example), or whether there are redundant codes. + // + // So this ColorMap solves the problem neatly by having two inputs: + // - the easy to read expected output in plain format without any colors + // - a mapping with identical structure except some characters replaced + // with single character color codes. + // This makes it easy to reference the output and expected colors side by + // side, and provides fairly precise control over exactly where color codes + // are expected (the only caveat being you can't have two color codes back + // to back). ColorMap combines these into the actual expected output. + // + // The color mapping needs to be identical to the expected output, except + // it has some chars replaced by color code stand ins. These are replaced + // with the actual color codes by the colorize method. The '.' character + // is also ignored (it doesn't need to match the input). This makes the + // color map more readable and avoids input characters from conflicting + // with color chars. + struct ColorMap { + text_map: &'static str, + char_to_color: HashMap, + } + + impl ColorMap { + fn from(text_map: &'static str) -> Self { + ColorMap { + text_map, + char_to_color: HashMap::new(), + } + } + + fn with(&mut self, c: char) -> &mut Self { + self.char_to_color.insert(c, C::ANSI_FG); + self + } + + fn colorize(&self, input: &str) -> String { + let mut output = String::new(); + let mut input_chars = input.chars(); + for c in self.text_map.chars() { + let next_input = input_chars.next().expect("input and color map don't match"); + if let Some(color) = self.char_to_color.get(&c) { + output.push_str(color); + } else if c != '.' { + // ignore '.' in the mapping for readability + assert_eq!(c, next_input, "input and color map don't match"); + } + output.push(next_input); + } + output + } + } + + #[test] + fn hex_colors() { + let input = b"He\x11\0 \xff\0\xdd"; + let expected_text = "\ + ┌────────┬─────────────────────────┬─────────────────────────┬────────┬────────┐\n\ + │00000000│ 48 65 11 00 20 ff 00 dd ┊ │He•⋄ ×⋄×┊ │\n\ + └────────┴─────────────────────────┴─────────────────────────┴────────┴────────┘\n"; + let expected = ColorMap::from( + "\ + ┌────────┬─────────────────────────┬─────────────────────────┬────────┬────────┐\n\ + │r.......d y. .. b. c. g. m. c. m.d┊ d│y.bcgmcmd d\n\ + └────────┴─────────────────────────┴─────────────────────────┴────────┴────────┘\n", + ) + .with::('r') + .with::('d') + .with::('y') + .with::('b') + .with::('g') + .with::('m') + .with::>('c') + .colorize(expected_text); + + hexyl() + .write_stdin(input) + .arg("--color=always") + .env("HEXYL_COLOR_OFFSET", "red") + .env("HEXYL_COLOR_ASCII_PRINTABLE", "yellow") + .env("HEXYL_COLOR_ASCII_WHITESPACE", "green") + .env("HEXYL_COLOR_ASCII_OTHER", "blue") + .env("HEXYL_COLOR_NONASCII", "bright magenta") + .env("HEXYL_COLOR_NULL", "#abcdef") + .assert() + .success() + .stdout(expected); + } + + #[test] + fn binary_colors() { + let input = b"He\x11\0 \xff\0\xdd"; + let expected_text = "\ + ┌────────┬─────────────────────────────────────────────────────────────────────────┬────────┐\n\ + │00000000│ 01001000 01100101 00010001 00000000 00100000 11111111 00000000 11011101 │He•⋄ ×⋄×│\n\ + └────────┴─────────────────────────────────────────────────────────────────────────┴────────┘\n"; + let expected = ColorMap::from( + "\ + ┌────────┬─────────────────────────────────────────────────────────────────────────┬────────┐\n\ + │r.......d y....... ........ b....... c....... g....... m....... c....... m.......d│y.bcgmcmd\n\ + └────────┴─────────────────────────────────────────────────────────────────────────┴────────┘\n" + ) + .with::('r') + .with::('d') + .with::('y') + .with::('b') + .with::('g') + .with::('m') + .with::>('c') + .colorize(expected_text); + + hexyl() + .write_stdin(input) + .arg("--color=always") + .arg("--panels=1") + .arg("--base=binary") + .env("HEXYL_COLOR_OFFSET", "red") + .env("HEXYL_COLOR_ASCII_PRINTABLE", "yellow") + .env("HEXYL_COLOR_ASCII_WHITESPACE", "green") + .env("HEXYL_COLOR_ASCII_OTHER", "blue") + .env("HEXYL_COLOR_NONASCII", "bright magenta") + .env("HEXYL_COLOR_NULL", "#abcdef") + .assert() + .success() + .stdout(expected); + } + + #[test] + fn groupsize_colors() { + let input = b"He\x11\0 \xff\0\xdd"; + let expected_text = "\ + ┌────────┬─────────────────────┬────────┐\n\ + │00000000│ 4865 1100 20ff 00dd │He•⋄ ×⋄×│\n\ + └────────┴─────────────────────┴────────┘\n"; + let expected = ColorMap::from( + "\ + ┌────────┬─────────────────────┬────────┐\n\ + │r.......d y... b.c. g.m. c.m.d│y.bcgmcmd\n\ + └────────┴─────────────────────┴────────┘\n", + ) + .with::('r') + .with::('d') + .with::('y') + .with::('b') + .with::('g') + .with::('m') + .with::>('c') + .colorize(expected_text); + + hexyl() + .write_stdin(input) + .arg("--color=always") + .arg("--panels=1") + .arg("--groupsize=2") + .env("HEXYL_COLOR_OFFSET", "red") + .env("HEXYL_COLOR_ASCII_PRINTABLE", "yellow") + .env("HEXYL_COLOR_ASCII_WHITESPACE", "green") + .env("HEXYL_COLOR_ASCII_OTHER", "blue") + .env("HEXYL_COLOR_NONASCII", "bright magenta") + .env("HEXYL_COLOR_NULL", "#abcdef") + .assert() + .success() + .stdout(expected); + } +}