From e46d9a2488664b368dd0bd12a46036e657ae8451 Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Mon, 5 Oct 2026 16:22:48 +0000 Subject: [PATCH] Add Float macro and serde crates Moves rain-math-float-macro and rain-math-float-serde from rainlanguage/rain.math.float#249 into the Rust workspace here, and publishes both on merge as a unit alongside the existing crate release. Co-authored-by: 0xgleb <39841057+0xgleb@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .github/workflows/crate-npm-release.yaml | 5 + Cargo.lock | 131 ++++- Cargo.toml | 4 + crates/float-macro/Cargo.toml | 19 + crates/float-macro/src/lib.rs | 246 ++++++++++ .../tests/compile_fail/dot_only.rs | 6 + .../tests/compile_fail/dot_only.stderr | 7 + .../compile_fail/float_result_overflow.rs | 7 + .../compile_fail/float_result_overflow.stderr | 7 + .../tests/compile_fail/negative_overflow.rs | 6 + .../compile_fail/negative_overflow.stderr | 7 + .../tests/compile_fail/overflow.rs | 6 + .../tests/compile_fail/overflow.stderr | 7 + .../tests/compile_fail/trailing_dot.rs | 6 + .../tests/compile_fail/trailing_dot.stderr | 7 + crates/float-macro/tests/float_macro.rs | 122 +++++ crates/float-serde/Cargo.toml | 16 + crates/float-serde/src/lib.rs | 446 ++++++++++++++++++ 18 files changed, 1043 insertions(+), 12 deletions(-) create mode 100644 crates/float-macro/Cargo.toml create mode 100644 crates/float-macro/src/lib.rs create mode 100644 crates/float-macro/tests/compile_fail/dot_only.rs create mode 100644 crates/float-macro/tests/compile_fail/dot_only.stderr create mode 100644 crates/float-macro/tests/compile_fail/float_result_overflow.rs create mode 100644 crates/float-macro/tests/compile_fail/float_result_overflow.stderr create mode 100644 crates/float-macro/tests/compile_fail/negative_overflow.rs create mode 100644 crates/float-macro/tests/compile_fail/negative_overflow.stderr create mode 100644 crates/float-macro/tests/compile_fail/overflow.rs create mode 100644 crates/float-macro/tests/compile_fail/overflow.stderr create mode 100644 crates/float-macro/tests/compile_fail/trailing_dot.rs create mode 100644 crates/float-macro/tests/compile_fail/trailing_dot.stderr create mode 100644 crates/float-macro/tests/float_macro.rs create mode 100644 crates/float-serde/Cargo.toml create mode 100644 crates/float-serde/src/lib.rs diff --git a/.github/workflows/crate-npm-release.yaml b/.github/workflows/crate-npm-release.yaml index 191b563..b2f78d8 100644 --- a/.github/workflows/crate-npm-release.yaml +++ b/.github/workflows/crate-npm-release.yaml @@ -20,3 +20,8 @@ jobs: crate: rain-math-float npm-package: "@rainlanguage/float" secrets: inherit + release-float-macro-serde: + uses: rainlanguage/rainix/.github/workflows/rainix-autopublish.yaml@main + with: + crates: "rain-math-float-macro rain-math-float-serde" + secrets: inherit diff --git a/Cargo.lock b/Cargo.lock index 0a1efb4..959a8c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -231,7 +231,7 @@ dependencies = [ "derive_more", "foldhash", "hashbrown 0.17.1", - "indexmap 2.9.0", + "indexmap 2.14.2", "itoa", "k256", "keccak-asm", @@ -358,7 +358,7 @@ dependencies = [ "alloy-sol-macro-input", "const-hex", "heck", - "indexmap 2.9.0", + "indexmap 2.14.2", "proc-macro-error2", "proc-macro2", "quote", @@ -1678,14 +1678,15 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.9.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "arbitrary", "equivalent", - "hashbrown 0.15.4", + "hashbrown 0.17.1", "serde", + "serde_core", ] [[package]] @@ -2228,6 +2229,25 @@ dependencies = [ "wasm-bindgen-utils", ] +[[package]] +name = "rain-math-float-macro" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "rain-math-float", + "trybuild", +] + +[[package]] +name = "rain-math-float-serde" +version = "0.1.0" +dependencies = [ + "alloy", + "rain-math-float", + "serde", + "serde_json", +] + [[package]] name = "rain-math-float-wasm" version = "0.1.0" @@ -2792,13 +2812,22 @@ version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ - "indexmap 2.9.0", + "indexmap 2.14.2", "itoa", "memchr", "ryu", "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_with" version = "3.12.0" @@ -2809,7 +2838,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.9.0", + "indexmap 2.14.2", "serde", "serde_derive", "serde_json", @@ -3001,6 +3030,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-triple" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171" + [[package]] name = "tempfile" version = "3.20.0" @@ -3014,6 +3049,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "2.0.12" @@ -3074,23 +3118,62 @@ dependencies = [ "time-core", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.9.0", - "toml_datetime", - "winnow 0.7.11", + "indexmap 2.14.2", + "toml_datetime 0.6.11", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.3", ] +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tracing" version = "0.1.41" @@ -3132,6 +3215,21 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "trybuild" +version = "1.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f614c21bd3a61bad9501d75cbb7686f00386c806d7f456778432c25cf86948a" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", +] + [[package]] name = "tsify" version = "0.4.5" @@ -3316,6 +3414,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -3450,9 +3557,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.11" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 7955f41..2a9c072 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ alloy = { version = "1.0.9", default-features = false, features = [ "json", "json-abi", ] } +alloy-primitives = "1.0.9" revm = { version = "36", default-features = false, features = [ "portable", "std", @@ -22,4 +23,7 @@ revm = { version = "36", default-features = false, features = [ ] } thiserror = "2.0.12" proptest = "1.7.0" +rain-math-float = { path = "crates/float", version = "0.1.13" } serde = "1.0.219" +serde_json = "1.0.140" +trybuild = "=1.0.115" diff --git a/crates/float-macro/Cargo.toml b/crates/float-macro/Cargo.toml new file mode 100644 index 0000000..7e3c993 --- /dev/null +++ b/crates/float-macro/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "rain-math-float-macro" +edition.workspace = true +version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +description = "Compile-time and fallible literal macros for rain-math-float" + +[lib] +proc-macro = true + +[dependencies] +rain-math-float.workspace = true + +[dev-dependencies] +alloy-primitives.workspace = true +rain-math-float.workspace = true +trybuild.workspace = true diff --git a/crates/float-macro/src/lib.rs b/crates/float-macro/src/lib.rs new file mode 100644 index 0000000..a39f4a4 --- /dev/null +++ b/crates/float-macro/src/lib.rs @@ -0,0 +1,246 @@ +// Proc macro crates must panic on internal errors (malformed TokenStream +// generation) -- there is no caller to return Result to. These panics +// surface as compile errors for the user. +#![allow(clippy::expect_used)] + +//! Proc macro for compile-time `Float` literal parsing. +//! +//! Evaluates `Float::parse` at compile time via revm when given a numeric +//! literal, emitting a const `Float::from_raw(FixedBytes([...]))` expression. +//! Invalid literals become compile errors instead of runtime panics. +//! +//! For runtime expressions (variables, method calls), falls back to +//! `Float::parse` at runtime with a panic on failure. + +use proc_macro::{TokenStream, TokenTree}; +use rain_math_float::Float; + +/// Returns true if the string looks like a numeric literal +/// (optional `-`, then digits, optional `.` and more digits). +fn is_numeric_literal_str(input: &str) -> bool { + let trimmed = input.trim(); + + // Must match: optional minus, digits, optional decimal point + digits + let s = trimmed.strip_prefix('-').unwrap_or(trimmed); + + if s.is_empty() { + return false; + } + + let mut seen_dot = false; + for ch in s.chars() { + if ch == '.' { + if seen_dot { + return false; + } + seen_dot = true; + } else if !ch.is_ascii_digit() { + return false; + } + } + + true +} + +/// Parses a numeric literal into a `Float` at compile time. +/// +/// Accepts bare numeric literals -- no quotes needed: +/// +/// ```ignore +/// use rain_math_float_macro::float; +/// +/// let value = float!(1.5); +/// let zero = float!(0); +/// let negative = float!(-42.7); +/// ``` +/// +/// Also accepts runtime expressions, falling back to `Float::parse` at +/// runtime: +/// +/// ```ignore +/// let price = 42.5_f64; +/// let value = float!(&price.to_string()); +/// ``` +/// +/// Invalid numeric literals produce a compile error: +/// +/// ```ignore,compile_fail +/// let bad = float!(99999999999999999999999999999999999999999999999999999999999999999999999); +/// ``` +#[proc_macro] +pub fn float(input: TokenStream) -> TokenStream { + let tokens: Vec = input.into_iter().collect(); + + // Reconstruct the literal from tokens. The Rust tokenizer splits + // `-1.4` into separate tokens (`-`, `1.4`), so we join them without + // spaces to recover the original literal. + let joined: String = tokens.iter().map(ToString::to_string).collect(); + + let source = if is_numeric_literal_str(&joined) { + match compile_time_float_source(&joined, "float!") { + Ok(source) => source, + Err(compile_error) => return compile_error, + } + } else { + // Runtime fallback: Float::parse(...).unwrap_or_else(panic!). + let expr: TokenStream = tokens.into_iter().collect(); + let expr_str = expr.to_string(); + format!( + "match ::rain_math_float::Float::parse(({expr_str}).to_string()) {{ \ + Ok(value) => value, \ + Err(error) => panic!(\"float!({{}}) failed: {{error}}\", {expr_str:?}), \ + }}" + ) + }; + + source.parse().expect("float TokenStream parse failed") +} + +/// Fallible sibling of [`float!`] for production code. +/// +/// Unlike [`float!`], this never panics at runtime: it always yields a +/// `Result` so callers can propagate failures with `?`. +/// +/// For numeric literals the Float is parsed at compile time and wrapped +/// in `Ok(...)` so it's free at runtime: +/// +/// ```ignore +/// use rain_math_float_macro::float_result; +/// use rain_math_float::{Float, FloatError}; +/// +/// fn doubled(value: &str) -> Result { +/// let two = float_result!(2)?; +/// float_result!(value)? * two +/// } +/// ``` +/// +/// For runtime expressions (variables, method calls), emits +/// `Float::parse($expr.to_string())` so the error propagates via `?`. +#[proc_macro] +pub fn float_result(input: TokenStream) -> TokenStream { + let tokens: Vec = input.into_iter().collect(); + let joined: String = tokens.iter().map(ToString::to_string).collect(); + + let source = if is_numeric_literal_str(&joined) { + match compile_time_float_source(&joined, "float_result!") { + Ok(float_expr) => format!( + "::core::result::Result::<::rain_math_float::Float, \ + ::rain_math_float::FloatError>::Ok({float_expr})" + ), + Err(compile_error) => return compile_error, + } + } else { + let expr: TokenStream = tokens.into_iter().collect(); + let expr_str = expr.to_string(); + format!("::rain_math_float::Float::parse(({expr_str}).to_string())") + }; + + source + .parse() + .expect("float_result TokenStream parse failed") +} + +/// Parses a numeric literal into source code constructing a `Float` via +/// `Float::from_raw`. Returns the generated source string on success, or a +/// `compile_error!` TokenStream on parse failure. +fn compile_time_float_source( + literal: &str, + caller_macro_name: &str, +) -> Result { + let parsed = Float::parse(literal.to_string()).map_err(|error| { + let message = format!("{caller_macro_name}({literal}) failed: {error}"); + format!("compile_error!({message:?})") + .parse::() + .expect("compile_error! TokenStream parse failed") + })?; + + let bytes = parsed.get_inner().0; + let byte_tokens: Vec = bytes.iter().map(|byte| format!("{byte:#04x}")).collect(); + let bytes_list = byte_tokens.join(", "); + + Ok(format!( + "::rain_math_float::Float::from_raw(::alloy_primitives::FixedBytes([{bytes_list}]))" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn integer_literals_are_numeric() { + assert!(is_numeric_literal_str("0")); + assert!(is_numeric_literal_str("1")); + assert!(is_numeric_literal_str("42")); + assert!(is_numeric_literal_str("100000")); + } + + #[test] + fn decimal_literals_are_numeric() { + assert!(is_numeric_literal_str("1.5")); + assert!(is_numeric_literal_str("0.001")); + assert!(is_numeric_literal_str("155.00")); + assert!(is_numeric_literal_str("123.456789")); + } + + #[test] + fn negative_literals_are_numeric() { + assert!(is_numeric_literal_str("-1")); + assert!(is_numeric_literal_str("-42.7")); + assert!(is_numeric_literal_str("-0.5")); + } + + #[test] + fn non_numeric_inputs_are_rejected() { + assert!(!is_numeric_literal_str("")); + assert!(!is_numeric_literal_str("-")); + assert!(!is_numeric_literal_str("abc")); + assert!(!is_numeric_literal_str("1.2.3")); + assert!(!is_numeric_literal_str("i64::MAX")); + assert!(!is_numeric_literal_str("&price.to_string()")); + assert!(!is_numeric_literal_str("some_var")); + } + + #[test] + fn edge_case_dot_handling() { + // Trailing dot passes the literal check (Rust tokenizer accepts `5.` + // as a float literal), but Float::parse will reject it at compile time + assert!(is_numeric_literal_str("5.")); + + // Lone dot is accepted by our check since it has a dot and no + // non-digit chars, but Float::parse rejects it + assert!(is_numeric_literal_str(".")); + + // Leading dot (no integer part) + assert!(is_numeric_literal_str(".5")); + } + + #[test] + fn signs_and_whitespace() { + // Plus sign is not accepted (only minus) + assert!(!is_numeric_literal_str("+1")); + + // Double minus + assert!(!is_numeric_literal_str("--1")); + + // Minus in middle + assert!(!is_numeric_literal_str("1-2")); + + // Whitespace is trimmed, so padded numbers pass + assert!(is_numeric_literal_str(" 42 ")); + assert!(is_numeric_literal_str(" -3.14 ")); + } + + #[test] + fn underscores_and_scientific_notation_rejected() { + // Rust numeric separators are not accepted + assert!(!is_numeric_literal_str("1_000")); + + // Scientific notation is not accepted + assert!(!is_numeric_literal_str("1e5")); + assert!(!is_numeric_literal_str("1.5e10")); + + // Hex is not accepted + assert!(!is_numeric_literal_str("0xff")); + } +} diff --git a/crates/float-macro/tests/compile_fail/dot_only.rs b/crates/float-macro/tests/compile_fail/dot_only.rs new file mode 100644 index 0000000..f72f98b --- /dev/null +++ b/crates/float-macro/tests/compile_fail/dot_only.rs @@ -0,0 +1,6 @@ +use rain_math_float_macro::float; + +fn main() { + // A lone dot is not a valid number + let _ = float!(.); +} diff --git a/crates/float-macro/tests/compile_fail/dot_only.stderr b/crates/float-macro/tests/compile_fail/dot_only.stderr new file mode 100644 index 0000000..b590cd6 --- /dev/null +++ b/crates/float-macro/tests/compile_fail/dot_only.stderr @@ -0,0 +1,7 @@ +error: float!(.) failed: Decimal Float error selector: Err(0x34bd2069) + --> tests/compile_fail/dot_only.rs:5:13 + | +5 | let _ = float!(.); + | ^^^^^^^^^ + | + = note: this error originates in the macro `float` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/float-macro/tests/compile_fail/float_result_overflow.rs b/crates/float-macro/tests/compile_fail/float_result_overflow.rs new file mode 100644 index 0000000..c6d7ebb --- /dev/null +++ b/crates/float-macro/tests/compile_fail/float_result_overflow.rs @@ -0,0 +1,7 @@ +use rain_math_float_macro::float_result; + +fn main() { + // Verifies the compile error names the actual invoking macro + // (`float_result!`) rather than hardcoding `float!`. + let _ = float_result!(99999999999999999999999999999999999999999999999999999999999999999999999); +} diff --git a/crates/float-macro/tests/compile_fail/float_result_overflow.stderr b/crates/float-macro/tests/compile_fail/float_result_overflow.stderr new file mode 100644 index 0000000..c3a20ba --- /dev/null +++ b/crates/float-macro/tests/compile_fail/float_result_overflow.stderr @@ -0,0 +1,7 @@ +error: float_result!(99999999999999999999999999999999999999999999999999999999999999999999999) failed: Decimal Float error selector: Err(0x32b8b8be) + --> tests/compile_fail/float_result_overflow.rs:6:13 + | +6 | let _ = float_result!(99999999999999999999999999999999999999999999999999999999999999999999999); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the macro `float_result` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/float-macro/tests/compile_fail/negative_overflow.rs b/crates/float-macro/tests/compile_fail/negative_overflow.rs new file mode 100644 index 0000000..d2fd3fe --- /dev/null +++ b/crates/float-macro/tests/compile_fail/negative_overflow.rs @@ -0,0 +1,6 @@ +use rain_math_float_macro::float; + +fn main() { + // Negative number far exceeding Float's representable range + let _ = float!(-99999999999999999999999999999999999999999999999999999999999999999999999); +} diff --git a/crates/float-macro/tests/compile_fail/negative_overflow.stderr b/crates/float-macro/tests/compile_fail/negative_overflow.stderr new file mode 100644 index 0000000..e7a6d5f --- /dev/null +++ b/crates/float-macro/tests/compile_fail/negative_overflow.stderr @@ -0,0 +1,7 @@ +error: float!(-99999999999999999999999999999999999999999999999999999999999999999999999) failed: Decimal Float error selector: Err(0x32b8b8be) + --> tests/compile_fail/negative_overflow.rs:5:13 + | +5 | let _ = float!(-99999999999999999999999999999999999999999999999999999999999999999999999); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the macro `float` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/float-macro/tests/compile_fail/overflow.rs b/crates/float-macro/tests/compile_fail/overflow.rs new file mode 100644 index 0000000..e5e6eb7 --- /dev/null +++ b/crates/float-macro/tests/compile_fail/overflow.rs @@ -0,0 +1,6 @@ +use rain_math_float_macro::float; + +fn main() { + // Number far exceeding Float's representable range + let _ = float!(99999999999999999999999999999999999999999999999999999999999999999999999); +} diff --git a/crates/float-macro/tests/compile_fail/overflow.stderr b/crates/float-macro/tests/compile_fail/overflow.stderr new file mode 100644 index 0000000..50a2ca5 --- /dev/null +++ b/crates/float-macro/tests/compile_fail/overflow.stderr @@ -0,0 +1,7 @@ +error: float!(99999999999999999999999999999999999999999999999999999999999999999999999) failed: Decimal Float error selector: Err(0x32b8b8be) + --> tests/compile_fail/overflow.rs:5:13 + | +5 | let _ = float!(99999999999999999999999999999999999999999999999999999999999999999999999); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the macro `float` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/float-macro/tests/compile_fail/trailing_dot.rs b/crates/float-macro/tests/compile_fail/trailing_dot.rs new file mode 100644 index 0000000..cdd7a93 --- /dev/null +++ b/crates/float-macro/tests/compile_fail/trailing_dot.rs @@ -0,0 +1,6 @@ +use rain_math_float_macro::float; + +fn main() { + // Trailing dot is not a valid float literal + let _ = float!(5.); +} diff --git a/crates/float-macro/tests/compile_fail/trailing_dot.stderr b/crates/float-macro/tests/compile_fail/trailing_dot.stderr new file mode 100644 index 0000000..0c0c968 --- /dev/null +++ b/crates/float-macro/tests/compile_fail/trailing_dot.stderr @@ -0,0 +1,7 @@ +error: float!(5.) failed: Decimal Float error selector: Err(0x7bfa48af) + --> tests/compile_fail/trailing_dot.rs:5:13 + | +5 | let _ = float!(5.); + | ^^^^^^^^^^ + | + = note: this error originates in the macro `float` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/float-macro/tests/float_macro.rs b/crates/float-macro/tests/float_macro.rs new file mode 100644 index 0000000..5679325 --- /dev/null +++ b/crates/float-macro/tests/float_macro.rs @@ -0,0 +1,122 @@ +//! Integration tests for the `float!` proc macro. +//! +//! These run as a separate binary that consumes the proc macro crate, +//! verifying compile-time evaluation matches runtime `Float::parse`. + +#![allow(clippy::unwrap_used)] + +use rain_math_float::Float; +use rain_math_float_macro::float; + +#[test] +fn integer_literals() { + assert!(float!(0).is_zero().unwrap()); + assert!( + float!(1) + .eq(Float::parse("1".to_string()).unwrap()) + .unwrap() + ); + assert!( + float!(42) + .eq(Float::parse("42".to_string()).unwrap()) + .unwrap() + ); + assert!( + float!(100000) + .eq(Float::parse("100000".to_string()).unwrap()) + .unwrap() + ); +} + +#[test] +fn decimal_literals() { + assert!( + float!(1.5) + .eq(Float::parse("1.5".to_string()).unwrap()) + .unwrap() + ); + assert!( + float!(0.001) + .eq(Float::parse("0.001".to_string()).unwrap()) + .unwrap() + ); + assert!( + float!(155.00) + .eq(Float::parse("155.00".to_string()).unwrap()) + .unwrap() + ); + assert!( + float!(123.456789) + .eq(Float::parse("123.456789".to_string()).unwrap()) + .unwrap() + ); +} + +#[test] +fn negative_literals() { + assert!( + float!(-1) + .eq(Float::parse("-1".to_string()).unwrap()) + .unwrap() + ); + assert!( + float!(-42.7) + .eq(Float::parse("-42.7".to_string()).unwrap()) + .unwrap() + ); + assert!( + float!(-0.5) + .eq(Float::parse("-0.5".to_string()).unwrap()) + .unwrap() + ); +} + +#[test] +fn arithmetic() { + let sum = (float!(1.5) + float!(2.5)).unwrap(); + assert!(sum.eq(float!(4)).unwrap()); + + let diff = (float!(10) - float!(3)).unwrap(); + assert!(diff.eq(float!(7)).unwrap()); + + let product = (float!(6) * float!(7)).unwrap(); + assert!(product.eq(float!(42)).unwrap()); + + let quotient = (float!(10) / float!(4)).unwrap(); + assert!(quotient.eq(float!(2.5)).unwrap()); +} + +#[test] +fn runtime_fallback() { + let value = "99.99".to_string(); + let from_macro = float!(&value); + let from_parse = Float::parse("99.99".to_string()).unwrap(); + assert!(from_macro.eq(from_parse).unwrap()); +} + +#[test] +#[should_panic(expected = "failed")] +fn runtime_invalid_string_panics() { + let invalid = "not_a_number".to_string(); + let _ = float!(&invalid); +} + +#[test] +#[should_panic(expected = "failed")] +fn runtime_empty_string_panics() { + let empty = String::new(); + let _ = float!(&empty); +} + +#[test] +#[should_panic(expected = "failed")] +fn runtime_special_chars_panic() { + let special = "1.2.3".to_string(); + let _ = float!(&special); +} + +#[test] +fn compile_fail_tests() { + let test_cases = trybuild::TestCases::new(); + test_cases.compile_fail("tests/compile_fail/*.rs"); +} diff --git a/crates/float-serde/Cargo.toml b/crates/float-serde/Cargo.toml new file mode 100644 index 0000000..7f4f0fa --- /dev/null +++ b/crates/float-serde/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "rain-math-float-serde" +edition.workspace = true +version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +description = "Serde helpers and decimal debug formatting for rain-math-float" + +[dependencies] +rain-math-float.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true + +[dev-dependencies] +alloy.workspace = true diff --git a/crates/float-serde/src/lib.rs b/crates/float-serde/src/lib.rs new file mode 100644 index 0000000..84dcc48 --- /dev/null +++ b/crates/float-serde/src/lib.rs @@ -0,0 +1,446 @@ +//! Serde helpers for `rain_math_float::Float`. +//! +//! Provides serialization as decimal strings and deserialization from +//! JSON strings, numbers, or hex-encoded Float values. + +use rain_math_float::Float; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::borrow::Borrow; + +/// Format a Float as a decimal string, falling back to scientific +/// notation when the exponent exceeds the non-scientific formatter's +/// range. +/// +/// Rain Float's `format_with_scientific(false)` rejects exponents +/// below -76. Rather than losing data, we fall back to scientific +/// notation (e.g., `"1.23e-5"`), which the parser handles natively. +pub fn format_float(value: &Float) -> Result { + value + .format_with_scientific(false) + .or_else(|_| value.format_with_scientific(true)) +} + +/// Format a Float value as a decimal string for display/logging purposes. +/// Falls back to debug representation on error. +pub fn format_float_with_fallback(value: &Float) -> String { + format_float(value).unwrap_or_else(|_| format!("{value:?}")) +} + +/// Wrapper for formatting a `Float` as decimal in `Debug` output. +/// +/// Use in manual `Debug` impls to avoid hex representation: +/// ```ignore +/// .field("price", &DebugFloat(&self.price)) +/// ``` +pub struct DebugFloat<'a>(pub &'a Float); + +impl std::fmt::Debug for DebugFloat<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", format_float_with_fallback(self.0)) + } +} + +/// Wrapper for formatting an `Option` as decimal in `Debug` output. +pub struct DebugOptionFloat<'a>(pub &'a Option); + +impl std::fmt::Debug for DebugOptionFloat<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.0 { + Some(value) => write!(f, "Some({})", format_float_with_fallback(value)), + None => write!(f, "None"), + } + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum FloatSerdeInput { + String(String), + Number(serde_json::Number), +} + +pub fn parse_float_string_or_hex(value: &str) -> Result { + if let Ok(float) = Float::parse(value.to_string()) { + return Ok(float); + } + + Float::from_hex(value) +} + +pub fn serialize_float_as_string(value: &Float, serializer: S) -> Result +where + S: Serializer, +{ + let formatted = format_float(value).map_err(serde::ser::Error::custom)?; + serializer.serialize_str(&formatted) +} + +pub fn deserialize_float_from_number_or_string<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + match FloatSerdeInput::deserialize(deserializer)? { + FloatSerdeInput::String(value) => { + parse_float_string_or_hex(&value).map_err(serde::de::Error::custom) + } + FloatSerdeInput::Number(value) => { + Float::parse(value.to_string()).map_err(serde::de::Error::custom) + } + } +} + +pub fn deserialize_option_float_from_number_or_string<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Option::::deserialize(deserializer)? + .map(|value| match value { + FloatSerdeInput::String(value) => { + parse_float_string_or_hex(&value).map_err(serde::de::Error::custom) + } + FloatSerdeInput::Number(value) => { + Float::parse(value.to_string()).map_err(serde::de::Error::custom) + } + }) + .transpose() +} + +pub fn serialize_option_float(value: &T, serializer: S) -> Result +where + S: Serializer, + T: Borrow>, +{ + option_float_string_serde::serialize(value, serializer) +} + +pub struct FloatDisplay<'a>(pub &'a Float); + +impl Serialize for FloatDisplay<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serialize_float_as_string(self.0, serializer) + } +} + +pub mod float_string_serde { + use super::{Deserializer, Float, Serializer}; + use super::{deserialize_float_from_number_or_string, serialize_float_as_string}; + + pub fn serialize(value: &Float, serializer: S) -> Result + where + S: Serializer, + { + serialize_float_as_string(value, serializer) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_float_from_number_or_string(deserializer) + } +} + +pub mod option_float_string_serde { + use super::{Deserializer, Float, Serializer}; + use super::{deserialize_option_float_from_number_or_string, serialize_float_as_string}; + + pub fn serialize(value: &T, serializer: S) -> Result + where + S: Serializer, + T: super::Borrow>, + { + match value.borrow().as_ref() { + Some(float) => serialize_float_as_string(float, serializer), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + deserialize_option_float_from_number_or_string(deserializer) + } +} + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + use serde_json::json; + + use super::*; + + #[derive(Serialize)] + struct SerializeFloat { + #[serde(serialize_with = "serialize_float_as_string")] + value: Float, + } + + #[derive(Deserialize)] + struct DeserializeFloat { + #[serde(deserialize_with = "deserialize_float_from_number_or_string")] + value: Float, + } + + #[derive(Deserialize)] + struct DeserializeOptionalFloat { + #[serde( + default, + deserialize_with = "deserialize_option_float_from_number_or_string" + )] + value: Option, + } + + #[derive(Serialize, Deserialize)] + struct WithSerdeModule { + #[serde(with = "float_string_serde")] + value: Float, + } + + #[derive(Serialize, Deserialize)] + struct WithOptionalSerdeModule { + #[serde(default, with = "option_float_string_serde")] + value: Option, + } + + #[test] + fn serialize_float_as_string_outputs_decimal_text() { + let payload = SerializeFloat { + value: Float::parse("12.5".to_string()).unwrap(), + }; + let json = serde_json::to_value(payload).unwrap(); + assert_eq!(json["value"], json!("12.5")); + } + + #[test] + fn deserialize_float_accepts_string_number_and_hex() { + let from_string: DeserializeFloat = + serde_json::from_value(json!({"value": "12.5"})).unwrap(); + assert!( + from_string + .value + .eq(Float::parse("12.5".to_string()).unwrap()) + .unwrap() + ); + + let from_number: DeserializeFloat = serde_json::from_value(json!({"value": 12.5})).unwrap(); + assert!( + from_number + .value + .eq(Float::parse("12.5".to_string()).unwrap()) + .unwrap() + ); + + let from_hex: DeserializeFloat = serde_json::from_value( + json!({"value": Float::parse("12.5".to_string()).unwrap().as_hex()}), + ) + .unwrap(); + assert!( + from_hex + .value + .eq(Float::parse("12.5".to_string()).unwrap()) + .unwrap() + ); + } + + #[test] + fn deserialize_optional_float_supports_none() { + let payload: DeserializeOptionalFloat = serde_json::from_value(json!({})).unwrap(); + assert!(payload.value.is_none()); + + let payload: DeserializeOptionalFloat = + serde_json::from_value(json!({"value": null})).unwrap(); + assert!(payload.value.is_none()); + } + + #[test] + fn float_string_serde_round_trips() { + let payload = WithSerdeModule { + value: Float::parse("12.5".to_string()).unwrap(), + }; + + let json = serde_json::to_value(&payload).unwrap(); + assert_eq!(json["value"], json!("12.5")); + + let parsed: WithSerdeModule = serde_json::from_value(json!({"value": "12.5"})).unwrap(); + assert!( + parsed + .value + .eq(Float::parse("12.5".to_string()).unwrap()) + .unwrap() + ); + } + + #[test] + fn option_float_string_serde_handles_null_and_values() { + let payload = WithOptionalSerdeModule { + value: Some(Float::parse("12.5".to_string()).unwrap()), + }; + + let json = serde_json::to_value(&payload).unwrap(); + assert_eq!(json["value"], json!("12.5")); + + let parsed: WithOptionalSerdeModule = + serde_json::from_value(json!({"value": "12.5"})).unwrap(); + assert!( + parsed + .value + .unwrap() + .eq(Float::parse("12.5".to_string()).unwrap()) + .unwrap() + ); + + let parsed: WithOptionalSerdeModule = serde_json::from_value(json!({})).unwrap(); + assert!(parsed.value.is_none()); + + let parsed: WithOptionalSerdeModule = + serde_json::from_value(json!({"value": null})).unwrap(); + assert!(parsed.value.is_none()); + } + + /// Construct a Float from raw coefficient and exponent by packing + /// them into the B256 layout: upper 32 bits = exponent (int32), + /// lower 224 bits = coefficient (int224). + fn pack_raw(coefficient: i64, exponent: i32) -> Float { + use alloy::primitives::B256; + + let mut bytes = [0u8; 32]; + + // Exponent in the top 4 bytes (big-endian). + bytes[..4].copy_from_slice(&exponent.to_be_bytes()); + + // Coefficient in the lower 28 bytes (big-endian, sign-extended). + let coeff_bytes = coefficient.to_be_bytes(); + let fill = if coefficient < 0 { 0xFF } else { 0x00 }; + bytes[4..24].fill(fill); + bytes[24..32].copy_from_slice(&coeff_bytes); + + Float::from_raw(B256::from(bytes)) + } + + #[test] + fn format_float_roundtrips_at_extreme_exponent() { + // Regression: accumulated Float arithmetic could produce exponents + // the non-scientific formatter rejected, crashing serialization. + // `format_float` must produce a roundtrippable string for such + // values regardless of whether the direct or the scientific path + // is taken. + let float = pack_raw(9_999_999_910_959_448, -77); + + let formatted = format_float(&float).unwrap(); + assert!( + !formatted.is_empty(), + "format_float should produce output for exponent -77" + ); + + let roundtripped = Float::parse(formatted.clone()).unwrap(); + assert!( + roundtripped.eq(float).unwrap(), + "Roundtrip failed: formatted as '{formatted}', parsed back to different value" + ); + } + + #[test] + fn serialize_float_with_extreme_exponent_roundtrips_through_serde() { + let float = pack_raw(9_999_999_910_959_448, -77); + + let payload = SerializeFloat { value: float }; + let json = serde_json::to_value(payload).unwrap(); + + // Should serialize without error (previously panicked). + let serialized = json["value"].as_str().unwrap(); + assert!(!serialized.is_empty()); + + // Should deserialize back to the same value. + let parsed: DeserializeFloat = serde_json::from_value(json).unwrap(); + assert!( + parsed.value.eq(float).unwrap(), + "Serde roundtrip failed for Float with exponent -77" + ); + } + + /// Regression coverage for a production serialization crash. The + /// exact values come from staging logs (event 605 against position + /// view 604): + /// net position = -0.09999999910959448 (17 decimal places) + /// hedge fill = 0.099999999 (9 decimal places) + /// sum = -0.00000000010959448 (~-1.1e-10) + /// + /// Their sum used to land on a Float exponent that the non-scientific + /// formatter rejected, panicking serialization. `format_float` must + /// produce a roundtrippable string for this case. + #[test] + fn format_float_roundtrips_near_cancellation_residual() { + let net_position = Float::parse("-0.09999999910959448".to_string()).unwrap(); + let hedge_fill = Float::parse("0.099999999".to_string()).unwrap(); + let result = (net_position + hedge_fill).unwrap(); + + let formatted = format_float(&result).unwrap(); + assert!(!formatted.is_empty(), "format_float produced empty output"); + + let roundtripped = Float::parse(formatted.clone()).unwrap(); + assert!( + roundtripped.eq(result).unwrap(), + "Roundtrip failed: '{formatted}'" + ); + } + + /// Same scenario as `format_float_roundtrips_near_cancellation_residual` + /// at smaller magnitudes -- previously these produced progressively + /// worse exponents from cancellation; we still want to roundtrip cleanly. + #[test] + fn format_float_roundtrips_tiny_cancellation_residuals() { + let hundredths = (Float::parse("-0.0099999991".to_string()).unwrap() + + Float::parse("0.009999999".to_string()).unwrap()) + .unwrap(); + let thousandths = (Float::parse("-0.00099999991".to_string()).unwrap() + + Float::parse("0.0009999999".to_string()).unwrap()) + .unwrap(); + + for value in [hundredths, thousandths] { + let formatted = format_float(&value).unwrap(); + let roundtripped = Float::parse(formatted.clone()).unwrap(); + assert!( + roundtripped.eq(value).unwrap(), + "Roundtrip failed: '{formatted}'" + ); + } + } + + #[test] + fn serialize_normal_float_still_uses_decimal_format() { + // Normal values should still produce clean decimal strings, + // not scientific notation. + let formatted = format_float(&Float::parse("72.5".to_string()).unwrap()).unwrap(); + assert_eq!(formatted, "72.5"); + + let formatted = format_float(&Float::parse("0.1".to_string()).unwrap()).unwrap(); + assert_eq!(formatted, "0.1"); + } + + #[test] + fn debug_float_formats_as_decimal() { + let value = Float::parse("12.5".to_string()).unwrap(); + let output = format!("{:?}", DebugFloat(&value)); + assert_eq!(output, "12.5"); + } + + #[test] + fn debug_option_float_formats_some_as_decimal() { + let value = Some(Float::parse("42".to_string()).unwrap()); + let output = format!("{:?}", DebugOptionFloat(&value)); + assert_eq!(output, "Some(42)"); + } + + #[test] + fn debug_option_float_formats_none() { + let value: Option = None; + let output = format!("{:?}", DebugOptionFloat(&value)); + assert_eq!(output, "None"); + } +}