diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..fc5d86b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +github: +- parasyte +patreon: blipjoy diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2dd121a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI +on: + push: + pull_request: + schedule: + - cron: '0 0 * * 0' +jobs: + checks: + name: Check + runs-on: ubuntu-latest + strategy: + matrix: + rust: + - stable + - beta + - 1.73.0 + steps: + - name: Checkout sources + uses: actions/checkout@v3 + - name: Update apt repos + run: sudo apt -y update + - name: Install toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: common + - name: Cargo check + run: cargo check --workspace + + lints: + name: Lints + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v3 + - name: Update apt repos + run: sudo apt -y update + - name: Install toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + components: clippy, rustfmt + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: common + - name: Install cargo-machete + uses: baptiste0928/cargo-install@v2 + with: + crate: cargo-machete + - name: Cargo fmt + run: cargo fmt --all -- --check + - name: Cargo doc + run: cargo doc --workspace --no-deps + - name: Cargo clippy + run: cargo clippy --workspace --tests -- -D warnings + - name: Cargo machete + run: cargo machete + + tests: + name: Test + runs-on: ubuntu-latest + needs: [checks, lints] + strategy: + matrix: + rust: + - stable + - beta + - 1.73.0 + steps: + - name: Checkout sources + uses: actions/checkout@v3 + - name: Update apt repos + run: sudo apt -y update + - name: Install toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: common + - name: Cargo test + run: cargo test --workspace diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0592392 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f0bda99 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,74 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "colorz" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ceb37c5798821e37369cb546f430f19da2f585e0364c9615ae340a9f2e6067b" + +[[package]] +name = "error-iter" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8070547d90d1b98debb6626421d742c897942bbb78f047694a5eb769495eccd6" + +[[package]] +name = "hd" +version = "0.1.0" +dependencies = [ + "colorz", + "error-iter", + "onlyargs", + "onlyargs_derive", + "onlyerror", + "unicode-display-width", + "unicode-segmentation", +] + +[[package]] +name = "myn" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d2fc6c79d00e708293cb0793a2a33357405d0c0bf0fa7dc88e7694c8db313" + +[[package]] +name = "onlyargs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d78a5eb5c0119da8284d056af5b352002594003e50fe1d6ee892acabfe6184" + +[[package]] +name = "onlyargs_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e96dec8cebe43d77e74cc3104184765ba563fc675890fef82b61124fd647cd6" +dependencies = [ + "myn", + "onlyargs", +] + +[[package]] +name = "onlyerror" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f0c9c18fd5c5a2c580df757c1a0bc5058fa4b1db1e1823c6692d7c7d5a09ff0" +dependencies = [ + "myn", +] + +[[package]] +name = "unicode-display-width" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a43273b656140aa2bb8e65351fe87c255f0eca706b2538a9bd4a590a3490bf3" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..99c0649 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "hd" +description = "Hex Display: A modern `xxd` alternative." +version = "0.1.0" +authors = ["Jay Oster "] +repository = "https://github.com/parasyte/hd" +edition = "2021" +rust-version = "1.73.0" +keywords = ["bytes", "hex", "pretty", "viewer", "xxd"] +categories = ["command-line-utilities", "development-tools", "value-formatting"] +license = "MIT" +include = [ + "/Cargo.*", + "/LICENSE", + "/README.md", + "/img/screenshot.png", + "/src/**/*", +] + +[dependencies] +colorz = { version = "1.1.4", features = ["std"] } +error-iter = "0.4.1" +onlyargs = "0.2.0" +onlyargs_derive = "0.2.0" +onlyerror = "0.1.5" +unicode-display-width = "0.3.0" +unicode-segmentation = "1.12.0" + +[profile.release] +codegen-units = 1 +lto = "fat" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a83775a --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +Copyright 2024 Jay Oster + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ff472c --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +[![Crates.io](https://img.shields.io/crates/v/hd)](https://crates.io/crates/hd "Crates.io version") +[![Documentation](https://img.shields.io/docsrs/hd)](https://docs.rs/hd "Documentation") +[![unsafe forbidden](https://img.shields.io/badge/unsafe-forbidden-success.svg)](https://github.com/rust-secure-code/safety-dance/) +[![GitHub actions](https://img.shields.io/github/actions/workflow/status/parasyte/hd/ci.yml?branch=main)](https://github.com/parasyte/hd/actions "CI") +[![GitHub activity](https://img.shields.io/github/last-commit/parasyte/hd)](https://github.com/parasyte/hd/commits "Commit activity") +[![GitHub Sponsors](https://img.shields.io/github/sponsors/parasyte)](https://github.com/sponsors/parasyte "Sponsors") + +# `hd` + +Hex Display: A modern `xxd` alternative. + +![Screenshot](./img/screenshot.png) + +# Installing + +```bash +cargo install hd +``` diff --git a/example.bin b/example.bin new file mode 100644 index 0000000..b359a33 Binary files /dev/null and b/example.bin differ diff --git a/img/screenshot.png b/img/screenshot.png new file mode 100644 index 0000000..a5571db Binary files /dev/null and b/img/screenshot.png differ diff --git a/src/grapheme.rs b/src/grapheme.rs new file mode 100644 index 0000000..3c88054 --- /dev/null +++ b/src/grapheme.rs @@ -0,0 +1,84 @@ +use unicode_segmentation::UnicodeSegmentation; + +/// A grapheme cluster. +/// +/// One single-wide or double-wide character, potentially composed of multiple Unicode codepoints. +pub(crate) struct Span<'a> { + pub(crate) bytes: &'a [u8], + pub(crate) parsed: Option<&'a str>, +} + +impl Span<'_> { + /// Create an ASCII span. + pub(crate) fn ascii(bytes: &[u8]) -> Span<'_> { + Span { + bytes, + parsed: None, + } + } + + /// Parse the first available grapheme cluster from a byte slice if possible. + pub(crate) fn parse(bytes: &[u8]) -> Option> { + let s = std::str::from_utf8(bytes).ok()?; + let mut graphemes = UnicodeSegmentation::graphemes(s, true); + + graphemes.next().map(|parsed| Span { + bytes: &bytes[..parsed.len()], + parsed: Some(parsed), + }) + } + + /// Show a parsed grapheme cluster in the character table. + pub(crate) fn as_char(&self, index: usize, column: usize, width: usize) -> Char<'_> { + // Correctly handle row wrapping with double-wide characters. + let cluster = self.parsed.unwrap(); + let wide = unicode_display_width::width(cluster) == 2; + if (index == 0 && (!wide || column != width - 1)) || (index == 1 && wide && column == 0) { + Char::Cluster(cluster) + } else if wide && ((index == 1 && column != 0) || (index == 2 && column == 1)) { + Char::Skip + } else { + Char::Space + } + } +} + +/// How to show a span in the character table. +pub(crate) enum Char<'a> { + /// Show the grapheme cluster. + Cluster(&'a str), + + /// Show a blank space. + Space, + + /// Skip this column. + Skip, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_as_ascii() { + let astronaut = "👩🏻‍🚀".as_bytes(); + let span = Span::parse(astronaut).unwrap(); + + // Normal cases: grapheme cluster is shown on first line. + for j in 0..7 { + assert!(matches!(span.as_char(0, j, 8), Char::Cluster(_))); + assert!(matches!(span.as_char(1, (j + 1) % 8, 8), Char::Skip)); + for i in 2..astronaut.len() { + assert!(matches!(span.as_char(i, (j + i) % 8, 8), Char::Space)); + } + } + + // Edge case: grapheme cluster is shown on second line. + assert!(matches!(span.as_char(0, 7, 8), Char::Space)); + assert!(matches!(span.as_char(1, 0, 8), Char::Cluster(_))); + assert!(matches!(span.as_char(2, 1, 8), Char::Skip)); + for i in 3..astronaut.len() { + assert!(matches!(span.as_char(i, (i - 2) % 8, 8), Char::Space)); + } + } +} diff --git a/src/group.rs b/src/group.rs new file mode 100644 index 0000000..3c481d0 --- /dev/null +++ b/src/group.rs @@ -0,0 +1,133 @@ +use crate::{grapheme::Span, Numeric}; + +/// Byte slices are grouped into spans by [`Kind`]. +pub(crate) struct Group<'a> { + /// The kind of group this is. + pub(crate) kind: Kind, + + /// The span of the byte slice composing the entire group. + pub(crate) span: Span<'a>, +} + +/// Byte classifications for pretty printing. +#[derive(Copy, Clone, Eq, PartialEq)] +pub(crate) enum Kind { + /// Numeric characters, depending on [`Numeric`] context: + /// + /// - Octal decimal: `0x30..=0x37` + /// - Decimal: `0x30..=0x39` + /// - Hexadecimal: `0x30..=0x39`, `0x41..=0x46`, and `0x61..=0x66` + Numeric, + + /// ASCII printable characters: `0x20..=0x7e` + Printable, + + /// ASCII control characters: `0x00..=0x1f` and `0x7f` + Control, + + /// UTF-8 encoded grapheme cluster (e.g. emoji). + Graphemes, + + /// Invalid ASCII/UTF-8 characters: `0x80..=0xff` + Invalid, +} + +impl Group<'_> { + /// Parse a group (span and classification) from a byte slice. + pub(crate) fn gather(bytes: &[u8], numeric: Numeric) -> Group<'_> { + debug_assert!(!bytes.is_empty(), "Cannot gather an empty byte slice"); + let byte = bytes[0]; + + if Kind::is_numeric(byte, numeric) { + Self::numeric_span(bytes, numeric) + } else if Kind::is_printable(byte) { + Self::printable_span(bytes, numeric) + } else if Kind::is_control(byte) { + Self::control_span(bytes) + } else if let Some(span) = Span::parse(bytes) { + Group { + kind: Kind::Graphemes, + span, + } + } else { + Self::invalid_span(bytes, numeric) + } + } + + fn new(kind: Kind, bytes: &[u8]) -> Group<'_> { + Group { + kind, + span: Span::ascii(bytes), + } + } + + fn numeric_span(bytes: &[u8], numeric: Numeric) -> Group<'_> { + let mut length = 1; + for byte in &bytes[1..] { + if !Kind::is_numeric(*byte, numeric) { + break; + } + length += 1; + } + + Self::new(Kind::Numeric, &bytes[..length]) + } + + fn printable_span(bytes: &[u8], numeric: Numeric) -> Group<'_> { + let mut length = 1; + for byte in &bytes[1..] { + if !Kind::is_printable(*byte) || Kind::is_numeric(*byte, numeric) { + break; + } + length += 1; + } + + Self::new(Kind::Printable, &bytes[..length]) + } + + fn control_span(bytes: &[u8]) -> Group<'_> { + let mut length = 1; + for byte in &bytes[1..] { + if !Kind::is_control(*byte) { + break; + } + length += 1; + } + + Self::new(Kind::Control, &bytes[..length]) + } + + fn invalid_span(bytes: &[u8], numeric: Numeric) -> Group<'_> { + let mut length = 1; + for (i, byte) in bytes[1..].iter().enumerate() { + if Kind::is_numeric(*byte, numeric) + || Kind::is_printable(*byte) + || Kind::is_control(*byte) + || Span::parse(&bytes[i..]).is_some() + { + break; + } + length += 1; + } + + Self::new(Kind::Invalid, &bytes[..length]) + } +} + +impl Kind { + fn is_numeric(byte: u8, numeric: Numeric) -> bool { + match numeric { + Numeric::Octal => (b'0'..b'7').contains(&byte), + Numeric::Decimal => byte.is_ascii_digit(), + Numeric::Hexadecimal => byte.is_ascii_hexdigit(), + } + } + + fn is_printable(byte: u8) -> bool { + byte == b' ' || byte.is_ascii_graphic() + } + + fn is_control(byte: u8) -> bool { + byte.is_ascii_control() + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..940c336 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,349 @@ +use self::grapheme::Char; +use self::group::{Group, Kind}; +use colorz::{mode::set_coloring_mode_from_env, Colorize as _}; +use error_iter::ErrorIter as _; +use onlyargs::OnlyArgs as _; +use onlyargs_derive::OnlyArgs; +use onlyerror::Error; +use std::fmt::{self, Write as _}; +use std::io::{self, Read, Write as _}; +use std::{fs::File, path::PathBuf, process::ExitCode, str::FromStr}; + +mod grapheme; +mod group; + +#[derive(OnlyArgs)] +#[footer = "Environment variables:"] +#[footer = " - NO_COLOR: Disable colors entirely"] +#[footer = " - ALWAYS_COLOR: Always enable colors"] +#[footer = ""] +#[footer = " - CLICOLOR_FORCE: Same as ALWAYS_COLOR"] +#[footer = " - FORCE_COLOR: Same as ALWAYS_COLOR"] +struct Args { + /// Number of bytes to print per row. + #[default(16)] + width: usize, + + /// Number of bytes to group within a row. + #[default(2)] + group: usize, + + /// Numeric classification for character table. + /// Prints bytes in cyan that match one of the following numeric classes: + /// - `o`, `oct`, or `octal`: `/[0-7]+/` + /// - `d`, `dec`, or `decimal`: `/[\d]+/` + /// - `h`, `x`, `hex`, or `hexadecimal`: `/[a-f\d]+/i` + /// + #[default("decimal")] + numeric: String, + + /// A list of file paths to read. + #[positional] + input: Vec, +} + +/// All possible errors that can be reported to the user. +#[derive(Debug, Error)] +enum Error { + /// CLI argument parsing error + Cli(#[from] onlyargs::CliError), + + /// Width must be in range `2 <= width < 4096` + Width, + + /// Grouping must not be larger than width + Grouping, + + /// Unable to read file + #[error("Unable to read file: {1:?}")] + File(#[source] io::Error, PathBuf), + + /// Unknown numeric class + #[error("Unknown numeric class: `{0}`")] + UnknownNumeric(String), + + /// I/O error + Io(#[from] io::Error), + + /// String formatting error + Fmt(#[from] fmt::Error), +} + +impl Error { + /// Check if the error was caused by CLI inputs. + fn is_cli(&self) -> bool { + use Error::*; + + matches!( + self, + Cli(_) | Width | Grouping | File(_, _) | UnknownNumeric(_) + ) + } +} + +fn main() -> ExitCode { + set_coloring_mode_from_env(); + + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + if error.is_cli() { + let _ = writeln!(io::stderr(), "{}", Args::HELP); + } + + let _ = writeln!(io::stderr(), "{}: {error}", "Error".bright_red()); + for source in error.sources().skip(1) { + let _ = writeln!(io::stderr(), " {}: {source}", "Caused by".bright_yellow()); + } + + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), Error> { + let args: Args = onlyargs::parse()?; + let width = args.width; + let group = args.group; + let numeric = args.numeric.parse()?; + let mut printer = Printer::new(width, group, numeric)?; + + if args.input.is_empty() { + // Read from stdin. + printer.pretty_hex(&mut io::stdin())?; + } else { + // Read file paths. + let show_header = args.input.len() > 1; + for path in args.input.into_iter() { + if show_header && writeln!(io::stdout(), "\n[{}]", path.display().yellow()).is_err() { + std::process::exit(1); + } + let mut file = File::open(&path).map_err(|err| Error::File(err, path.to_path_buf()))?; + printer.pretty_hex(&mut file)?; + } + } + + Ok(()) +} + +/// Numeric context for byte classification. +#[derive(Copy, Clone)] +enum Numeric { + Octal, + Decimal, + Hexadecimal, +} + +impl FromStr for Numeric { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "o" | "oct" | "octal" => Ok(Self::Octal), + "d" | "dec" | "decimal" => Ok(Self::Decimal), + "h" | "x" | "hex" | "hexadecimal" => Ok(Self::Hexadecimal), + _ => Err(Error::UnknownNumeric(s.to_string())), + } + } +} + +/// Row printer. Pretty prints byte slices one row at a time. +struct Printer { + /// Number of bytes per row. + width: usize, + + /// Number of bytes to group within a row. + group: usize, + + /// Numeric classification for character table. + numeric: Numeric, + + /// Total number of columns to print for the hex digits in each row. + max: usize, + + /// Internal state for printing rows and grouping bytes. + state: PrinterState, +} + +#[derive(Default)] +struct PrinterState { + addr: usize, + column: usize, + hex: String, + table: String, + hex_group: String, + table_group: String, +} + +impl Printer { + /// Create a new row printer with width and group counts. + /// + /// # Errors + /// + /// - [`Error::Width`]: `width` is greater than 4096. + /// - [`Error::Grouping`]: `group` is greater than `width`. + fn new(width: usize, group: usize, numeric: Numeric) -> Result { + if width <= 1 || width > 4096 { + Err(Error::Width) + } else if group > width { + Err(Error::Grouping) + } else { + Ok(Self { + width, + group, + numeric, + max: padding(group, width), + state: Default::default(), + }) + } + } + + /// Pretty print a [`Reader`] as hex bytes. + fn pretty_hex(&mut self, reader: &mut R) -> Result<(), Error> + where + R: Read, + { + let mut buf = [0; 4096]; + + loop { + // Read as much as possible, appending to buffer. + let size = reader.read(&mut buf)?; + if size == 0 { + break; + } + + // Print bytes grouped by classification. + let mut start = 0; + while start < size { + let group = Group::gather(&buf[start..size], self.numeric); + start += group.span.bytes.len(); + self.format_group(group)?; + } + } + + // Print any remaining row. + if self.state.column > 0 { + self.print_row()?; + } + + Ok(()) + } + + /// Format a classified group of bytes. + fn format_group(&mut self, group: Group<'_>) -> Result<(), Error> { + for (i, byte) in group.span.bytes.iter().enumerate() { + // Write byte group separator. + if self.state.column % self.group == 0 { + self.state.hex_group.write_char(' ')?; + } + + // Write hex. + write!(&mut self.state.hex_group, "{byte:02x}")?; + + // Write character table. + let ch = match group.kind { + Kind::Printable | Kind::Numeric => Some(*byte as char), + Kind::Graphemes => match group.span.as_char(i, self.state.column, self.width) { + Char::Cluster(cluster) => { + self.state.table_group.write_str(cluster)?; + None + } + Char::Space => Some(' '), + Char::Skip => None, + }, + Kind::Control | Kind::Invalid => Some('.'), + }; + if let Some(ch) = ch { + self.state.table_group.write_char(ch)?; + } + + self.state.column += 1; + if self.state.column == self.width { + self.colorize_group(group.kind)?; + self.print_row()?; + } + } + + if self.state.column > 0 { + self.colorize_group(group.kind)?; + } + + Ok(()) + } + + // Colorize formatted group. + fn colorize_group(&mut self, kind: Kind) -> Result<(), Error> { + let hex = &mut self.state.hex; + let table = &mut self.state.table; + let row_group = &self.state.hex_group; + let table_group = &self.state.table_group; + match kind { + Kind::Control => { + write!(hex, "{}", row_group.bright_yellow())?; + write!(table, "{}", table_group.bright_yellow())?; + } + Kind::Printable => { + write!(hex, "{}", row_group.bright_green())?; + write!(table, "{}", table_group.bright_green())?; + } + Kind::Numeric => { + write!(hex, "{}", row_group.bright_cyan())?; + write!(table, "{}", table_group.bright_cyan())?; + } + Kind::Graphemes => { + write!(hex, "{}", row_group.green().bold())?; + write!(table, "{}", table_group.green().bold())?; + } + Kind::Invalid => { + write!(hex, "{}", row_group.bright_red())?; + write!(table, "{}", table_group.bright_red())?; + } + } + + self.state.hex_group.clear(); + self.state.table_group.clear(); + + Ok(()) + } + + // Print a complete row. + fn print_row(&mut self) -> Result<(), Error> { + let written = writeln!( + io::stdout(), + "{addr}:{hex}{hex_pad} | {table}{table_pad} |", + addr = self.pretty_addr(), + hex = self.state.hex, + hex_pad = " ".repeat(self.max - padding(self.group, self.state.column)), + table = self.state.table, + table_pad = " ".repeat(self.width - self.state.column), + ); + + // Exit process if the stdout pipe was closed. + if written.is_err() { + std::process::exit(1); + } + + self.state.column = 0; + self.state.addr += self.width; + self.state.hex.clear(); + self.state.table.clear(); + + Ok(()) + } + + // Return the address as a formatted and colorized string. + fn pretty_addr(&self) -> colorz::StyledValue { + let a = self.state.addr >> 48; + let b = (self.state.addr >> 32) & 0xffff; + let c = (self.state.addr >> 16) & 0xffff; + let d = self.state.addr & 0xffff; + + format!("{:04x}_{:04x}_{:04x}_{:04x}", a, b, c, d).into_bright_blue() + } +} + +/// Compute the number of columns needed to print a byte slice of the given length as grouped hex +/// bytes. +fn padding(group: usize, length: usize) -> usize { + length * 2 + length.div_ceil(group) +}