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}