Files
jackandClaude Fable 5 3f6dc7dd36 Speed up hex encode/decode with lookup tables
Data.hexEncodedString() built each byte via String(format: "%02x", _),
paying Foundation format-string parsing per byte on the hot BLE receive
path (PeerID(hexData:) is called several times per received packet).
Replace it with an ASCII lookup table writing into a preallocated
buffer; output is byte-for-byte identical (lowercase hex).

Data(hexString:) similarly allocated a two-character substring and went
through integer radix parsing per byte; replace with direct nibble
lookups over the UTF-8 bytes. Semantics are unchanged except that
sign-prefixed pairs like "+f", which the old radix parser incorrectly
accepted, are now rejected.

Add round-trip, known-vector, and invalid-input tests to
BitFoundationTests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:26:38 +02:00

90 lines
2.7 KiB
Swift

//
// Data+Hex.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import struct Foundation.Data
/// Lowercase hex digits used by `hexEncodedString()`.
private let hexDigits: [UInt8] = Array("0123456789abcdef".utf8)
/// Maps an ASCII byte to its hex nibble value, or nil for non-hex characters.
/// Accepts both lowercase and uppercase hex digits.
@inline(__always)
private func hexNibble(_ ascii: UInt8) -> UInt8? {
switch ascii {
case UInt8(ascii: "0")...UInt8(ascii: "9"):
return ascii - UInt8(ascii: "0")
case UInt8(ascii: "a")...UInt8(ascii: "f"):
return ascii - UInt8(ascii: "a") + 10
case UInt8(ascii: "A")...UInt8(ascii: "F"):
return ascii - UInt8(ascii: "A") + 10
default:
return nil
}
}
public extension Data {
/// Lowercase hex representation of the bytes.
///
/// Lookup-table based: this sits on the hot BLE receive path (it is called
/// several times per received packet via `PeerID(hexData:)`), where the
/// previous per-byte `String(format: "%02x", _)` implementation spent most
/// of its time re-parsing the format string through Foundation.
func hexEncodedString() -> String {
if isEmpty {
return ""
}
var output = [UInt8](repeating: 0, count: count * 2)
var i = 0
for byte in self {
output[i] = hexDigits[Int(byte >> 4)]
output[i + 1] = hexDigits[Int(byte & 0x0F)]
i += 2
}
return String(decoding: output, as: UTF8.self)
}
/// Initialize Data from a hex string.
/// - Parameter hexString: A hex string, optionally prefixed with "0x" or "0X".
/// Whitespace is trimmed. Must have even length after prefix removal.
/// - Returns: nil if the string has odd length or contains invalid hex characters.
init?(hexString: String) {
var hex = hexString.trimmed
// Remove optional 0x prefix
if hex.hasPrefix("0x") || hex.hasPrefix("0X") {
hex = String(hex.dropFirst(2))
}
let ascii = Array(hex.utf8)
// Reject odd-length strings
guard ascii.count % 2 == 0 else {
return nil
}
// Accept empty strings
guard !ascii.isEmpty else {
self = Data()
return
}
var data = Data(capacity: ascii.count / 2)
var index = 0
while index < ascii.count {
guard let high = hexNibble(ascii[index]),
let low = hexNibble(ascii[index + 1]) else {
return nil
}
data.append((high << 4) | low)
index += 2
}
self = data
}
}