Skip to main content

tess/
hex.rs

1//! xxd-style hex dump rendering. One row = 16 bytes, with offset prefix
2//! and ASCII gutter.
3
4/// Format one row of a hex dump.
5///
6/// Layout: `<8-hex-digit offset>: <16 bytes as 8 x 2-byte words> <16-char ASCII gutter>`.
7/// When `bytes.len() < 16`, the hex portion is right-padded with spaces so
8/// the ASCII gutter remains column-aligned with full rows.
9///
10/// Offsets larger than 0xFFFFFFFF still render with at least 8 hex digits
11/// (the format width is a minimum, not a max).
12///
13/// Non-printable bytes (outside 0x20..=0x7E) render as `.` in the ASCII gutter.
14pub fn format_hex_row(offset: usize, bytes: &[u8]) -> String {
15    debug_assert!(bytes.len() <= 16, "hex row must be <= 16 bytes");
16    let mut out = String::with_capacity(80);
17    out.push_str(&format!("{:08x}: ", offset));
18    for i in 0..16 {
19        if i > 0 && i % 2 == 0 {
20            out.push(' ');
21        }
22        if i < bytes.len() {
23            out.push_str(&format!("{:02x}", bytes[i]));
24        } else {
25            out.push_str("  ");
26        }
27    }
28    out.push_str("  ");
29    for b in bytes {
30        if (0x20..=0x7E).contains(b) {
31            out.push(*b as char);
32        } else {
33            out.push('.');
34        }
35    }
36    out
37}
38
39#[cfg(test)]
40mod tests {
41    use super::*;
42
43    #[test]
44    fn aligned_input_16_bytes_renders_full_row() {
45        let bytes = b"Hello world. tes";
46        let row = format_hex_row(0, bytes);
47        assert_eq!(
48            row,
49            "00000000: 4865 6c6c 6f20 776f 726c 642e 2074 6573  Hello world. tes"
50        );
51    }
52
53    #[test]
54    fn short_tail_pads_ascii_gutter_columns() {
55        let bytes = b"t.";
56        let row = format_hex_row(0x10, bytes);
57        assert!(row.starts_with("00000010: 742e "));
58        assert!(row.ends_with("  t."));
59        let ascii_start = row.find("  t.").unwrap();
60        let full_row = format_hex_row(0, b"0123456789abcdef");
61        let full_ascii_start = full_row.rfind("  ").unwrap();
62        assert_eq!(ascii_start, full_ascii_start,
63                   "short-row ASCII column should align with full-row ASCII column");
64    }
65
66    #[test]
67    fn all_printable_bytes_show_in_gutter() {
68        let bytes = b"abcdefghijklmnop";
69        let row = format_hex_row(0, bytes);
70        assert!(row.ends_with("  abcdefghijklmnop"));
71    }
72
73    #[test]
74    fn all_non_printable_bytes_show_as_dots() {
75        let bytes = &[0x00, 0x01, 0x02, 0x1f, 0x7f, 0x80, 0xff];
76        let row = format_hex_row(0, bytes);
77        assert!(row.ends_with("  ......."));
78    }
79
80    #[test]
81    fn utf8_multibyte_renders_as_dots_in_gutter() {
82        let bytes = "ä".as_bytes();
83        let row = format_hex_row(0, bytes);
84        assert!(row.contains("c3a4"));
85        assert!(row.ends_with("  .."));
86    }
87
88    #[test]
89    fn offset_grows_past_0x10000() {
90        let bytes = b"X";
91        let row = format_hex_row(0x123456, bytes);
92        assert!(row.starts_with("00123456: "));
93    }
94
95    #[test]
96    fn offset_grows_past_8_digits() {
97        let bytes = b"X";
98        let row = format_hex_row(0x1_2345_6789, bytes);
99        assert!(row.starts_with("123456789: "));
100    }
101}