rational_extensions

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

commit cb45a315815bdaba737ed9ff734991662385eef3
Author: Zack Newman <zack@philomathiclife.com>
Date:   Sun,  5 Mar 2023 18:31:27 -0700

initial

Diffstat:
ACargo.toml | 25+++++++++++++++++++++++++
ALICENSE-APACHE | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ALICENSE-MIT | 20++++++++++++++++++++
AREADME.md | 8++++++++
Asrc/lib.rs | 404+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 659 insertions(+), 0 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +authors = ["Zack Newman <zack@philomathiclife.com>"] +categories = ["algorithms", "science", "no-std"] +description = "Extensions for rational numbers." +documentation = "https://docs.rs/rational_extensions" +edition = "2021" +keywords = ["mathematics", "numerics"] +license = "MIT OR Apache-2.0" +name = "rational_extensions" +readme = "README.md" +repository = "https://git.philomathiclife.com/rational_extensions/" +version = "0.1.1" + +[lib] +name = "rational_extensions" +path = "src/lib.rs" + +[dependencies] +num-integer = { version = "0.1.45", default-features = false } +num-rational = { version = "0.4.1", default-features = false } +num-traits = { version = "0.2.15", default-features = false } +serde = { version = "1.0.152", default-features = false } + +[dev-dependencies] +serde_json = { version = "1.0.91", default-features = false, features = ["alloc"] } diff --git a/LICENSE-APACHE b/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT @@ -0,0 +1,20 @@ +Copyright © 2023 Zack Newman + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +“Software”), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md @@ -0,0 +1,8 @@ +rational_extensions +-------- +rational_extensions is a library that extends `num_rational::Ratio<T>` +such that strings in decimal notation can be converted into such an +instance. One can furthermore control the minimum and maximum number of +fractional digits allowed. It also allows one to convert a `Ratio<T>` +into a decimal `String` controlling how many fractional digits should be +used. diff --git a/src/lib.rs b/src/lib.rs @@ -0,0 +1,404 @@ +//! This crate extends how `num_rational::Ratio<T>` can be converted +//! from a string specifically by allowing decimal notation with the +//! ability to constrain the minimum and maximum number of fractional +//! digits allowed. +#![no_std] +#![no_implicit_prelude] +#![deny( + unsafe_code, + unused, + warnings, + clippy::all, + clippy::cargo, + clippy::nursery, + clippy::pedantic +)] +#![allow( + clippy::implicit_return, + clippy::missing_trait_methods, + clippy::unseparated_literal_suffix +)] +extern crate alloc; +#[allow(unused_extern_crates)] +extern crate core; +#[allow(unused_extern_crates)] +extern crate num_integer; +#[allow(unused_extern_crates)] +extern crate num_rational; +#[allow(unused_extern_crates)] +extern crate num_traits; +#[allow(unused_extern_crates)] +extern crate serde; +use crate::FromDecStrErr::{IntParseErr, TooFewFractionalDigits, TooManyFractionalDigits}; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::clone::Clone; +use core::cmp::PartialOrd; +use core::convert::From; +use core::fmt::{self, Debug, Display, Formatter}; +use core::marker::PhantomData; +use core::ops::Mul; +use core::option::Option; +use core::result::Result::{self, Err, Ok}; +use core::str::FromStr; +use core::{unreachable, write}; +use num_integer::Integer; +use num_rational::Ratio; +use num_traits::Pow; +/// An ordered pair whose first value is <= to the second. +pub struct MinMax<T> { + /// The first value which is <= the second. + min: T, + /// The second value which is >= the first. + max: T, +} +impl<T> MinMax<T> { + /// Returns a reference to the first value. + pub const fn min(&self) -> &T { + &self.min + } + /// Returns a reference to the second value. + pub const fn max(&self) -> &T { + &self.max + } +} +impl<T> MinMax<T> +where + T: PartialOrd<T>, +{ + /// Returns `None` iff `min` `>` `max`. + pub fn new(min: T, max: T) -> Option<Self> { + (min <= max).then_some(Self { min, max }) + } + /// Returns `MinMax` without verifying `min` `<=` `max`. + + /// # Safety + /// + /// `max` `>=` `min`. + #[allow(unsafe_code)] + pub const unsafe fn new_unchecked(min: T, max: T) -> Self { + Self { min, max } + } +} +/// 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), + /// The variant returned when a decimal string has fewer rational + /// digits than allowed. + TooFewFractionalDigits(usize), + /// The variant returned when a decimal string has more rational + /// digits than allowed. + TooManyFractionalDigits(usize), +} +impl<T> Display for FromDecStrErr<T> +where + T: Display, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match *self { + IntParseErr(ref x) => x.fmt(f), + TooFewFractionalDigits(ref x) => write!( + f, + "There were only {x} fractional digits which is fewer than the minimum required." + ), + TooManyFractionalDigits(ref x) => write!( + f, + "There were {x} fractional digits which is more than the maximum required." + ), + } + } +} +impl<T> Debug for FromDecStrErr<T> +where + T: Display, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + <Self as Display>::fmt(self, f) + } +} +impl<T> From<T> for FromDecStrErr<T> { + fn from(x: T) -> Self { + IntParseErr(x) + } +} +/// Converts a string in decimal notation into a `Ratio<T>`. + +/// # 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(clippy::arithmetic_side_effects, clippy::single_char_lifetime_names)] +pub fn try_from_dec_str<T>( + val: &str, + frac_digit_count: &MinMax<usize>, +) -> Result<Ratio<T>, FromDecStrErr<<T as FromStr>::Err>> +where + T: Clone + + From<u8> + + FromStr + + Integer + + for<'a> Mul<&'a T, Output = T> + + Pow<usize, Output = T>, +{ + val.split_once('.').map_or_else( + || { + if *frac_digit_count.min() == 0 { + Ok(Ratio::from(T::from_str(val)?)) + } else { + Err(TooFewFractionalDigits(val.len())) + } + }, + |(l, r)| { + if r.len() >= *frac_digit_count.min() { + if r.len() <= *frac_digit_count.max() { + let mult = T::from(10).pow(r.len()); + Ok(Ratio::new( + (T::from_str(l)? * &mult) + T::from_str(r)?, + mult, + )) + } else { + Err(TooManyFractionalDigits(r.len())) + } + } else { + Err(TooFewFractionalDigits(r.len())) + } + }, + ) +} +/// 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), + /// The variant that is returned when a string in rational + /// notation has a denominator that is zero. + DenominatorIsZero, +} +impl<T> Display for FromStrErr<T> +where + T: Display, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match *self { + Self::IntParseErr(ref x) => x.fmt(f), + Self::DenominatorIsZero => f.write_str("denominator is zero"), + } + } +} +impl<T> Debug for FromStrErr<T> +where + T: Display, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + <Self as Display>::fmt(self, f) + } +} +impl<T> From<T> for FromStrErr<T> { + fn from(x: T) -> Self { + Self::IntParseErr(x) + } +} +/// Converts a string in rational or decimal notation into a `Ratio<T>`. + +/// # Errors +/// +/// Will return `FromStrErr` iff `val` is not a rational number in +/// rational or decimal notation. +#[allow( + unsafe_code, + clippy::arithmetic_side_effects, + clippy::single_char_lifetime_names, + clippy::unreachable +)] +pub fn try_from_str<T>(val: &str) -> Result<Ratio<T>, FromStrErr<<T as FromStr>::Err>> +where + T: Clone + + From<u8> + + FromStr + + Integer + + for<'a> Mul<&'a T, Output = T> + + Pow<usize, Output = T>, +{ + val.split_once('/').map_or_else( + || { + // SAFETY: + // usize::MAX >= 0 + try_from_dec_str(val, &unsafe{MinMax::new_unchecked(0, usize::MAX)}).map_err(|e| match e { + 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"), + }) + }, + |(l, r)| { + let denom = T::from_str(r)?; + if denom == T::from(0) { + Err(FromStrErr::DenominatorIsZero) + } else { + l.split_once(' ').map_or_else( + || Ok(Ratio::new(T::from_str(l)?, denom.clone())), + |(l2, r2)| { + Ok(Ratio::new( + (T::from_str(l2)? * &denom) + T::from_str(r2)?, + denom.clone(), + )) + }, + ) + } + }, + ) +} +/// Returns a `String` representing `val` in decimal notation with +/// `frac_digit_count` fractional digits using normal rounding rules. +#[allow( + unsafe_code, + clippy::arithmetic_side_effects, + clippy::integer_arithmetic +)] +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>, +{ + 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 + 1); + v.extend_from_slice(int.to_string().as_bytes()); + if frac_digit_count > 0 { + v.push(b'.'); + let len = v.len(); + let frac_str = frac.to_string(); + while v.len() < len + (frac_digit_count - frac_str.len()) { + v.push(b'0'); + } + v.extend_from_slice(frac.to_string().as_bytes()); + } + // SAFETY: + // Each byte in v corresponds to the UTF-8 code unit used to encode + // U+0030–U+0039 (i.e., decimal digits). + unsafe { String::from_utf8_unchecked(v) } +} +use serde::de::{self, Deserialize, Deserializer, Unexpected, Visitor}; +/// Wrapper around a `num_rational::Ratio` that +/// deserializes a JSON string representing a rational number in +/// rational or decimal notation to a Ratio&lt;T&gt;. +#[allow(clippy::exhaustive_structs)] +pub struct Rational<T>(pub Ratio<T>); +#[allow(clippy::single_char_lifetime_names)] +impl<'de, T> Deserialize<'de> for Rational<T> +where + T: Clone + + From<u8> + + FromStr + + Integer + + for<'a> Mul<&'a T, Output = T> + + Pow<usize, Output = T>, +{ + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// Visitor used to deserialize a JSON string into a Rational. + struct RationalVisitor<T> { + /// 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> + where + T: Clone + + From<u8> + + FromStr + + Integer + + for<'a> Mul<&'a T, Output = T> + + Pow<usize, Output = T>, + { + type Value = Rational<T>; + 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, + { + try_from_str(v).map_or_else( + |_| { + Err(E::invalid_value( + Unexpected::Str(v), + &"a rational number in fraction or decimal notation", + )) + }, + |r| Ok(Rational(r)), + ) + } + } + deserializer.deserialize_str(RationalVisitor { _x: PhantomData }) + } +} +#[cfg(test)] +mod tests { + #[allow(unused_extern_crates)] + extern crate serde_json; + use super::*; + use alloc::format; + use core::assert_eq; + use core::num::ParseIntError; + + #[test] + fn test_min_max() -> Result<(), String> { + let mut m: MinMax<u32>; + for i in 0..1000 { + for j in 0..1000 { + m = MinMax::new(i, i + j) + .ok_or_else(|| format!("Failed for {} and {}.", i, i + j))?; + assert_eq!(*m.min(), i); + assert_eq!(*m.max(), i + j); + } + } + Ok(()) + } + #[test] + #[should_panic] + fn test_min_max_err() { + MinMax::new(f64::NAN, 0.0).unwrap(); + } + #[test] + fn test_dec_string() -> Result<(), String> { + assert_eq!("0", &to_dec_string(&Ratio::<u32>::new(0, 1), 0)); + assert_eq!("5.000", &to_dec_string(&Ratio::<u32>::new(5, 1), 3)); + assert_eq!("0.66667", &to_dec_string(&Ratio::<u32>::new(2, 3), 5)); + Ok(()) + } + #[test] + fn test_from_str() -> Result<(), FromStrErr<ParseIntError>> { + assert_eq!(try_from_str::<u32>("4")?, Ratio::new(4, 1)); + assert_eq!(try_from_str::<u32>("4/8")?, Ratio::new(1, 2)); + assert_eq!(try_from_str::<u32>("5 9/8")?, Ratio::new(49, 8)); + assert_eq!(try_from_str::<u32>("0.1249")?, Ratio::new(1249, 10000)); + assert_eq!(try_from_str::<u32>("0.0")?, Ratio::new(0, 1)); + try_from_str::<u32>("1/0").map_or_else( + |e| match e { + FromStrErr::DenominatorIsZero => (), + _ => assert_eq!(false, true), + }, + |_| assert_eq!(false, true), + ); + Ok(()) + } + #[test] + fn test_serde() -> Result<(), serde_json::Error> { + assert_eq!( + Ratio::new(2u8, 3u8), + serde_json::from_str::<Rational::<u8>>(r#""2/3""#)?.0 + ); + assert_eq!( + Ratio::new(67u8, 100u8), + serde_json::from_str::<Rational::<u8>>(r#""0.67""#)?.0 + ); + Ok(()) + } +}