fast-hex-lite 0.1.1

High-performance hex encoding and decoding with zero allocations, no_std support and SIMD acceleration
Documentation
//! Scalar hex decoder.

use crate::Error;

/// Returns the number of bytes produced from a hex string of `hex_len` bytes.
///
/// Returns [`Error::OddLength`] if `hex_len` is odd.
///
/// # Examples
/// ```
/// assert_eq!(fast_hex_lite::decoded_len(8).unwrap(), 4);
/// ```
#[inline]
pub fn decoded_len(hex_len: usize) -> Result<usize, Error> {
    if hex_len.is_multiple_of(2) {
        Ok(hex_len / 2)
    } else {
        Err(Error::OddLength)
    }
}

/// Decode ASCII-hex bytes `src_hex` into `dst`.
///
/// `src_hex` must contain an even number of bytes, all valid hex characters
/// (`0-9`, `a-f`, `A-F`). `dst` must be at least `src_hex.len() / 2` bytes.
///
/// Returns the number of bytes written.
#[inline]
pub fn decode_to_slice(src_hex: &[u8], dst: &mut [u8]) -> Result<usize, Error> {
    let out_len = decoded_len(src_hex.len())?;
    if dst.len() < out_len {
        return Err(Error::OutputTooSmall);
    }
    #[cfg(feature = "simd")]
    {
        crate::simd::decode_to_slice_simd(src_hex, &mut dst[..out_len])
    }
    #[cfg(not(feature = "simd"))]
    {
        decode_scalar(src_hex, &mut dst[..out_len])
    }
}

/// Decode exactly `N` bytes from a hex string of length `2*N`.
///
/// Returns [`Error::OutputTooSmall`] if `src_hex.len() / 2 != N`.
pub fn decode_to_array<const N: usize>(src_hex: &[u8]) -> Result<[u8; N], Error> {
    let out_len = decoded_len(src_hex.len())?;
    if out_len != N {
        return Err(Error::OutputTooSmall);
    }
    let mut arr = [0u8; N];
    decode_to_slice(src_hex, &mut arr)?;
    Ok(arr)
}

/// Decode hex bytes in-place: `buf` initially contains ASCII hex; after
/// decoding, the first `buf.len() / 2` bytes hold the result.
///
/// Returns the number of bytes written.
#[inline]
pub fn decode_in_place(buf: &mut [u8]) -> Result<usize, Error> {
    let out_len = decoded_len(buf.len())?;

    // Pass 1: validate without writing, so on error the buffer is unchanged.
    // Also lets us keep the fast decode loop branch-free.
    for i in 0..out_len {
        let hi = buf[2 * i];
        let lo = buf[2 * i + 1];

        if unhex_byte(hi).is_none() {
            return Err(Error::InvalidByte {
                index: 2 * i,
                byte: hi,
            });
        }
        if unhex_byte(lo).is_none() {
            return Err(Error::InvalidByte {
                index: 2 * i + 1,
                byte: lo,
            });
        }
    }

    // Pass 2: decode. Safe to write now.
    for i in 0..out_len {
        let hi = buf[2 * i];
        let lo = buf[2 * i + 1];
        // Validation above guarantees `decode_pair` returns 0x00..=0xFF.
        buf[i] = u8::try_from(decode_pair(hi, lo)).unwrap();
    }

    Ok(out_len)
}

// ── Scalar decoder ─────────────────────────────────────────────────────────

#[inline]
pub(crate) fn decode_scalar(src_hex: &[u8], dst: &mut [u8]) -> Result<usize, Error> {
    // `src_hex` is already even-length checked by the caller.
    // `dst` is already sized-checked by the caller.
    let out_len = src_hex.len() >> 1;

    // Hot loop: single 16-bit table lookup per output byte.
    // Use a tight index-based loop so LLVM can eliminate bounds checks.
    let mut j = 0usize;
    for out in dst.iter_mut().take(out_len) {
        let hi = src_hex[j];
        let lo = src_hex[j + 1];

        let v = decode_pair(hi, lo);
        if (v & 0x0100) != 0 {
            // Slow-path only on error: identify which byte is invalid so we
            // can report the correct index/byte.
            if unhex_byte(hi).is_none() {
                return Err(Error::InvalidByte { index: j, byte: hi });
            }
            return Err(Error::InvalidByte {
                index: j + 1,
                byte: lo,
            });
        }

        // `decode_pair` returns 0x00..=0xFF for valid pairs.
        *out = u8::try_from(v).unwrap();
        j += 2;
    }

    Ok(out_len)
}

/// Map a single ASCII hex digit to its nibble value (0..=15).
/// Returns `None` for non-hex bytes.
///
/// Fast table lookup.
#[inline]
pub(crate) fn unhex_byte(b: u8) -> Option<u8> {
    let v = UNHEX_TABLE[b as usize];
    if v == 0xFF {
        None
    } else {
        Some(v)
    }
}

// 256-entry nibble table (0..=15) or 0xFF for invalid.
const UNHEX_TABLE: [u8; 256] = make_unhex_table();

// 65_536-entry pair table. Each entry encodes either:
// - valid: 0x0000..=0x00FF (decoded byte)
// - invalid: 0x0100 (flag set)
//
// This lets the scalar decoder process 2 input bytes per iteration with a
// single table lookup.
static HEXPAIR_TABLE: [u16; 65536] = make_hexpair_table();

#[inline]
fn decode_pair(hi: u8, lo: u8) -> u16 {
    // Index is the two ASCII bytes.
    let idx = ((hi as usize) << 8) | (lo as usize);
    HEXPAIR_TABLE[idx]
}

const fn make_unhex_table() -> [u8; 256] {
    let mut t = [0xFFu8; 256];

    // Iterate as `u8` to avoid any potentially-truncating casts.
    let mut b = 0u8;
    loop {
        t[b as usize] = if b >= b'0' && b <= b'9' {
            b - b'0'
        } else if b >= b'a' && b <= b'f' {
            b - b'a' + 10
        } else if b >= b'A' && b <= b'F' {
            b - b'A' + 10
        } else {
            0xFF
        };

        if b == u8::MAX {
            break;
        }
        b = b.wrapping_add(1);
    }

    t
}

#[allow(clippy::large_stack_arrays)]
const fn make_hexpair_table() -> [u16; 65536] {
    let mut t = [0x0100u16; 65536];
    let unhex = make_unhex_table();

    let mut hi = 0u32;
    while hi < 256 {
        let mut lo = 0u32;
        while lo < 256 {
            let hn = unhex[hi as usize];
            let ln = unhex[lo as usize];
            if hn != 0xFF && ln != 0xFF {
                let out = ((hn as u16) << 4) | (ln as u16);
                t[((hi as usize) << 8) | (lo as usize)] = out;
            }
            lo += 1;
        }
        hi += 1;
    }

    t
}

#[cfg(test)]
#[path = "decode/tests.rs"]
mod tests;