rational_extensions

Extends num_rational::Ratio<T>.
git clone https://git.philomathiclife.com/repos/rational_extensions
Log | Files | Refs | README

commit 951200277530bc4588f36bcf49a57ccb1d19e5f7
parent 723a956cf8af18f1ebbedce384edbf79f16e60c1
Author: Zack Newman <zack@philomathiclife.com>
Date:   Tue, 26 Aug 2025 08:51:55 -0600

cleanup. fix macos compile. more lints. docs

Diffstat:
MCargo.toml | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Dbuild.rs | 12------------
Msrc/lib.rs | 216++++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/rational.rs | 28+++++-----------------------
4 files changed, 201 insertions(+), 163 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -3,36 +3,104 @@ authors = ["Zack Newman <zack@philomathiclife.com>"] categories = ["algorithms", "science", "no-std"] description = "Extensions for rational numbers." documentation = "https://docs.rs/rational_extensions" -edition = "2021" +edition = "2024" keywords = ["mathematics", "numerics"] license = "MIT OR Apache-2.0" name = "rational_extensions" readme = "README.md" repository = "https://git.philomathiclife.com/repos/rational_extensions/" -version = "0.4.1" +rust-version = "1.88.0" +version = "0.4.2" -[lib] -name = "rational_extensions" -path = "src/lib.rs" +[lints.rust] +ambiguous_negative_literals = { level = "deny", priority = -1 } +closure_returning_async_block = { level = "deny", priority = -1 } +deprecated_safe = { level = "deny", priority = -1 } +deref_into_dyn_supertrait = { level = "deny", priority = -1 } +ffi_unwind_calls = { level = "deny", priority = -1 } +future_incompatible = { level = "deny", priority = -1 } +#fuzzy_provenance_casts = { level = "deny", priority = -1 } +impl_trait_redundant_captures = { level = "deny", priority = -1 } +keyword_idents = { level = "deny", priority = -1 } +let_underscore = { level = "deny", priority = -1 } +#linker_messages = { level = "deny", priority = -1 } +#lossy_provenance_casts = { level = "deny", priority = -1 } +macro_use_extern_crate = { level = "deny", priority = -1 } +meta_variable_misuse = { level = "deny", priority = -1 } +missing_copy_implementations = { level = "deny", priority = -1 } +missing_debug_implementations = { level = "deny", priority = -1 } +missing_docs = { level = "deny", priority = -1 } +#multiple_supertrait_upcastable = { level = "deny", priority = -1 } +#must_not_suspend = { level = "deny", priority = -1 } +non_ascii_idents = { level = "deny", priority = -1 } +#non_exhaustive_omitted_patterns = { level = "deny", priority = -1 } +nonstandard_style = { level = "deny", priority = -1 } +redundant_imports = { level = "deny", priority = -1 } +redundant_lifetimes = { level = "deny", priority = -1 } +refining_impl_trait = { level = "deny", priority = -1 } +rust_2018_compatibility = { level = "deny", priority = -1 } +rust_2018_idioms = { level = "deny", priority = -1 } +rust_2021_compatibility = { level = "deny", priority = -1 } +rust_2024_compatibility = { level = "deny", priority = -1 } +single_use_lifetimes = { level = "deny", priority = -1 } +#supertrait_item_shadowing_definition = { level = "deny", priority = -1 } +trivial_casts = { level = "deny", priority = -1 } +trivial_numeric_casts = { level = "deny", priority = -1 } +unit_bindings = { level = "deny", priority = -1 } +unnameable_types = { level = "deny", priority = -1 } +#unqualified_local_imports = { level = "deny", priority = -1 } +unreachable_pub = { level = "deny", priority = -1 } +unsafe_code = { level = "deny", priority = -1 } +unstable_features = { level = "deny", priority = -1 } +unused = { level = "deny", priority = -1 } +unused_crate_dependencies = { level = "deny", priority = -1 } +unused_import_braces = { level = "deny", priority = -1 } +unused_lifetimes = { level = "deny", priority = -1 } +unused_qualifications = { level = "deny", priority = -1 } +unused_results = { level = "deny", priority = -1 } +variant_size_differences = { level = "deny", priority = -1 } +warnings = { level = "deny", priority = -1 } -[dependencies] -num-integer = { version = "0.1.45", default-features = false } -num-rational = { version = "0.4.1", default-features = false } -num-traits = { version = "0.2.16", default-features = false } -serde = { version = "1.0.188", default-features = false, optional = true } +[lints.clippy] +all = { level = "deny", priority = -1 } +cargo = { level = "deny", priority = -1 } +complexity = { level = "deny", priority = -1 } +correctness = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } +perf = { level = "deny", priority = -1 } +restriction = { level = "deny", priority = -1 } +style = { level = "deny", priority = -1 } +suspicious = { level = "deny", priority = -1 } +# Noisy, opinionated, and likely don't prevent bugs or improve APIs. +arbitrary_source_item_ordering = "allow" +blanket_clippy_restriction_lints = "allow" +implicit_return = "allow" +min_ident_chars = "allow" +missing_trait_methods = "allow" +exhaustive_enums = "allow" +exhaustive_structs = "allow" +question_mark_used = "allow" +ref_patterns = "allow" +single_call_fn = "allow" +single_char_lifetime_names = "allow" -[build-dependencies] -rustc_version = "0.4.0" +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[dependencies] +num-integer = { version = "0.1.46", default-features = false } +num-rational = { version = "0.4.2", default-features = false } +num-traits = { version = "0.2.19", default-features = false } +serde = { version = "1.0.219", default-features = false, optional = true } [dev-dependencies] -serde_json = { version = "1.0.106", default-features = false, features = ["alloc"] } +serde_json = { version = "1.0.143", default-features = false, features = ["alloc"] } -[features] -rational = ["dep:serde"] -[package.metadata.docs.rs] -all-features = true -rustdoc-args = ["--cfg", "docsrs"] +### FEATURES ################################################################# -[badges] -maintenance = { status = "passively-maintained" } +[features] +# Provide (de)serialization support. +rational = ["dep:serde"] diff --git a/build.rs b/build.rs @@ -1,12 +0,0 @@ -use rustc_version::{version_meta, Channel}; - -fn main() { - // Set cfg flags depending on release channel - let channel = match version_meta().unwrap().channel { - Channel::Stable => "CHANNEL_STABLE", - Channel::Beta => "CHANNEL_BETA", - Channel::Nightly => "CHANNEL_NIGHTLY", - Channel::Dev => "CHANNEL_DEV", - }; - println!("cargo:rustc-cfg={}", channel) -} diff --git a/src/lib.rs b/src/lib.rs @@ -2,33 +2,24 @@ //! from a string specifically by allowing decimal notation with the //! ability to constrain the minimum and maximum number of fractional //! digits allowed. -#![cfg_attr(all(doc, CHANNEL_NIGHTLY), feature(doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #![no_std] -#![deny( - unsafe_code, - unused, - warnings, - clippy::all, - clippy::cargo, - clippy::nursery, - clippy::pedantic -)] -#![allow( - clippy::implicit_return, - clippy::question_mark_used, - clippy::unseparated_literal_suffix -)] extern crate alloc; use crate::FromDecStrErr::{IntParseErr, TooFewFractionalDigits, TooManyFractionalDigits}; -use alloc::string::{String, ToString}; -use alloc::vec::Vec; -use core::fmt::{self, Debug, Display, Formatter}; -use core::ops::Mul; -use core::str::FromStr; +use alloc::{ + string::{String, ToString as _}, + vec::Vec, +}; +use core::{ + fmt::{self, Debug, Display, Formatter}, + ops::Mul, + str::FromStr, +}; use num_integer::Integer; use num_rational::Ratio; use num_traits::Pow; /// An ordered pair whose first value is <= to the second. +#[derive(Clone, Copy, Debug)] pub struct MinMax<T> { /// The first value which is <= the second. min: T, @@ -57,11 +48,14 @@ where (min <= max).then_some(Self { min, max }) } /// Returns `MinMax` without verifying `min` `<=` `max`. - + /// /// # Safety /// /// `min` `<=` `max`. - #[allow(unsafe_code)] + #[expect( + unsafe_code, + reason = "want to expose a function that does not uphold the invariants" + )] #[inline] pub const unsafe fn new_unchecked(min: T, max: T) -> Self { Self { min, max } @@ -69,7 +63,6 @@ where } /// The error returned when parsing a string in decimal notation into /// a `num_rational::Ratio<T>`. -#[allow(clippy::exhaustive_enums)] pub enum FromDecStrErr<T> { /// Contains the error returned when parsing a string into a `T`. IntParseErr(T), @@ -116,17 +109,17 @@ impl<T> From<T> for FromDecStrErr<T> { } /// Converts a string in decimal notation into a `Ratio<T>`. /// +/// # Panics +/// +/// May `panic` if `T` implements arithmetic in a way where `panic`s occur on overflow or underflow. +/// /// # Errors /// -/// Will return `FromDecStrErr` iff `val` is not a valid rational -/// number in decimal notation with number of fractional digits -/// inclusively between `frac_digit_count.min()` and -/// `frac_digit_count.max()`. -#[allow( +/// Will return `FromDecStrErr` iff `val` is not a valid rational number in decimal notation with number of +/// fractional digits inclusively between `frac_digit_count.min()` and `frac_digit_count.max()`. +#[expect( clippy::arithmetic_side_effects, - clippy::indexing_slicing, - clippy::single_char_lifetime_names, - clippy::string_slice + reason = "calling code's responsibility to ensure T implements arithmetic correctly" )] #[inline] pub fn try_from_dec_str<T>( @@ -157,7 +150,10 @@ where let addend = T::from_str(r)?; let zero = T::from(0); Ok(Ratio::new( - if numer < zero || (numer == zero && &l[..1] == "-") { + if numer < zero + || (numer == zero + && l.as_bytes().first().is_some_and(|fst| *fst == b'-')) + { numer - addend } else { numer + addend @@ -175,7 +171,6 @@ where } /// The error returned when parsing a string in decimal or /// rational notation into a `num_rational::Ratio<T>`. -#[allow(clippy::exhaustive_enums)] pub enum FromStrErr<T> { /// Contains the error when a string fails to parse into a `T`. IntParseErr(T), @@ -212,15 +207,18 @@ impl<T> From<T> for FromStrErr<T> { } /// Converts a string in rational or decimal notation into a `Ratio<T>`. /// +/// # Panics +/// +/// May `panic` if `T` implements arithmetic in a way where `panic`s occur on overflow or underflow. +/// /// # Errors /// /// Will return `FromStrErr` iff `val` is not a rational number in /// rational or decimal notation. -#[allow( - unsafe_code, +#[expect(clippy::unreachable, reason = "want to crash when there is a bug")] +#[expect( clippy::arithmetic_side_effects, - clippy::single_char_lifetime_names, - clippy::unreachable + reason = "calling code's responsibility to ensure T implements arithmetic correctly" )] #[inline] pub fn try_from_str<T>(val: &str) -> Result<Ratio<T>, FromStrErr<<T as FromStr>::Err>> @@ -232,82 +230,73 @@ where + for<'a> Mul<&'a T, Output = T> + Pow<usize, Output = T>, { - /// Used as the closure that transforms a `FromDecStrErr` into a `FromStrErr`. - #[inline] - fn dec_err<S>(err: FromDecStrErr<<S as FromStr>::Err>) -> FromStrErr<<S as FromStr>::Err> - where - S: FromStr, - { - match err { - IntParseErr(x) => FromStrErr::IntParseErr(x), - TooFewFractionalDigits(_) | TooManyFractionalDigits(_) => unreachable!("There is a bug in rational::try_from_dec_str. 0 and usize::MAX were passed as the minimum and maximum number of fractional digits allowed respectively, but it still errored due to too few or too many fractional digits"), - } - } - /// Used as the closure that attempts to transform a `String` split from a `/` - /// into a `Ratio<S>`. - #[inline] - fn frac_split<S>(split: (&str, &str)) -> Result<Ratio<S>, FromStrErr<<S as FromStr>::Err>> - where - S: Clone + From<u8> + FromStr + Integer + for<'a> Mul<&'a S, Output = S>, - { - let denom = S::from_str(split.1)?; - let zero = S::from(0); - if denom == zero { - Err(FromStrErr::DenominatorIsZero) - } else { - split.0.split_once(' ').map_or_else( - || Ok(Ratio::new(S::from_str(split.0)?, denom.clone())), - |(l2, r2)| { - let numer = S::from_str(l2)? * &denom; - let addend = S::from_str(r2)?; - Ok(Ratio::new( - if numer < zero { - numer - addend - } else { - numer + addend - }, - denom.clone(), - )) - }, - ) - } - } val.split_once('/').map_or_else( || { - // SAFETY: - // usize::MAX >= 0 - try_from_dec_str(val, &unsafe { MinMax::new_unchecked(0, usize::MAX) }) - .map_err(dec_err::<T>) + try_from_dec_str( + val, + &MinMax { + min: 0, + max: usize::MAX, + }, + ) + .map_err(|err| match err { + IntParseErr(x) => FromStrErr::IntParseErr(x), + TooFewFractionalDigits(_) | TooManyFractionalDigits(_) => unreachable!( + "There is a bug in rational::try_from_dec_str. 0 and usize::MAX were passed as the minimum and maximum number of fractional digits allowed respectively, but it still errored due to too few or too many fractional digits" + ), + }) }, - frac_split, + |split| { + let denom = T::from_str(split.1)?; + let zero = T::from(0); + if denom == zero { + Err(FromStrErr::DenominatorIsZero) + } else { + split.0.split_once(' ').map_or_else( + || Ok(Ratio::new(T::from_str(split.0)?, denom.clone())), + |(l2, r2)| { + let numer = T::from_str(l2)? * &denom; + let addend = T::from_str(r2)?; + Ok(Ratio::new( + if numer < zero { + numer - addend + } else { + numer + addend + }, + denom.clone(), + )) + }, + ) + } + } ) } -/// Returns a `String` representing `val` in decimal notation with -/// `frac_digit_count` fractional digits using normal rounding rules. -#[allow( - unsafe_code, +/// Returns a `String` representing `val` in decimal notation with `frac_digit_count` fractional digits +/// using normal rounding rules. +/// +/// # Panics +/// +/// May `panic` if `T` implements arithmetic in a way where `panic`s occur on overflow or underflow. +#[expect(unsafe_code, reason = "comment justifies correctness")] +#[expect( clippy::arithmetic_side_effects, - clippy::as_conversions, - clippy::cast_lossless, + clippy::expect_used, clippy::indexing_slicing, - clippy::string_slice + reason = "calling code's responsibility to ensure T implements arithmetic correctly" )] #[inline] pub fn to_dec_string<T>(val: &Ratio<T>, frac_digit_count: usize) -> String where T: Clone + Display + From<u8> + Integer + Pow<usize, Output = T>, { - /// Returns 1 iff `start` is `"-"`; otherwise returns 0. - /// This function is used as the closure passed to `map_or` when checking - /// if the first "character" of the fraction string is a negative sign. - #[inline] - fn neg_sign(start: &str) -> usize { - (start == "-") as usize - } let mult = T::from(10).pow(frac_digit_count); let (int, frac) = (val * &mult).round().numer().div_rem(&mult); let int_str = int.to_string(); - let mut v = Vec::with_capacity(int_str.len() + frac_digit_count + 2); + let mut v = Vec::with_capacity( + int_str + .len() + .saturating_add(frac_digit_count.saturating_add(2)), + ); let zero = T::from(0); if int >= zero && frac < zero { v.push(b'-'); @@ -316,30 +305,41 @@ where if frac_digit_count > 0 { v.push(b'.'); let len = v.len(); - let frac_str = frac.to_string(); - let frac_val = &frac_str[frac_str.get(..1).map_or(0, neg_sign)..]; - while v.len() < len + (frac_digit_count - frac_val.len()) { + let frac_vec = frac.to_string().into_bytes(); + // This cannot `panic` since we start at index 0 when it's empty or does not begin with `b'-'`; + // otherwise we start at index 1. + let frac_val = &frac_vec[frac_vec + .first() + .map_or(0, |start| usize::from(*start == b'-'))..]; + // We rely on saturating add. If overflow occurs, then the code will `panic` anyway due to + // the below loop causing the underlying `Vec` to be too large. + let term = len.saturating_add( + frac_digit_count + .checked_sub(frac_val.len()) + .expect("T::to_string returns an unexpected large string"), + ); + while v.len() < term { v.push(b'0'); } - v.extend_from_slice(frac_val.as_bytes()); + v.extend_from_slice(frac_val); } // SAFETY: - // v contains precisely the UTF-8 code units returned from Strings - // returned from the to_string function on the integer and fraction part of + // `v` contains precisely the UTF-8 code units returned from `String`s + // returned from `to_string` on the integer and fraction part of // the passed value plus optionally the single byte encodings of ".", "-", and "0". unsafe { String::from_utf8_unchecked(v) } } +/// Enables deserialization of strings in decimal or fractional format into [`rational::Rational<T>`]. +#[cfg_attr(docsrs, doc(cfg(feature = "rational")))] #[cfg(feature = "rational")] -// Enables deserialization of strings in decimal or fractional format -// into [`rational::Rational<T>`]. pub mod rational; #[cfg(test)] mod tests { use super::*; use alloc::format; - use core::assert_eq; - use core::num::ParseIntError; + use core::{assert_eq, num::ParseIntError}; + use serde_json as _; #[test] fn test_min_max() -> Result<(), String> { @@ -357,7 +357,7 @@ mod tests { #[test] #[should_panic] fn test_min_max_err() { - MinMax::new(f64::NAN, 0.0).unwrap(); + _ = MinMax::new(f64::NAN, 0.0).unwrap(); } #[test] fn test_dec_string() -> Result<(), String> { diff --git a/src/rational.rs b/src/rational.rs @@ -1,14 +1,4 @@ -#![deny( - unsafe_code, - unused, - warnings, - clippy::all, - clippy::cargo, - clippy::nursery, - clippy::pedantic -)] -#![allow(clippy::implicit_return, clippy::missing_trait_methods)] -use crate::{try_from_str, Ratio}; +use crate::{Ratio, try_from_str}; use core::fmt::{self, Formatter}; use core::marker::PhantomData; use core::ops::Mul; @@ -19,9 +9,8 @@ use serde::de::{self, Deserialize, Deserializer, Unexpected, Visitor}; /// Wrapper around a `num_rational::Ratio` that /// deserializes a JSON string representing a rational number in /// fractional or decimal notation to a Ratio&lt;T&gt;. -#[allow(clippy::exhaustive_structs)] +#[derive(Clone, Copy, Debug)] pub struct Rational<T>(pub Ratio<T>); -#[allow(clippy::single_char_lifetime_names)] impl<'de, T> Deserialize<'de> for Rational<T> where T: Clone @@ -41,8 +30,7 @@ where /// Does not own nor drop a `T`. _x: PhantomData<fn() -> T>, } - #[allow(clippy::single_char_lifetime_names)] - impl<'de, T> Visitor<'de> for RationalVisitor<T> + impl<T> Visitor<'_> for RationalVisitor<T> where T: Clone + From<u8> @@ -52,19 +40,13 @@ where + Pow<usize, Output = T>, { type Value = Rational<T>; - fn expecting(&self, formatter: &mut Formatter) -> fmt::Result { + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { formatter.write_str("struct Rational") } fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> where E: de::Error, { - #[allow(clippy::unnecessary_wraps)] - /// Used as the closure that maps a `Ratio<R>`into an `Ok(Rational<R>)`. - #[inline] - const fn to_rational<R, E2>(val: Ratio<R>) -> Result<Rational<R>, E2> { - Ok(Rational(val)) - } try_from_str(v).map_or_else( |_| { Err(E::invalid_value( @@ -72,7 +54,7 @@ where &"a rational number in fraction or decimal notation", )) }, - to_rational, + |val| Ok(Rational(val)), ) } }