From c7c61388a9cbbc8b713dd805496034706ba609de Mon Sep 17 00:00:00 2001 From: Kaido Kert Date: Sun, 5 Jul 2026 18:07:59 -0700 Subject: [PATCH 1/4] Open 0.1.2-alpha pre-release line Additive alpha cycle for the upcoming byte-trait work (a fallible variable-length FromByteSlice parse). Patch slot, not minor: this crate's change is purely additive; the only breaking change in the broader plan lives in the backend (fixed-bigint), not here. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 3d7862f7..3e1427c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ categories = ["algorithms", "science", "no-std"] license = "MIT OR Apache-2.0" repository = "https://github.com/kaidokert/num-traits" name = "const-num-traits" -version = "0.1.1" +version = "0.1.2-alpha.0" readme = "README.md" # Include-list of the files that ship. Anything not listed here is never packaged. include = [ From 76ed064adf7bdaa3d634ff02d3de9310285f72e8 Mon Sep 17 00:00:00 2001 From: Kaido Kert Date: Sun, 5 Jul 2026 18:36:45 -0700 Subject: [PATCH 2/4] Add FromByteSlice: fallible variable-length integer parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capability (b) from the byte-trait ecosystem plan. FromBytes mirrors core's from_le_bytes — fixed width, infallible — and so can't parse a byte slice whose length the caller doesn't match to the target, nor reject over-length input. FromByteSlice fills that: from_be_slice / from_le_slice take a &[u8] of arbitrary length, zero-extend shorter input, and return a crate-owned ByteSliceError on over-width (never truncating) or empty input. Unsigned-only (zero-extension has no sign-extend analogue); crate-owned error mirrors AsciiParseError so it stays constructible on stable; const-callable on nightly (canary in const_nightly.rs). Additive — no existing item changes. Empty rejected rather than read as 0, so a truncated buffer can't pass as a valid zero (matches FromAscii). No from_ne_slice: native endianness is for fixed-width reinterpretation, not a variable-length parse. --- src/lib.rs | 2 + src/ops/byte_slice.rs | 212 +++++++++++++++++++++++++++++++++++++++++ src/ops/mod.rs | 1 + tests/const_nightly.rs | 9 +- 4 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 src/ops/byte_slice.rs diff --git a/src/lib.rs b/src/lib.rs index 391cda40..f2df7a9e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -83,6 +83,7 @@ pub use crate::ops::bits::{ BitWidth, DepositBits, ExtractBits, FunnelShl, FunnelShr, HighestOne, IsolateHighestOne, IsolateLowestOne, LowestOne, ShlExact, ShrExact, UnboundedShl, UnboundedShr, }; +pub use crate::ops::byte_slice::{ByteSliceError, ByteSliceErrorKind, FromByteSlice}; pub use crate::ops::bytes::{FromBytes, ToBytes}; pub use crate::ops::carrying::{BorrowingSub, CarryingAdd, CarryingMul, WideningMul}; pub use crate::ops::checked::{ @@ -177,6 +178,7 @@ pub mod prelude { pub use crate::identities::*; pub use crate::int::*; pub use crate::ops::bits::*; + pub use crate::ops::byte_slice::*; pub use crate::ops::bytes::*; pub use crate::ops::carrying::*; pub use crate::ops::checked::*; diff --git a/src/ops/byte_slice.rs b/src/ops/byte_slice.rs new file mode 100644 index 00000000..76879f51 --- /dev/null +++ b/src/ops/byte_slice.rs @@ -0,0 +1,212 @@ +//! Parsing an unsigned integer from a byte slice of *arbitrary* length. +//! +//! [`FromBytes`](super::bytes::FromBytes) mirrors core's `from_le_bytes` — the +//! byte count is a type-level constant (`[u8; N]`) and the read is infallible. +//! This is the variable-length cousin: the input is a `&[u8]` whose length the +//! caller doesn't have to match to the target width, so the parse can fail +//! (input wider than the target), which `FromBytes` has no channel to report. +//! Shorter-than-width input is zero-extended; equal is exact; wider errors. +//! +//! Unsigned-only: zero-extension is unambiguous for magnitudes but has no +//! sign-extension analogue, so signed targets are deliberately excluded. +//! +//! **CT tier A (structure-only)** for a fixed-length input: control flow +//! branches only on `bytes.len()` (emptiness, over-width), never on byte +//! *values*, so a constant-length caller is constant-time. + +use core::fmt; + +/// The reason a [`FromByteSlice`] parse failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ByteSliceErrorKind { + /// The input slice was empty. An empty slice is rejected rather than read + /// as `0`, so a truncated or missing buffer can't masquerade as a valid + /// zero value. + Empty, + /// The input had more bytes than the target type can hold. The value is + /// never silently truncated. + Overflow, +} + +/// Error parsing an integer from a byte slice. +/// +/// Crate-owned (mirrors the [`AsciiParseError`](super::from_ascii::AsciiParseError) +/// pattern) so it is constructible on stable and needs no std type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ByteSliceError { + /// What went wrong. + pub kind: ByteSliceErrorKind, +} + +impl fmt::Display for ByteSliceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let description = match self.kind { + ByteSliceErrorKind::Empty => "cannot parse integer from empty byte slice", + ByteSliceErrorKind::Overflow => "byte slice too long for target type", + }; + description.fmt(f) + } +} + +c0nst::c0nst! { +/// Parses an unsigned integer from a byte slice of arbitrary length. +/// +/// Input shorter than the target width is zero-extended (leading zeros for +/// big-endian, trailing for little-endian); input wider than the target is +/// rejected with [`ByteSliceErrorKind::Overflow`] — never truncated. An empty +/// slice is [`ByteSliceErrorKind::Empty`], not `0`. +pub c0nst trait FromByteSlice: Sized { + /// Parses from a big-endian byte slice. + /// + /// ``` + /// use const_num_traits::FromByteSlice; + /// + /// assert_eq!(::from_be_slice(&[0x12, 0x34]), Ok(0x1234)); + /// assert_eq!(::from_be_slice(&[1, 2, 3, 4]), Ok(0x0102_0304)); + /// assert!(::from_be_slice(&[1, 2, 3]).is_err()); // too wide + /// assert!(::from_be_slice(&[]).is_err()); // empty + /// ``` + fn from_be_slice(bytes: &[u8]) -> Result; + + /// Parses from a little-endian byte slice. + /// + /// ``` + /// use const_num_traits::FromByteSlice; + /// + /// assert_eq!(::from_le_slice(&[0x34, 0x12]), Ok(0x1234)); + /// assert_eq!(::from_le_slice(&[1, 2, 3, 4]), Ok(0x0403_0201)); + /// ``` + fn from_le_slice(bytes: &[u8]) -> Result; +} +} + +macro_rules! from_byte_slice_impl { + ($($t:ty)*) => {$( + c0nst::c0nst! { + c0nst impl FromByteSlice for $t { + fn from_be_slice(bytes: &[u8]) -> Result { + if bytes.len() == 0 { + return Err(ByteSliceError { kind: ByteSliceErrorKind::Empty }); + } + let width = core::mem::size_of::<$t>(); + if bytes.len() > width { + return Err(ByteSliceError { kind: ByteSliceErrorKind::Overflow }); + } + // right-align into a zero buffer, then reinterpret — avoids the + // `<< 8` shift that would overflow a single-byte accumulator. + let mut buf = [0u8; core::mem::size_of::<$t>()]; + let off = width - bytes.len(); + let mut i = 0; + while i < bytes.len() { + buf[off + i] = bytes[i]; + i += 1; + } + Ok(<$t>::from_be_bytes(buf)) + } + + fn from_le_slice(bytes: &[u8]) -> Result { + if bytes.len() == 0 { + return Err(ByteSliceError { kind: ByteSliceErrorKind::Empty }); + } + let width = core::mem::size_of::<$t>(); + if bytes.len() > width { + return Err(ByteSliceError { kind: ByteSliceErrorKind::Overflow }); + } + let mut buf = [0u8; core::mem::size_of::<$t>()]; + let mut i = 0; + while i < bytes.len() { + buf[i] = bytes[i]; + i += 1; + } + Ok(<$t>::from_le_bytes(buf)) + } + } + } + )*}; +} + +from_byte_slice_impl!(usize u8 u16 u32 u64 u128); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_width() { + assert_eq!( + ::from_be_slice(&[1, 2, 3, 4]), + Ok(0x0102_0304) + ); + assert_eq!( + ::from_le_slice(&[1, 2, 3, 4]), + Ok(0x0403_0201) + ); + assert_eq!(::from_be_slice(&[0xff]), Ok(0xff)); + assert_eq!(::from_le_slice(&[0xff]), Ok(0xff)); + } + + #[test] + fn zero_extends_short_input() { + // BE: fewer bytes are the least-significant end + assert_eq!( + ::from_be_slice(&[0x12, 0x34]), + Ok(0x0000_1234) + ); + // LE: fewer bytes are the least-significant end too + assert_eq!( + ::from_le_slice(&[0x34, 0x12]), + Ok(0x0000_1234) + ); + assert_eq!(::from_be_slice(&[1]), Ok(1)); + } + + #[test] + fn round_trips_be_le() { + let v = 0x0102_0304u32; + assert_eq!( + ::from_be_slice(&v.to_be_bytes()), + Ok(v) + ); + assert_eq!( + ::from_le_slice(&v.to_le_bytes()), + Ok(v) + ); + } + + #[test] + fn empty_is_error_not_zero() { + use ByteSliceErrorKind::*; + assert_eq!( + ::from_be_slice(&[]).unwrap_err().kind, + Empty + ); + assert_eq!( + ::from_le_slice(&[]).unwrap_err().kind, + Empty + ); + } + + #[test] + fn too_wide_is_overflow() { + use ByteSliceErrorKind::*; + assert_eq!( + ::from_be_slice(&[1, 2, 3]) + .unwrap_err() + .kind, + Overflow + ); + // over-width even when the surplus high bytes are zero: length-based. + assert_eq!( + ::from_be_slice(&[0, 1, 2]) + .unwrap_err() + .kind, + Overflow + ); + assert_eq!( + ::from_le_slice(&[1, 2]) + .unwrap_err() + .kind, + Overflow + ); + } +} diff --git a/src/ops/mod.rs b/src/ops/mod.rs index c5970c8b..538d4cb5 100644 --- a/src/ops/mod.rs +++ b/src/ops/mod.rs @@ -1,4 +1,5 @@ pub mod bits; +pub mod byte_slice; pub mod bytes; pub mod carrying; pub mod checked; diff --git a/tests/const_nightly.rs b/tests/const_nightly.rs index 7c808385..cb5bc0f5 100644 --- a/tests/const_nightly.rs +++ b/tests/const_nightly.rs @@ -16,7 +16,9 @@ use const_num_traits::{ ShlExact, StrictAdd, StrictEuclid, Truncate, UnboundedShr, UnsignedAbs, Widen, WideningMul, WrappingPow, }; -use const_num_traits::{Algebraic, FloatBits, FromAscii, FromBytes, Maximum, NextUp, ToBytes}; +use const_num_traits::{ + Algebraic, FloatBits, FromAscii, FromByteSlice, FromBytes, Maximum, NextUp, ToBytes, +}; #[test] fn bytes_in_const() { @@ -159,6 +161,10 @@ fn ascii_and_floats_in_const() { Ok(v) => v, Err(_) => panic!("parse failed"), }; + const SLICE: u32 = match ::from_be_slice(&[0x12, 0x34]) { + Ok(v) => v, + Err(_) => panic!("parse failed"), + }; const BITS: u32 = FloatBits::to_bits(1.0f32); const ONE: f32 = FloatBits::from_bits(0x3F80_0000u32); const UP: f32 = NextUp::next_up(1.0f32); @@ -166,6 +172,7 @@ fn ascii_and_floats_in_const() { const ALG: f64 = Algebraic::algebraic_mul(3.0f64, 0.5); assert_eq!(HEX, 0xdead_beef); assert_eq!(NEG, -1234); + assert_eq!(SLICE, 0x1234); assert_eq!(BITS, 0x3F80_0000); assert_eq!(ONE, 1.0); assert_eq!(UP, f32::from_bits(0x3F80_0001)); From 6cdc5ec54acadeddcd5e10a2b4d8b27e07ba5616 Mon Sep 17 00:00:00 2001 From: Kaido Kert Date: Sun, 5 Jul 2026 18:41:29 -0700 Subject: [PATCH 3/4] byte_slice: drop sibling-lineage framing from docs The module header and ByteSliceError doc justified themselves by analogy to FromBytes / AsciiParseError. A reader doesn't care which trait it was modeled on, and the reference rots if the sibling changes. State the contract and the reason (fallible because an over-long slice has no lossless read; crate-owned because std's parse error is opaque) on their own terms. --- src/ops/byte_slice.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/ops/byte_slice.rs b/src/ops/byte_slice.rs index 76879f51..eef47d03 100644 --- a/src/ops/byte_slice.rs +++ b/src/ops/byte_slice.rs @@ -1,11 +1,9 @@ //! Parsing an unsigned integer from a byte slice of *arbitrary* length. //! -//! [`FromBytes`](super::bytes::FromBytes) mirrors core's `from_le_bytes` — the -//! byte count is a type-level constant (`[u8; N]`) and the read is infallible. -//! This is the variable-length cousin: the input is a `&[u8]` whose length the -//! caller doesn't have to match to the target width, so the parse can fail -//! (input wider than the target), which `FromBytes` has no channel to report. -//! Shorter-than-width input is zero-extended; equal is exact; wider errors. +//! The input `&[u8]` need not match the target's byte width: shorter input +//! zero-extends, equal is exact, wider is rejected rather than truncated. That +//! reject case is why the parse returns a `Result` — an over-long slice has no +//! lossless reading. //! //! Unsigned-only: zero-extension is unambiguous for magnitudes but has no //! sign-extension analogue, so signed targets are deliberately excluded. @@ -30,8 +28,8 @@ pub enum ByteSliceErrorKind { /// Error parsing an integer from a byte slice. /// -/// Crate-owned (mirrors the [`AsciiParseError`](super::from_ascii::AsciiParseError) -/// pattern) so it is constructible on stable and needs no std type. +/// Crate-owned so it stays constructible on stable — the standard library's +/// integer-parse error is opaque and can't be built outside `core`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ByteSliceError { /// What went wrong. From 4d9c51028623ad736ca0654411346209daba7b85 Mon Sep 17 00:00:00 2001 From: Kaido Kert Date: Sun, 5 Jul 2026 18:47:54 -0700 Subject: [PATCH 4/4] Set version to 0.1.2 (drop pre-release suffix) A downstream `= "0.1.2"` requirement doesn't match a `0.1.2-alpha.0` pre-release without exact opt-in, which makes prototyping the byte-trait integration awkward. Carry the plain version and mark the alpha/prototype status with the git tag (v0.1.2-alpha.0) instead. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 3e1427c4..bce0826a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ categories = ["algorithms", "science", "no-std"] license = "MIT OR Apache-2.0" repository = "https://github.com/kaidokert/num-traits" name = "const-num-traits" -version = "0.1.2-alpha.0" +version = "0.1.2" readme = "README.md" # Include-list of the files that ship. Anything not listed here is never packaged. include = [