├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── example.bin ├── img └── screenshot.png └── src ├── grapheme.rs ├── group.rs └── main.rs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: 2 | - parasyte 3 | patreon: blipjoy 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | pull_request: 5 | schedule: 6 | - cron: '0 0 * * 0' 7 | jobs: 8 | checks: 9 | name: Check 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | rust: 14 | - stable 15 | - beta 16 | - 1.73.0 17 | steps: 18 | - name: Checkout sources 19 | uses: actions/checkout@v3 20 | - name: Update apt repos 21 | run: sudo apt -y update 22 | - name: Install toolchain 23 | uses: dtolnay/rust-toolchain@master 24 | with: 25 | toolchain: ${{ matrix.rust }} 26 | - name: Rust cache 27 | uses: Swatinem/rust-cache@v2 28 | with: 29 | shared-key: common 30 | - name: Cargo check 31 | run: cargo check --workspace 32 | 33 | lints: 34 | name: Lints 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: Checkout sources 38 | uses: actions/checkout@v3 39 | - name: Update apt repos 40 | run: sudo apt -y update 41 | - name: Install toolchain 42 | uses: dtolnay/rust-toolchain@master 43 | with: 44 | toolchain: stable 45 | components: clippy, rustfmt 46 | - name: Rust cache 47 | uses: Swatinem/rust-cache@v2 48 | with: 49 | shared-key: common 50 | - name: Install cargo-machete 51 | uses: baptiste0928/cargo-install@v2 52 | with: 53 | crate: cargo-machete 54 | - name: Cargo fmt 55 | run: cargo fmt --all -- --check 56 | - name: Cargo doc 57 | run: cargo doc --workspace --no-deps 58 | - name: Cargo clippy 59 | run: cargo clippy --workspace --tests -- -D warnings 60 | - name: Cargo machete 61 | run: cargo machete 62 | 63 | tests: 64 | name: Test 65 | runs-on: ubuntu-latest 66 | needs: [checks, lints] 67 | strategy: 68 | matrix: 69 | rust: 70 | - stable 71 | - beta 72 | - 1.73.0 73 | steps: 74 | - name: Checkout sources 75 | uses: actions/checkout@v3 76 | - name: Update apt repos 77 | run: sudo apt -y update 78 | - name: Install toolchain 79 | uses: dtolnay/rust-toolchain@master 80 | with: 81 | toolchain: ${{ matrix.rust }} 82 | - name: Rust cache 83 | uses: Swatinem/rust-cache@v2 84 | with: 85 | shared-key: common 86 | - name: Cargo test 87 | run: cargo test --workspace 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "colorz" 7 | version = "1.1.4" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "6ceb37c5798821e37369cb546f430f19da2f585e0364c9615ae340a9f2e6067b" 10 | 11 | [[package]] 12 | name = "error-iter" 13 | version = "0.4.1" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "8070547d90d1b98debb6626421d742c897942bbb78f047694a5eb769495eccd6" 16 | 17 | [[package]] 18 | name = "hd" 19 | version = "0.1.0" 20 | dependencies = [ 21 | "colorz", 22 | "error-iter", 23 | "onlyargs", 24 | "onlyargs_derive", 25 | "onlyerror", 26 | "unicode-display-width", 27 | "unicode-segmentation", 28 | ] 29 | 30 | [[package]] 31 | name = "myn" 32 | version = "0.2.2" 33 | source = "registry+https://github.com/rust-lang/crates.io-index" 34 | checksum = "e49d2fc6c79d00e708293cb0793a2a33357405d0c0bf0fa7dc88e7694c8db313" 35 | 36 | [[package]] 37 | name = "onlyargs" 38 | version = "0.2.0" 39 | source = "registry+https://github.com/rust-lang/crates.io-index" 40 | checksum = "00d78a5eb5c0119da8284d056af5b352002594003e50fe1d6ee892acabfe6184" 41 | 42 | [[package]] 43 | name = "onlyargs_derive" 44 | version = "0.2.0" 45 | source = "registry+https://github.com/rust-lang/crates.io-index" 46 | checksum = "9e96dec8cebe43d77e74cc3104184765ba563fc675890fef82b61124fd647cd6" 47 | dependencies = [ 48 | "myn", 49 | "onlyargs", 50 | ] 51 | 52 | [[package]] 53 | name = "onlyerror" 54 | version = "0.1.5" 55 | source = "registry+https://github.com/rust-lang/crates.io-index" 56 | checksum = "1f0c9c18fd5c5a2c580df757c1a0bc5058fa4b1db1e1823c6692d7c7d5a09ff0" 57 | dependencies = [ 58 | "myn", 59 | ] 60 | 61 | [[package]] 62 | name = "unicode-display-width" 63 | version = "0.3.0" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "9a43273b656140aa2bb8e65351fe87c255f0eca706b2538a9bd4a590a3490bf3" 66 | dependencies = [ 67 | "unicode-segmentation", 68 | ] 69 | 70 | [[package]] 71 | name = "unicode-segmentation" 72 | version = "1.12.0" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 75 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hd" 3 | description = "Hex Display: A modern `xxd` alternative." 4 | version = "0.1.0" 5 | authors = ["Jay Oster "] 6 | repository = "https://github.com/parasyte/hd" 7 | edition = "2021" 8 | rust-version = "1.73.0" 9 | keywords = ["bytes", "hex", "pretty", "viewer", "xxd"] 10 | categories = ["command-line-utilities", "development-tools", "value-formatting"] 11 | license = "MIT" 12 | include = [ 13 | "/Cargo.*", 14 | "/LICENSE", 15 | "/README.md", 16 | "/img/screenshot.png", 17 | "/src/**/*", 18 | ] 19 | 20 | [dependencies] 21 | colorz = { version = "1.1.4", features = ["std"] } 22 | error-iter = "0.4.1" 23 | onlyargs = "0.2.0" 24 | onlyargs_derive = "0.2.0" 25 | onlyerror = "0.1.5" 26 | unicode-display-width = "0.3.0" 27 | unicode-segmentation = "1.12.0" 28 | 29 | [profile.release] 30 | codegen-units = 1 31 | lto = "fat" 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Jay Oster 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Crates.io](https://img.shields.io/crates/v/hd)](https://crates.io/crates/hd "Crates.io version") 2 | [![unsafe forbidden](https://img.shields.io/badge/unsafe-forbidden-success.svg)](https://github.com/rust-secure-code/safety-dance/) 3 | [![GitHub actions](https://img.shields.io/github/actions/workflow/status/parasyte/hd/ci.yml?branch=main)](https://github.com/parasyte/hd/actions "CI") 4 | [![GitHub activity](https://img.shields.io/github/last-commit/parasyte/hd)](https://github.com/parasyte/hd/commits "Commit activity") 5 | [![GitHub Sponsors](https://img.shields.io/github/sponsors/parasyte)](https://github.com/sponsors/parasyte "Sponsors") 6 | 7 | # `hd` 8 | 9 | Hex Display: A modern `xxd` alternative. 10 | 11 | ![Screenshot](./img/screenshot.png) 12 | 13 | # Installing 14 | 15 | ```bash 16 | cargo install hd 17 | ``` 18 | -------------------------------------------------------------------------------- /example.bin: -------------------------------------------------------------------------------- 1 | HHHHDDDDDHHDDHHHHHDDHHDDHHHHDDDDD 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ©2024 Jay Oster 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | Supports UTF-8 💩🤣🛡👩🏻‍🚀 -------------------------------------------------------------------------------- /img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/parasyte/hd/efe50e1f1527c26c5454cad4dab97c107c4de5f2/img/screenshot.png -------------------------------------------------------------------------------- /src/grapheme.rs: -------------------------------------------------------------------------------- 1 | use unicode_segmentation::UnicodeSegmentation; 2 | 3 | /// A grapheme cluster. 4 | /// 5 | /// One single-wide or double-wide character, potentially composed of multiple Unicode codepoints. 6 | pub(crate) struct Span<'a> { 7 | pub(crate) bytes: &'a [u8], 8 | pub(crate) parsed: Option<&'a str>, 9 | } 10 | 11 | impl Span<'_> { 12 | /// Create an ASCII span. 13 | pub(crate) fn ascii(bytes: &[u8]) -> Span<'_> { 14 | Span { 15 | bytes, 16 | parsed: None, 17 | } 18 | } 19 | 20 | /// Parse the first available grapheme cluster from a byte slice if possible. 21 | pub(crate) fn parse(bytes: &[u8]) -> Option> { 22 | let s = std::str::from_utf8(bytes).ok()?; 23 | let mut graphemes = UnicodeSegmentation::graphemes(s, true); 24 | 25 | graphemes.next().map(|parsed| Span { 26 | bytes: &bytes[..parsed.len()], 27 | parsed: Some(parsed), 28 | }) 29 | } 30 | 31 | /// Show a parsed grapheme cluster in the character table. 32 | pub(crate) fn as_char(&self, index: usize, column: usize, width: usize) -> Char<'_> { 33 | // Correctly handle row wrapping with double-wide characters. 34 | let cluster = self.parsed.unwrap(); 35 | let wide = unicode_display_width::width(cluster) == 2; 36 | if (index == 0 && (!wide || column != width - 1)) || (index == 1 && wide && column == 0) { 37 | Char::Cluster(cluster) 38 | } else if wide && ((index == 1 && column != 0) || (index == 2 && column == 1)) { 39 | Char::Skip 40 | } else { 41 | Char::Space 42 | } 43 | } 44 | } 45 | 46 | /// How to show a span in the character table. 47 | pub(crate) enum Char<'a> { 48 | /// Show the grapheme cluster. 49 | Cluster(&'a str), 50 | 51 | /// Show a blank space. 52 | Space, 53 | 54 | /// Skip this column. 55 | Skip, 56 | } 57 | 58 | #[cfg(test)] 59 | mod tests { 60 | use super::*; 61 | 62 | #[test] 63 | fn test_as_ascii() { 64 | let astronaut = "👩🏻‍🚀".as_bytes(); 65 | let span = Span::parse(astronaut).unwrap(); 66 | 67 | // Normal cases: grapheme cluster is shown on first line. 68 | for j in 0..7 { 69 | assert!(matches!(span.as_char(0, j, 8), Char::Cluster(_))); 70 | assert!(matches!(span.as_char(1, (j + 1) % 8, 8), Char::Skip)); 71 | for i in 2..astronaut.len() { 72 | assert!(matches!(span.as_char(i, (j + i) % 8, 8), Char::Space)); 73 | } 74 | } 75 | 76 | // Edge case: grapheme cluster is shown on second line. 77 | assert!(matches!(span.as_char(0, 7, 8), Char::Space)); 78 | assert!(matches!(span.as_char(1, 0, 8), Char::Cluster(_))); 79 | assert!(matches!(span.as_char(2, 1, 8), Char::Skip)); 80 | for i in 3..astronaut.len() { 81 | assert!(matches!(span.as_char(i, (i - 2) % 8, 8), Char::Space)); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/group.rs: -------------------------------------------------------------------------------- 1 | use crate::{grapheme::Span, Numeric}; 2 | 3 | /// Byte slices are grouped into spans by [`Kind`]. 4 | pub(crate) struct Group<'a> { 5 | /// The kind of group this is. 6 | pub(crate) kind: Kind, 7 | 8 | /// The span of the byte slice composing the entire group. 9 | pub(crate) span: Span<'a>, 10 | } 11 | 12 | /// Byte classifications for pretty printing. 13 | #[derive(Copy, Clone, Eq, PartialEq)] 14 | pub(crate) enum Kind { 15 | /// Numeric characters, depending on [`Numeric`] context: 16 | /// 17 | /// - Octal decimal: `0x30..=0x37` 18 | /// - Decimal: `0x30..=0x39` 19 | /// - Hexadecimal: `0x30..=0x39`, `0x41..=0x46`, and `0x61..=0x66` 20 | Numeric, 21 | 22 | /// ASCII printable characters: `0x20..=0x7e` 23 | Printable, 24 | 25 | /// ASCII control characters: `0x00..=0x1f` and `0x7f` 26 | Control, 27 | 28 | /// UTF-8 encoded grapheme cluster (e.g. emoji). 29 | Graphemes, 30 | 31 | /// Invalid ASCII/UTF-8 characters: `0x80..=0xff` 32 | Invalid, 33 | } 34 | 35 | impl Group<'_> { 36 | /// Parse a group (span and classification) from a byte slice. 37 | pub(crate) fn gather(bytes: &[u8], numeric: Numeric) -> Group<'_> { 38 | debug_assert!(!bytes.is_empty(), "Cannot gather an empty byte slice"); 39 | let byte = bytes[0]; 40 | 41 | if Kind::is_numeric(byte, numeric) { 42 | Self::numeric_span(bytes, numeric) 43 | } else if Kind::is_printable(byte) { 44 | Self::printable_span(bytes, numeric) 45 | } else if Kind::is_control(byte) { 46 | Self::control_span(bytes) 47 | } else if let Some(span) = Span::parse(bytes) { 48 | Group { 49 | kind: Kind::Graphemes, 50 | span, 51 | } 52 | } else { 53 | Self::invalid_span(bytes, numeric) 54 | } 55 | } 56 | 57 | fn new(kind: Kind, bytes: &[u8]) -> Group<'_> { 58 | Group { 59 | kind, 60 | span: Span::ascii(bytes), 61 | } 62 | } 63 | 64 | fn numeric_span(bytes: &[u8], numeric: Numeric) -> Group<'_> { 65 | let mut length = 1; 66 | for byte in &bytes[1..] { 67 | if !Kind::is_numeric(*byte, numeric) { 68 | break; 69 | } 70 | length += 1; 71 | } 72 | 73 | Self::new(Kind::Numeric, &bytes[..length]) 74 | } 75 | 76 | fn printable_span(bytes: &[u8], numeric: Numeric) -> Group<'_> { 77 | let mut length = 1; 78 | for byte in &bytes[1..] { 79 | if !Kind::is_printable(*byte) || Kind::is_numeric(*byte, numeric) { 80 | break; 81 | } 82 | length += 1; 83 | } 84 | 85 | Self::new(Kind::Printable, &bytes[..length]) 86 | } 87 | 88 | fn control_span(bytes: &[u8]) -> Group<'_> { 89 | let mut length = 1; 90 | for byte in &bytes[1..] { 91 | if !Kind::is_control(*byte) { 92 | break; 93 | } 94 | length += 1; 95 | } 96 | 97 | Self::new(Kind::Control, &bytes[..length]) 98 | } 99 | 100 | fn invalid_span(bytes: &[u8], numeric: Numeric) -> Group<'_> { 101 | let mut length = 1; 102 | for (i, byte) in bytes[1..].iter().enumerate() { 103 | if Kind::is_numeric(*byte, numeric) 104 | || Kind::is_printable(*byte) 105 | || Kind::is_control(*byte) 106 | || Span::parse(&bytes[i..]).is_some() 107 | { 108 | break; 109 | } 110 | length += 1; 111 | } 112 | 113 | Self::new(Kind::Invalid, &bytes[..length]) 114 | } 115 | } 116 | 117 | impl Kind { 118 | fn is_numeric(byte: u8, numeric: Numeric) -> bool { 119 | match numeric { 120 | Numeric::Octal => (b'0'..b'7').contains(&byte), 121 | Numeric::Decimal => byte.is_ascii_digit(), 122 | Numeric::Hexadecimal => byte.is_ascii_hexdigit(), 123 | } 124 | } 125 | 126 | fn is_printable(byte: u8) -> bool { 127 | byte == b' ' || byte.is_ascii_graphic() 128 | } 129 | 130 | fn is_control(byte: u8) -> bool { 131 | byte.is_ascii_control() 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use self::grapheme::Char; 2 | use self::group::{Group, Kind}; 3 | use colorz::{mode::set_coloring_mode_from_env, Colorize as _}; 4 | use error_iter::ErrorIter as _; 5 | use onlyargs::OnlyArgs as _; 6 | use onlyargs_derive::OnlyArgs; 7 | use onlyerror::Error; 8 | use std::fmt::{self, Write as _}; 9 | use std::io::{self, Read, Write as _}; 10 | use std::{fs::File, path::PathBuf, process::ExitCode, str::FromStr}; 11 | 12 | mod grapheme; 13 | mod group; 14 | 15 | #[derive(OnlyArgs)] 16 | #[footer = "Environment variables:"] 17 | #[footer = " - NO_COLOR: Disable colors entirely"] 18 | #[footer = " - ALWAYS_COLOR: Always enable colors"] 19 | #[footer = ""] 20 | #[footer = " - CLICOLOR_FORCE: Same as ALWAYS_COLOR"] 21 | #[footer = " - FORCE_COLOR: Same as ALWAYS_COLOR"] 22 | struct Args { 23 | /// Number of bytes to print per row. 24 | #[default(16)] 25 | width: usize, 26 | 27 | /// Number of bytes to group within a row. 28 | #[default(2)] 29 | group: usize, 30 | 31 | /// Numeric classification for character table. 32 | /// Prints bytes in cyan that match one of the following numeric classes: 33 | /// - `o`, `oct`, or `octal`: `/[0-7]+/` 34 | /// - `d`, `dec`, or `decimal`: `/[\d]+/` 35 | /// - `h`, `x`, `hex`, or `hexadecimal`: `/[a-f\d]+/i` 36 | /// 37 | #[default("decimal")] 38 | numeric: String, 39 | 40 | /// A list of file paths to read. 41 | #[positional] 42 | input: Vec, 43 | } 44 | 45 | /// All possible errors that can be reported to the user. 46 | #[derive(Debug, Error)] 47 | enum Error { 48 | /// CLI argument parsing error 49 | Cli(#[from] onlyargs::CliError), 50 | 51 | /// Width must be in range `2 <= width < 4096` 52 | Width, 53 | 54 | /// Grouping must not be larger than width 55 | Grouping, 56 | 57 | /// Unable to read file 58 | #[error("Unable to read file: {1:?}")] 59 | File(#[source] io::Error, PathBuf), 60 | 61 | /// Unknown numeric class 62 | #[error("Unknown numeric class: `{0}`")] 63 | UnknownNumeric(String), 64 | 65 | /// I/O error 66 | Io(#[from] io::Error), 67 | 68 | /// String formatting error 69 | Fmt(#[from] fmt::Error), 70 | } 71 | 72 | impl Error { 73 | /// Check if the error was caused by CLI inputs. 74 | fn is_cli(&self) -> bool { 75 | use Error::*; 76 | 77 | matches!( 78 | self, 79 | Cli(_) | Width | Grouping | File(_, _) | UnknownNumeric(_) 80 | ) 81 | } 82 | } 83 | 84 | fn main() -> ExitCode { 85 | set_coloring_mode_from_env(); 86 | 87 | match run() { 88 | Ok(()) => ExitCode::SUCCESS, 89 | Err(error) => { 90 | if error.is_cli() { 91 | let _ = writeln!(io::stderr(), "{}", Args::HELP); 92 | } 93 | 94 | let _ = writeln!(io::stderr(), "{}: {error}", "Error".bright_red()); 95 | for source in error.sources().skip(1) { 96 | let _ = writeln!(io::stderr(), " {}: {source}", "Caused by".bright_yellow()); 97 | } 98 | 99 | ExitCode::FAILURE 100 | } 101 | } 102 | } 103 | 104 | fn run() -> Result<(), Error> { 105 | let args: Args = onlyargs::parse()?; 106 | let width = args.width; 107 | let group = args.group; 108 | let numeric = args.numeric.parse()?; 109 | let mut printer = Printer::new(width, group, numeric)?; 110 | 111 | if args.input.is_empty() { 112 | // Read from stdin. 113 | printer.pretty_hex(&mut io::stdin())?; 114 | } else { 115 | // Read file paths. 116 | let show_header = args.input.len() > 1; 117 | for path in args.input.into_iter() { 118 | if show_header && writeln!(io::stdout(), "\n[{}]", path.display().yellow()).is_err() { 119 | std::process::exit(1); 120 | } 121 | let mut file = File::open(&path).map_err(|err| Error::File(err, path.to_path_buf()))?; 122 | printer.pretty_hex(&mut file)?; 123 | } 124 | } 125 | 126 | Ok(()) 127 | } 128 | 129 | /// Numeric context for byte classification. 130 | #[derive(Copy, Clone)] 131 | enum Numeric { 132 | Octal, 133 | Decimal, 134 | Hexadecimal, 135 | } 136 | 137 | impl FromStr for Numeric { 138 | type Err = Error; 139 | 140 | fn from_str(s: &str) -> Result { 141 | match s.to_lowercase().as_str() { 142 | "o" | "oct" | "octal" => Ok(Self::Octal), 143 | "d" | "dec" | "decimal" => Ok(Self::Decimal), 144 | "h" | "x" | "hex" | "hexadecimal" => Ok(Self::Hexadecimal), 145 | _ => Err(Error::UnknownNumeric(s.to_string())), 146 | } 147 | } 148 | } 149 | 150 | /// Row printer. Pretty prints byte slices one row at a time. 151 | struct Printer { 152 | /// Number of bytes per row. 153 | width: usize, 154 | 155 | /// Number of bytes to group within a row. 156 | group: usize, 157 | 158 | /// Numeric classification for character table. 159 | numeric: Numeric, 160 | 161 | /// Total number of columns to print for the hex digits in each row. 162 | max: usize, 163 | 164 | /// Internal state for printing rows and grouping bytes. 165 | state: PrinterState, 166 | } 167 | 168 | #[derive(Default)] 169 | struct PrinterState { 170 | addr: usize, 171 | column: usize, 172 | hex: String, 173 | table: String, 174 | hex_group: String, 175 | table_group: String, 176 | } 177 | 178 | impl Printer { 179 | /// Create a new row printer with width and group counts. 180 | /// 181 | /// # Errors 182 | /// 183 | /// - [`Error::Width`]: `width` is greater than 4096. 184 | /// - [`Error::Grouping`]: `group` is greater than `width`. 185 | fn new(width: usize, group: usize, numeric: Numeric) -> Result { 186 | if width <= 1 || width > 4096 { 187 | Err(Error::Width) 188 | } else if group > width { 189 | Err(Error::Grouping) 190 | } else { 191 | Ok(Self { 192 | width, 193 | group, 194 | numeric, 195 | max: padding(group, width), 196 | state: Default::default(), 197 | }) 198 | } 199 | } 200 | 201 | /// Pretty print a [`Reader`] as hex bytes. 202 | fn pretty_hex(&mut self, reader: &mut R) -> Result<(), Error> 203 | where 204 | R: Read, 205 | { 206 | let mut buf = [0; 4096]; 207 | 208 | loop { 209 | // Read as much as possible, appending to buffer. 210 | let size = reader.read(&mut buf)?; 211 | if size == 0 { 212 | break; 213 | } 214 | 215 | // Print bytes grouped by classification. 216 | let mut start = 0; 217 | while start < size { 218 | let group = Group::gather(&buf[start..size], self.numeric); 219 | start += group.span.bytes.len(); 220 | self.format_group(group)?; 221 | } 222 | } 223 | 224 | // Print any remaining row. 225 | if self.state.column > 0 { 226 | self.print_row()?; 227 | } 228 | 229 | Ok(()) 230 | } 231 | 232 | /// Format a classified group of bytes. 233 | fn format_group(&mut self, group: Group<'_>) -> Result<(), Error> { 234 | for (i, byte) in group.span.bytes.iter().enumerate() { 235 | // Write byte group separator. 236 | if self.state.column % self.group == 0 { 237 | self.state.hex_group.write_char(' ')?; 238 | } 239 | 240 | // Write hex. 241 | write!(&mut self.state.hex_group, "{byte:02x}")?; 242 | 243 | // Write character table. 244 | let ch = match group.kind { 245 | Kind::Printable | Kind::Numeric => Some(*byte as char), 246 | Kind::Graphemes => match group.span.as_char(i, self.state.column, self.width) { 247 | Char::Cluster(cluster) => { 248 | self.state.table_group.write_str(cluster)?; 249 | None 250 | } 251 | Char::Space => Some(' '), 252 | Char::Skip => None, 253 | }, 254 | Kind::Control | Kind::Invalid => Some('.'), 255 | }; 256 | if let Some(ch) = ch { 257 | self.state.table_group.write_char(ch)?; 258 | } 259 | 260 | self.state.column += 1; 261 | if self.state.column == self.width { 262 | self.colorize_group(group.kind)?; 263 | self.print_row()?; 264 | } 265 | } 266 | 267 | if self.state.column > 0 { 268 | self.colorize_group(group.kind)?; 269 | } 270 | 271 | Ok(()) 272 | } 273 | 274 | // Colorize formatted group. 275 | fn colorize_group(&mut self, kind: Kind) -> Result<(), Error> { 276 | let hex = &mut self.state.hex; 277 | let table = &mut self.state.table; 278 | let row_group = &self.state.hex_group; 279 | let table_group = &self.state.table_group; 280 | match kind { 281 | Kind::Control => { 282 | write!(hex, "{}", row_group.bright_yellow())?; 283 | write!(table, "{}", table_group.bright_yellow())?; 284 | } 285 | Kind::Printable => { 286 | write!(hex, "{}", row_group.bright_green())?; 287 | write!(table, "{}", table_group.bright_green())?; 288 | } 289 | Kind::Numeric => { 290 | write!(hex, "{}", row_group.bright_cyan())?; 291 | write!(table, "{}", table_group.bright_cyan())?; 292 | } 293 | Kind::Graphemes => { 294 | write!(hex, "{}", row_group.green().bold())?; 295 | write!(table, "{}", table_group.green().bold())?; 296 | } 297 | Kind::Invalid => { 298 | write!(hex, "{}", row_group.bright_red())?; 299 | write!(table, "{}", table_group.bright_red())?; 300 | } 301 | } 302 | 303 | self.state.hex_group.clear(); 304 | self.state.table_group.clear(); 305 | 306 | Ok(()) 307 | } 308 | 309 | // Print a complete row. 310 | fn print_row(&mut self) -> Result<(), Error> { 311 | let written = writeln!( 312 | io::stdout(), 313 | "{addr}:{hex}{hex_pad} | {table}{table_pad} |", 314 | addr = self.pretty_addr(), 315 | hex = self.state.hex, 316 | hex_pad = " ".repeat(self.max - padding(self.group, self.state.column)), 317 | table = self.state.table, 318 | table_pad = " ".repeat(self.width - self.state.column), 319 | ); 320 | 321 | // Exit process if the stdout pipe was closed. 322 | if written.is_err() { 323 | std::process::exit(1); 324 | } 325 | 326 | self.state.column = 0; 327 | self.state.addr += self.width; 328 | self.state.hex.clear(); 329 | self.state.table.clear(); 330 | 331 | Ok(()) 332 | } 333 | 334 | // Return the address as a formatted and colorized string. 335 | fn pretty_addr(&self) -> colorz::StyledValue { 336 | let a = self.state.addr >> 48; 337 | let b = (self.state.addr >> 32) & 0xffff; 338 | let c = (self.state.addr >> 16) & 0xffff; 339 | let d = self.state.addr & 0xffff; 340 | 341 | format!("{:04x}_{:04x}_{:04x}_{:04x}", a, b, c, d).into_bright_blue() 342 | } 343 | } 344 | 345 | /// Compute the number of columns needed to print a byte slice of the given length as grouped hex 346 | /// bytes. 347 | fn padding(group: usize, length: usize) -> usize { 348 | length * 2 + length.div_ceil(group) 349 | } 350 | --------------------------------------------------------------------------------