Skip to main content

hex_display/
lib.rs

1#![no_std]
2#![doc = include_str!("../README.md")]
3
4use core::fmt::{Debug, Display, LowerHex, UpperHex};
5
6#[cfg(any(feature = "alloc", test))]
7extern crate alloc;
8
9/// An extension trait that allows for more easily constructing [`Hex`] values
10///
11/// ```
12/// use hex_display::HexDisplayExt;
13/// assert_eq!(
14///     format!("{}", [0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef].hex()),
15///     "0123456789abcdef"
16/// );
17/// assert_eq!(
18///     format!("{:X}", [0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef].hex()),
19///     "0123456789ABCDEF"
20/// );
21/// ```
22///
23/// The formatter's width/alignment/fill are honored, so the output can be
24/// padded like any other [`Display`] value:
25///
26/// ```
27/// use hex_display::HexDisplayExt;
28/// assert_eq!(format!("{:>8}", [0x01, 0x23].hex()), "    0123");
29/// assert_eq!(format!("{:*^8}", [0x01, 0x23].hex()), "**0123**");
30/// ```
31///
32/// Use the alternate form (`#`) to emit a multiline `hexdump -C`-style dump
33/// with an offset column and ASCII gutter:
34///
35/// ```
36/// use hex_display::HexDisplayExt;
37/// assert_eq!(
38///     format!("{:#}", b"Hello, world!\n".hex()),
39///     "00000000  48 65 6c 6c 6f 2c 20 77  6f 72 6c 64 21 0a        |Hello, world!.|"
40/// );
41/// ```
42///
43/// See the documentation for [`Hex`] for more details.
44///
45/// This trait is sealed: downstream crates cannot implement it, since the
46/// blanket impl for `T: AsRef<[u8]> + ?Sized` already covers every
47/// reasonable input.
48pub trait HexDisplayExt: sealed::HexSealed {
49    /// Display as a hexdump
50    #[must_use]
51    fn hex(&self) -> Hex<'_>;
52
53    /// Convert to a upper-case hex string
54    ///
55    /// Only present when built with `alloc` support.
56    #[cfg(feature = "alloc")]
57    #[must_use]
58    fn to_upper_hex_string(&self) -> alloc::string::String {
59        alloc::format!("{:X}", self.hex())
60    }
61
62    /// Convert to a lower-case hex string
63    ///
64    /// Only present when built with `alloc` support.
65    #[cfg(feature = "alloc")]
66    #[must_use]
67    fn to_hex_string(&self) -> alloc::string::String {
68        use alloc::string::ToString;
69        self.hex().to_string()
70    }
71
72    /// Convert to a upper-case hexdump.
73    ///
74    /// Only present when built with `alloc` support.
75    #[cfg(feature = "alloc")]
76    #[must_use]
77    fn to_upper_hex_dump(&self) -> alloc::string::String {
78        alloc::format!("{:#X}", self.hex())
79    }
80
81    /// Convert to a lower-case hexdump.
82    ///
83    /// Only present when built with `alloc` support.
84    #[cfg(feature = "alloc")]
85    #[must_use]
86    fn to_hex_dump(&self) -> alloc::string::String {
87        alloc::format!("{:#x}", self.hex())
88    }
89}
90
91impl<T: AsRef<[u8]> + ?Sized> HexDisplayExt for T {
92    fn hex(&self) -> Hex<'_> {
93        Hex(self.as_ref())
94    }
95}
96
97mod sealed {
98    /// Seal [`super::HexDisplayExt`] to prevent downstream implementations.
99    #[allow(clippy::module_name_repetitions)]
100    pub trait HexSealed {}
101    impl<T: AsRef<[u8]> + ?Sized> HexSealed for T {}
102}
103
104/// A wrapper type for `&[u8]` which implements Display by providing a hexdump
105///
106/// See [`HexDisplayExt`] for an easier method of constructing this type.
107///
108/// By default, it outputs a lower-case hexdump, but it outputs upper-case if provided with `{:X}`
109/// formatting option.
110///
111/// ```
112/// use hex_display::Hex;
113///
114/// assert_eq!(
115///     format!("{}", Hex::new(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef])),
116///     "0123456789abcdef"
117/// );
118/// assert_eq!(
119///     format!("{:?}", Hex::new(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef])),
120///     "Hex(0123456789abcdef)"
121/// );
122/// assert_eq!(
123///     format!("{:X}", Hex::new(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef])),
124///     "0123456789ABCDEF"
125/// );
126/// ```
127///
128/// The formatter's `width`, `align`, and `fill` are applied to the entire
129/// output, the same way they would be for any other [`Display`] value:
130///
131/// ```
132/// use hex_display::Hex;
133/// assert_eq!(format!("{:>8}", Hex::new(&[0x01, 0x23])), "    0123");
134/// ```
135///
136/// Passing the alternate form (`#`) switches the output to a multiline
137/// `hexdump -C`-style dump, with an 8-digit offset column, two groups of
138/// eight hex bytes, and an ASCII gutter (non-printable bytes shown as `.`):
139///
140/// ```
141/// use hex_display::Hex;
142///
143/// let bytes: [u8; 18] = [
144///     0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
145///     0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
146///     0x10, 0x11,
147/// ];
148/// assert_eq!(
149///     format!("{:#}", Hex::new(&bytes)),
150///     "\
151/// 00000000  00 01 02 03 04 05 06 07  08 09 0a 0b 0c 0d 0e 0f  |................|
152/// 00000010  10 11                                             |..|",
153/// );
154/// ```
155///
156/// The alternate form combines with the upper-case flag (`{:#X}`) and with
157/// width-padding (`{:>WIDTH$#}`) just like the single-line form.
158///
159/// If the hex dump length exceeds the width in the output format (longer
160/// than 4 GiB), then the leading digit is replaced with a `*`:
161/// ```text
162/// ...
163/// fffffff0  00 01 02 03 04 05 06 07  08 09 0a 0b 0c 0d 0e 0f  |................|
164/// *0000000  00 01 02 03 04 05 06 07  08 09 0a 0b 0c 0d 0e 0f  |................|
165/// *0000010  00 01 02 03 04 05 06 07  08 09 0a 0b 0c 0d 0e 0f  |................|
166/// ```
167#[derive(Clone, Copy)]
168pub struct Hex<'a>(
169    /// The bytes to be converted into a hexdump.
170    &'a [u8],
171);
172
173impl<'a> Hex<'a> {
174    /// Create a new [`Hex`] instance for the given bytes.
175    #[must_use]
176    pub fn new(s: &'a (impl AsRef<[u8]> + ?Sized)) -> Self {
177        Self(s.as_ref())
178    }
179}
180
181/// Lookup table indexed by nibble for lowercase hex output.
182const LOWER_HEX: &[u8; 16] = b"0123456789abcdef";
183/// Uppercase counterpart to [`LOWER_HEX`].
184const UPPER_HEX: &[u8; 16] = b"0123456789ABCDEF";
185
186/// Format `bytes` as hex into `f` using the supplied nibble lookup table.
187///
188/// Bytes are encoded into a stack buffer in chunks so we make one
189/// [`core::fmt::Write::write_str`] call per chunk instead of one formatting
190/// call per byte. The chunk size is chosen to comfortably fit on the stack
191/// while amortizing the formatter's per-call overhead.
192fn write_hex(
193    f: &mut core::fmt::Formatter<'_>,
194    bytes: &[u8],
195    table: &[u8; 16],
196) -> core::fmt::Result {
197    /// Bytes per chunk; produces `CHUNK * 2` ASCII hex characters per write.
198    const CHUNK: usize = 32;
199    let mut buf = [0u8; CHUNK * 2];
200    for chunk in bytes.chunks(CHUNK) {
201        for (i, &byte) in chunk.iter().enumerate() {
202            buf[i * 2] = table[(byte >> 4) as usize];
203            buf[i * 2 + 1] = table[(byte & 0x0f) as usize];
204        }
205        // The lookup table should be valid ascii, so this shouldn't panic, but is kept to
206        // safeguard against future bugs.
207        //
208        // Switching to `unsafe` was profiled and produces ~20% speedup, which was deemed to be not
209        // enough speedup to be worth introducing new `unsafe`.
210        let s = core::str::from_utf8(&buf[..chunk.len() * 2])
211            .expect("hex lookup table only emits ASCII");
212        f.write_str(s)?;
213    }
214    Ok(())
215}
216
217/// Bytes per line in the multiline hexdump output.
218const HEXDUMP_LINE_BYTES: usize = 16;
219
220/// The width of the address in hexdump output.
221const HEXDUMP_ADDRESS_WIDTH: usize = 8;
222
223/// The number of lines of hexdump output before we overflow the address.
224///
225/// We can't just do `(1 << (HEXDUMP_ADDRESS_WIDTH * 4)) / HEXDUMP_LINE_BYTES` because that would
226/// overflow on 32-bit machines.
227const HEXDUMP_ADDRESS_OVERFLOW_LINE_NUM: usize =
228    1 << (HEXDUMP_ADDRESS_WIDTH * 4 - HEXDUMP_LINE_BYTES.ilog2() as usize);
229
230/// Width of the hex region in a hexdump line: two 8-byte groups (each
231/// `"xx "` × 8 = 24 chars) separated by an extra space.
232const HEXDUMP_HEX_REGION: usize = 8 * 3 * 2;
233/// Width of a full hexdump line (16 bytes), excluding any trailing newlines.
234///
235/// Layout: `ADDRESS_␠␠<hex region>␠␠|<ascii 16>|`.
236const HEXDUMP_FULL_LINE: usize =
237    HEXDUMP_ADDRESS_WIDTH + 2 + HEXDUMP_HEX_REGION + 2 + 1 + HEXDUMP_LINE_BYTES + 1;
238
239/// Compute the exact length, in bytes, of the alternate-form hexdump output
240/// for a slice of `byte_count` bytes (no trailing newline).
241fn hexdump_len(byte_count: usize) -> usize {
242    if byte_count == 0 {
243        return 0;
244    }
245    let n_full = byte_count / HEXDUMP_LINE_BYTES;
246    let rem = byte_count % HEXDUMP_LINE_BYTES;
247    let n_lines = n_full + usize::from(rem > 0);
248    // Partial line: same offset + hex region as a full line, but the ASCII
249    // gutter only contains `rem` bytes (still bracketed by `|`).
250    let partial = if rem > 0 {
251        8 + 2 + HEXDUMP_HEX_REGION + 2 + 1 + rem + 1
252    } else {
253        0
254    };
255    n_full * HEXDUMP_FULL_LINE + partial + n_lines - 1
256}
257
258/// Write the multiline `hexdump -C`-style output for `bytes` into `f`.
259fn write_hexdump(
260    f: &mut core::fmt::Formatter<'_>,
261    bytes: &[u8],
262    table: &[u8; 16],
263) -> core::fmt::Result {
264    for (line_idx, chunk) in bytes.chunks(HEXDUMP_LINE_BYTES).enumerate() {
265        // Write out in line-based chunks, to save overhead.
266        //
267        // Initialize to all spaces so we can just skip slots and they'll be a space.
268        let mut buf = [b' '; HEXDUMP_FULL_LINE + 1];
269        let mut buf_idx = 0;
270        if line_idx > 0 {
271            buf[buf_idx] = b'\n';
272            buf_idx += 1;
273        }
274
275        let offset = line_idx * HEXDUMP_LINE_BYTES;
276        let offset_buf = &mut buf[buf_idx..][..8];
277        for i in 0..HEXDUMP_ADDRESS_WIDTH {
278            offset_buf[HEXDUMP_ADDRESS_WIDTH - i - 1] = table[(offset >> (i * 4)) & 0xf];
279        }
280        if line_idx >= HEXDUMP_ADDRESS_OVERFLOW_LINE_NUM {
281            // If the offset overflows the space, mark the most significant nibble with '*' to
282            // indicate that the count has wrapped and is not accurate. This isn't a standard
283            // anywhere else, but it should be clear enough to end-users.
284            offset_buf[0] = b'*';
285        }
286        buf_idx += HEXDUMP_ADDRESS_WIDTH + 2; // add 2 blank spaces after above
287
288        let hex_buf = &mut buf[buf_idx..][..HEXDUMP_HEX_REGION];
289        for (i, &byte) in chunk.iter().enumerate() {
290            // Bytes 8..16 sit one extra space to the right to form two groups.
291            let pos = i * 3 + usize::from(i >= 8);
292            hex_buf[pos] = table[(byte >> 4) as usize];
293            hex_buf[pos + 1] = table[(byte & 0x0f) as usize];
294        }
295        buf_idx += HEXDUMP_HEX_REGION + 2;
296        buf[buf_idx] = b'|';
297        buf_idx += 1;
298
299        let ascii_buf = &mut buf[buf_idx..][..chunk.len()];
300        for (i, &byte) in chunk.iter().enumerate() {
301            ascii_buf[i] = if (0x20..=0x7e).contains(&byte) {
302                byte
303            } else {
304                b'.'
305            };
306        }
307        buf_idx += chunk.len();
308        buf[buf_idx] = b'|';
309        buf_idx += 1;
310        f.write_str(
311            core::str::from_utf8(&buf[..buf_idx]).expect("we should not write invalid ascii"),
312        )?;
313    }
314    Ok(())
315}
316
317/// Apply the formatter's width/alignment/fill around `write_content`.
318///
319/// Acts like [`core::fmt::Formatter::pad`] but without requiring the content
320/// to first be materialized into a `&str` — the caller provides the exact
321/// `content_len` and a closure that writes the content directly.
322fn write_padded(
323    f: &mut core::fmt::Formatter<'_>,
324    content_len: usize,
325    write_content: impl FnOnce(&mut core::fmt::Formatter<'_>) -> core::fmt::Result,
326) -> core::fmt::Result {
327    use core::fmt::{Alignment, Write};
328    let Some(width) = f.width() else {
329        return write_content(f);
330    };
331    if content_len >= width {
332        return write_content(f);
333    }
334    let pad_total = width - content_len;
335    let fill = f.fill();
336    let align = f.align().unwrap_or(Alignment::Left);
337    let (pre, post) = match align {
338        Alignment::Left => (0, pad_total),
339        Alignment::Right => (pad_total, 0),
340        Alignment::Center => (pad_total / 2, pad_total - pad_total / 2),
341    };
342    for _ in 0..pre {
343        f.write_char(fill)?;
344    }
345    write_content(f)?;
346    for _ in 0..post {
347        f.write_char(fill)?;
348    }
349    Ok(())
350}
351
352/// Dispatch to either the single-line hex encoding or the alternate-form
353/// multiline hexdump, then apply any width-padding requested by the formatter.
354fn fmt_hex_with_options(
355    f: &mut core::fmt::Formatter<'_>,
356    bytes: &[u8],
357    table: &[u8; 16],
358) -> core::fmt::Result {
359    let alternate = f.alternate();
360    let content_len = if alternate {
361        hexdump_len(bytes.len())
362    } else {
363        bytes.len() * 2
364    };
365    write_padded(f, content_len, |f| {
366        if alternate {
367            write_hexdump(f, bytes, table)
368        } else {
369            write_hex(f, bytes, table)
370        }
371    })
372}
373
374impl UpperHex for Hex<'_> {
375    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
376        fmt_hex_with_options(f, self.0, UPPER_HEX)
377    }
378}
379impl LowerHex for Hex<'_> {
380    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
381        fmt_hex_with_options(f, self.0, LOWER_HEX)
382    }
383}
384impl Debug for Hex<'_> {
385    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
386        f.debug_tuple("Hex")
387            .field(&format_args!("{self:x}"))
388            .finish()
389    }
390}
391impl Display for Hex<'_> {
392    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
393        fmt_hex_with_options(f, self.0, LOWER_HEX)
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use alloc::format;
400
401    use super::*;
402
403    #[test]
404    fn test_all_bytes() {
405        for byte in 0..=0xff {
406            assert_eq!(format!("{byte:02x}"), format!("{}", Hex(&[byte])));
407            #[cfg(feature = "alloc")]
408            assert_eq!(format!("{byte:02x}"), [byte].to_hex_string());
409            assert_eq!(format!("{byte:02x}"), format!("{:x}", Hex(&[byte])));
410            assert_eq!(format!("{byte:02X}"), format!("{:X}", Hex(&[byte])));
411            #[cfg(feature = "alloc")]
412            assert_eq!(format!("{byte:02X}"), [byte].to_upper_hex_string());
413        }
414    }
415
416    #[test]
417    fn test_all_byte_pairs() {
418        for (a, b) in (0..=0xff).zip(0..=0xff) {
419            assert_eq!(format!("{a:02x}{b:02x}"), format!("{}", Hex(&[a, b])));
420            assert_eq!(format!("{a:02X}{b:02X}"), format!("{:X}", Hex(&[a, b])));
421        }
422    }
423
424    #[test]
425    fn test_width_padding() {
426        let h = Hex(&[0x01, 0x23]);
427        assert_eq!(format!("{h:>8}"), "    0123");
428        assert_eq!(format!("{h:<8}"), "0123    ");
429        assert_eq!(format!("{h:^8}"), "  0123  ");
430        assert_eq!(format!("{h:*>8}"), "****0123");
431        // Width <= content length is a no-op.
432        assert_eq!(format!("{h:2}"), "0123");
433        // Default alignment is left, matching `Formatter::pad` for strings.
434        assert_eq!(format!("{h:8}"), "0123    ");
435        // Padding applies to UpperHex as well.
436        assert_eq!(format!("{h:>6X}"), "  0123");
437    }
438
439    #[test]
440    fn test_alternate_single_line() {
441        let bytes: [u8; 4] = [b'a', b'b', 0x00, 0xff];
442        let expected = "00000000  61 62 00 ff                                       |ab..|";
443        assert_eq!(format!("{:#}", Hex(&bytes)), expected);
444    }
445
446    #[test]
447    fn test_alternate_multiline() {
448        let mut bytes = [0u8; 18];
449        #[allow(clippy::cast_possible_truncation)] // i is small enoguh to not wrap
450        for (i, b) in bytes.iter_mut().enumerate() {
451            *b = i as u8;
452        }
453        let expected = "\
45400000000  00 01 02 03 04 05 06 07  08 09 0a 0b 0c 0d 0e 0f  |................|
45500000010  10 11                                             |..|";
456        assert_eq!(format!("{:#}", Hex(&bytes)), expected);
457        #[cfg(feature = "alloc")]
458        assert_eq!(bytes.to_hex_dump(), expected);
459    }
460
461    #[test]
462    fn test_alternate_upper_and_ascii_gutter() {
463        let bytes = b"Hello!\x00\x7f";
464        let expected = "00000000  48 65 6C 6C 6F 21 00 7F                           |Hello!..|";
465        assert_eq!(format!("{:#X}", bytes.hex()), expected);
466        #[cfg(feature = "alloc")]
467        assert_eq!(bytes.to_upper_hex_dump(), expected);
468    }
469
470    #[test]
471    fn test_alternate_empty() {
472        assert_eq!(format!("{:#}", Hex(&[])), "");
473    }
474
475    #[test]
476    fn test_alternate_with_padding() {
477        let bytes = [0xabu8, 0xcd];
478        let dump = format!("{:#}", Hex(&bytes));
479        assert_eq!(dump.len(), hexdump_len(bytes.len()));
480        // Right-align the whole multiline hexdump in a wider field.
481        let target_width = dump.len() + 4;
482        let padded = format!("{:>#1$}", Hex(&bytes), target_width);
483        assert_eq!(padded.len(), target_width);
484        assert!(padded.starts_with("    "));
485        assert_eq!(&padded[4..], dump);
486    }
487}