Skip to main content

polyspec_crudui_validator/validate/
numeric.rs

1//! Numbers (validation rules, "Values"): numeric text, numeric values and exact
2//! step multiples.
3
4use serde_json::Value;
5
6use super::canonical::number_text;
7use super::whitespace::trim;
8
9/// Whether `text` is numeric text, the HTML valid floating-point number:
10/// `^-?(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)(?:[eE][-+]?[0-9]+)?$`.
11fn is_numeric_text(text: &str) -> bool {
12    let digits = |part: &str| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit());
13    let unsigned = text.strip_prefix('-').unwrap_or(text);
14    let (mantissa, exponent) = match unsigned.split_once(['e', 'E']) {
15        Some((mantissa, exponent)) => (mantissa, Some(exponent)),
16        None => (unsigned, None),
17    };
18    let mantissa_ok = match mantissa.split_once('.') {
19        Some((integer, fraction)) => (integer.is_empty() || digits(integer)) && digits(fraction),
20        None => digits(mantissa),
21    };
22    let exponent_ok = exponent
23        .is_none_or(|exponent| digits(exponent.strip_prefix(['-', '+']).unwrap_or(exponent)));
24    mantissa_ok && exponent_ok
25}
26
27/// The value of numeric text (untrimmed): the nearest double, or `None` when the
28/// text is not numeric text or its value overflows.
29pub(crate) fn numeric_text_value(text: &str) -> Option<f64> {
30    if !is_numeric_text(text) {
31        return None;
32    }
33    // `is_numeric_text` admits only the grammar that Rust's `f64` parser reads.
34    let value = text.parse::<f64>().expect("numeric text parses as f64");
35    value.is_finite().then_some(value)
36}
37
38/// The value of a numeric value: a finite number, or a string that after trimming
39/// is numeric text with a finite value. Other values are not numeric.
40pub(crate) fn numeric_value(value: &Value) -> Option<f64> {
41    match value {
42        Value::Number(number) => number.as_f64().filter(|value| value.is_finite()),
43        Value::String(text) => numeric_text_value(trim(text)),
44        _ => None,
45    }
46}
47
48/// The bounds of a `range` parameter: `[minimum, maximum]` finite numbers with the
49/// minimum not above the maximum.
50pub fn number_range(parameter: &Value) -> Option<(f64, f64)> {
51    match parameter.as_array().map(Vec::as_slice) {
52        Some([minimum, maximum]) => match (finite(minimum), finite(maximum)) {
53            (Some(minimum), Some(maximum)) if minimum <= maximum => Some((minimum, maximum)),
54            _ => None,
55        },
56        _ => None,
57    }
58}
59
60/// The increment of a `step` parameter: a finite number above 0.
61pub fn step(parameter: &Value) -> Option<f64> {
62    finite(parameter).filter(|step| *step > 0.0)
63}
64
65/// A JSON number as a finite double.
66fn finite(value: &Value) -> Option<f64> {
67    value.as_f64().filter(|number| number.is_finite())
68}
69
70/// A finite nonnegative double as the decimal its canonical text writes:
71/// `significand × 10^exponent`.
72fn decimal(value: f64) -> (u64, i32) {
73    let text = number_text(value.abs());
74    let (mantissa, exponent) = match text.split_once('e') {
75        Some((mantissa, exponent)) => (
76            mantissa,
77            exponent
78                .parse::<i32>()
79                .expect("canonical text writes a decimal exponent"),
80        ),
81        None => (text.as_str(), 0),
82    };
83    let (integer, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
84    let digits = format!("{integer}{fraction}");
85    // At most 17 significant digits; the zeros an integer text ends with move into
86    // the exponent, so the significand fits.
87    let significant = digits.trim_end_matches('0');
88    if significant.is_empty() {
89        return (0, 0);
90    }
91    let significand = significant
92        .parse::<u64>()
93        .expect("canonical text has at most 17 significant digits");
94    let zeros = (digits.len() - significant.len()) as i32;
95    (significand, exponent - fraction.len() as i32 + zeros)
96}
97
98/// `(a · b) mod m` without overflow for `m < 2^64`.
99fn mul_mod(a: u64, b: u64, m: u64) -> u64 {
100    ((a as u128 * b as u128) % m as u128) as u64
101}
102
103/// `10^exponent mod m`.
104fn pow10_mod(mut exponent: u32, m: u64) -> u64 {
105    let mut result = 1 % m;
106    let mut base = 10 % m;
107    while exponent > 0 {
108        if exponent & 1 == 1 {
109            result = mul_mod(result, base, m);
110        }
111        base = mul_mod(base, base, m);
112        exponent >>= 1;
113    }
114    result
115}
116
117/// Whether finite `value` is an integer multiple of finite `step > 0`, both read
118/// as the decimals their canonical texts write; decided exactly.
119pub fn is_multiple(value: f64, step: f64) -> bool {
120    let (a, p) = decimal(value);
121    let (b, q) = decimal(step);
122    if a == 0 {
123        return true;
124    }
125    if p >= q {
126        // a · 10^(p−q) mod b
127        mul_mod(a % b, pow10_mod((p - q) as u32, b), b) == 0
128    } else {
129        let shift = (q - p) as u32;
130        // a < 10^17, so a larger shift leaves a fraction.
131        match 10u64.checked_pow(shift) {
132            Some(power) if a % power == 0 => (a / power) % b == 0,
133            _ => false,
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use serde_json::json;
142
143    #[test]
144    fn numeric_text_grammar() {
145        for text in [
146            "0", "12", "-12", "0.5", ".5", "-.5", "1e5", "1E-3", "-.5e+2", "007", "1e999999",
147        ] {
148            assert!(is_numeric_text(text), "{text}");
149        }
150        for text in [
151            "",
152            "-",
153            ".",
154            "+1",
155            "1.",
156            "-.",
157            "1.e5",
158            "e5",
159            "1e",
160            "1e+",
161            "--1",
162            "0x10",
163            "Infinity",
164            "NaN",
165            "1_000",
166            "1,5",
167            "1 2",
168            " 1",
169            "\u{661}\u{662}",
170            "1.2.3",
171            "1e5.0",
172        ] {
173            assert!(!is_numeric_text(text), "{text}");
174        }
175    }
176
177    #[test]
178    fn numeric_values() {
179        assert_eq!(numeric_value(&json!(" 12\t")), Some(12.0));
180        assert_eq!(numeric_value(&json!("1e-400")), Some(0.0));
181        assert_eq!(numeric_value(&json!("1e999")), None);
182        assert_eq!(numeric_value(&json!(1.5)), Some(1.5));
183        for value in [json!(true), json!(null), json!(["1"]), json!({})] {
184            assert_eq!(numeric_value(&value), None, "{value}");
185        }
186    }
187
188    #[test]
189    fn decimals_follow_the_canonical_text() {
190        assert_eq!(decimal(0.0), (0, 0));
191        assert_eq!(decimal(1.5), (15, -1));
192        assert_eq!(decimal(100.0), (1, 2));
193        assert_eq!(decimal(0.000001), (1, -6));
194        assert_eq!(decimal(-2e-7), (2, -7));
195        assert_eq!(decimal(1e21), (1, 21));
196        assert_eq!(decimal(1.05e21), (105, 19));
197        assert_eq!(decimal(123456789012345680000.0), (12345678901234568, 4));
198    }
199
200    #[test]
201    fn multiples_are_exact() {
202        for (value, step, expected) in [
203            (0.0, 0.1, true),
204            (0.3, 0.1, true),
205            (0.1 + 0.2, 0.1, false),
206            (2e-7, 0.1, false),
207            (4e-7, 2e-7, true),
208            (3e-7, 2e-7, false),
209            (-15.0, 5.0, true),
210            (1e21, 1e20, true),
211            (1.05e21, 1e20, false),
212            (1.0000000001, 0.1, false),
213            (1e300, 3.0, false),
214            (1e300, 7.0, false),
215            (1.2e300, 3.0, true),
216            (5e-324, 5e-324, true),
217            (1e-300, 1e300, false),
218            (0.75, 0.25, true),
219            (
220                123456789012345680000.0,
221                7.0,
222                123456789012345680000u128.is_multiple_of(7),
223            ),
224            (123456789012345680000.0, 8.0, true),
225            (98765432109876540.0, 0.001, true),
226        ] {
227            assert_eq!(is_multiple(value, step), expected, "{value} / {step}");
228        }
229    }
230}