Skip to main content

tabnas_render/
number.rs

1//! Number text, shared by the renderers.
2//!
3//! A number reaches a renderer as a machine value and, when the source
4//! could hand it over, the lexeme it was spelled with. The lexeme wins,
5//! because it is the only thing that keeps `50.25` as `50.25` and keeps the
6//! digits of a number beyond f64's exact range; but a lexeme is data from a
7//! source, so it is checked against the JSON number grammar before it is
8//! copied into an output that promises to be JSON or CSV. Without a lexeme
9//! the shortest text that reads back as the same f64 is written. A value
10//! with no finite text (NaN, infinity) is rejected as unrepresentable
11//! rather than written as `null`, which would silently change the data, and
12//! it is rejected whatever lexeme stands beside it: `1e999` spells a
13//! number, but the value the pipeline holds is infinity, and a JSON reader
14//! given the lexeme refuses it as out of range.
15//!
16//! Validation and formatting are separate functions so a renderer can check
17//! a whole row before it writes any of it, and format each number once.
18
19use std::fmt::Write as _;
20
21use tabnas_alchemy::shared::{Code, Fail};
22
23/// Whether `text` is a number by RFC 8259's grammar:
24/// `-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?`, nothing else and
25/// nothing around it.
26pub fn is_json_number(text: &str) -> bool {
27    let b = text.as_bytes();
28    let mut i = 0;
29    if b.first() == Some(&b'-') {
30        i += 1;
31    }
32    match b.get(i) {
33        Some(b'0') => i += 1,
34        Some(b'1'..=b'9') => {
35            i += 1;
36            while matches!(b.get(i), Some(b'0'..=b'9')) {
37                i += 1;
38            }
39        }
40        _ => return false,
41    }
42    if b.get(i) == Some(&b'.') {
43        i += 1;
44        let start = i;
45        while matches!(b.get(i), Some(b'0'..=b'9')) {
46            i += 1;
47        }
48        if i == start {
49            return false;
50        }
51    }
52    if matches!(b.get(i), Some(b'e' | b'E')) {
53        i += 1;
54        if matches!(b.get(i), Some(b'+' | b'-')) {
55            i += 1;
56        }
57        let start = i;
58        while matches!(b.get(i), Some(b'0'..=b'9')) {
59            i += 1;
60        }
61        if i == start {
62            return false;
63        }
64    }
65    i == b.len()
66}
67
68/// Whether a renderer may write this number at all.
69///
70/// A lexeme that is not a JSON number is `INVALID_NUMBER`. A value that is
71/// not finite is `TARGET_VALUE_UNREPRESENTABLE`, with or without a lexeme:
72/// the design brief names NaN and infinity unrepresentable, and a lexeme
73/// such as `1e999` would hand the reader a number the pipeline never had
74/// (serde_json rejects it as out of range). Nothing is formatted here, so a
75/// renderer can run this over a whole row before writing a byte of it.
76pub(crate) fn check_number(value: f64, lexeme: Option<&str>) -> Result<(), Fail> {
77    if let Some(l) = lexeme {
78        if !is_json_number(l) {
79            return Err(Fail::new(
80                Code::InvalidNumber,
81                format!("{l:?} is not a JSON number"),
82            ));
83        }
84    }
85    if !value.is_finite() {
86        let message = match lexeme {
87            Some(l) => format!("{l:?} is {value} as a number, which has no representation"),
88            None => format!("{value} has no representation as a number"),
89        };
90        return Err(Fail::new(Code::TargetValueUnrepresentable, message));
91    }
92    Ok(())
93}
94
95/// The magnitudes written positionally: from `1e-6` up to, not including,
96/// `1e21`. These are the thresholds JavaScript's `Number#toString` uses,
97/// so they are the ones most JSON in circulation was written with; an
98/// integer of up to 21 digits stays an integer, and `1e300` is five
99/// characters rather than 301.
100const POSITIONAL_MIN: f64 = 1e-6;
101const POSITIONAL_MAX: f64 = 1e21;
102
103/// Write the shortest text that reads back as `value`, into `out`
104/// (cleared first), and hand it back as a slice of `out`.
105///
106/// Rust's float formatting produces the shortest digit string that
107/// round-trips; this function only chooses the layout. Positional form for
108/// zero and for magnitudes within [`POSITIONAL_MIN`, `POSITIONAL_MAX`),
109/// exponent form (`1.5e300`, `-2.5e-8`) outside, because Rust's positional
110/// form never uses an exponent and would spell `1e300` with 301 digits.
111/// Both layouts are JSON numbers. The value must be finite, which
112/// [`check_number`] establishes before every call: a non-finite value has
113/// no JSON text and is refused there, not here.
114pub(crate) fn write_value(value: f64, out: &mut String) -> &str {
115    out.clear();
116    let magnitude = value.abs();
117    // Writing into a String cannot fail; the Result is fmt's, not a writer's.
118    let _ = if magnitude == 0.0 || (POSITIONAL_MIN..POSITIONAL_MAX).contains(&magnitude) {
119        write!(out, "{value}")
120    } else {
121        write!(out, "{value:e}")
122    };
123    out
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn the_json_number_grammar_is_exact() {
132        for ok in [
133            "0",
134            "-0",
135            "1",
136            "-1",
137            "10",
138            "1.5",
139            "0.0",
140            "1e5",
141            "1E5",
142            "1e+5",
143            "1e-5",
144            "1.5e10",
145            "123456789012345678901234567890",
146            "-0.000001",
147        ] {
148            assert!(is_json_number(ok), "{ok:?} should be a number");
149        }
150        for bad in [
151            "",
152            "-",
153            "+1",
154            "01",
155            "1.",
156            ".5",
157            "1e",
158            "1e+",
159            "1.e5",
160            "0x10",
161            "NaN",
162            "Infinity",
163            "-Infinity",
164            " 1",
165            "1 ",
166            "1_000",
167            "1,5",
168            "١",
169        ] {
170            assert!(!is_json_number(bad), "{bad:?} should not be a number");
171        }
172    }
173
174    #[test]
175    fn a_json_number_lexeme_beside_a_finite_value_passes() {
176        assert_eq!(check_number(1.0, Some("1.00")), Ok(()));
177        assert_eq!(check_number(1.0, None), Ok(()));
178        assert_eq!(
179            check_number(1e30, Some("123456789012345678901234567890")),
180            Ok(())
181        );
182    }
183
184    #[test]
185    fn a_lexeme_that_is_not_a_json_number_is_invalid_number() {
186        assert_eq!(
187            check_number(1.0, Some("1.")).unwrap_err().code,
188            Code::InvalidNumber
189        );
190        // The lexeme is judged first: a NaN spelled "NaN" is a bad lexeme,
191        // not an unrepresentable value.
192        assert_eq!(
193            check_number(f64::NAN, Some("NaN")).unwrap_err().code,
194            Code::InvalidNumber
195        );
196    }
197
198    #[test]
199    fn a_non_finite_value_is_unrepresentable_with_or_without_a_lexeme() {
200        for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
201            assert_eq!(
202                check_number(v, None).unwrap_err().code,
203                Code::TargetValueUnrepresentable
204            );
205        }
206        let err = check_number(f64::INFINITY, Some("1e999")).unwrap_err();
207        assert_eq!(err.code, Code::TargetValueUnrepresentable);
208        assert!(err.message.contains("\"1e999\""), "{}", err.message);
209        assert_eq!(
210            check_number(f64::NEG_INFINITY, Some("-1e999"))
211                .unwrap_err()
212                .code,
213            Code::TargetValueUnrepresentable
214        );
215    }
216
217    fn text(value: f64) -> String {
218        let mut scratch = String::new();
219        write_value(value, &mut scratch).to_owned()
220    }
221
222    #[test]
223    fn a_value_takes_the_shortest_form_positional_within_the_javascript_range() {
224        assert_eq!(text(1.0), "1");
225        assert_eq!(text(0.1), "0.1");
226        assert_eq!(text(-0.0), "-0");
227        assert_eq!(text(0.0), "0");
228        assert_eq!(text(50.25), "50.25");
229        assert_eq!(text(123456.789), "123456.789");
230        assert_eq!(text(1e20), "100000000000000000000");
231        assert_eq!(text(1.5e17), "150000000000000000");
232        assert_eq!(text(1.23456789e18), "1234567890000000000");
233        assert_eq!(text(0.000001), "0.000001");
234        assert_eq!(text(-0.000025), "-0.000025");
235    }
236
237    #[test]
238    fn a_value_outside_the_positional_range_takes_the_exponent_form() {
239        assert_eq!(text(1e21), "1e21");
240        assert_eq!(text(1e300), "1e300");
241        assert_eq!(text(1e-300), "1e-300");
242        assert_eq!(text(1e-7), "1e-7");
243        assert_eq!(text(-2.5e-8), "-2.5e-8");
244        assert_eq!(text(5e-324), "5e-324");
245        assert_eq!(text(f64::MAX), "1.7976931348623157e308");
246        assert_eq!(text(-1.5e300), "-1.5e300");
247    }
248
249    #[test]
250    fn every_form_is_a_json_number_that_reads_back_as_the_same_value() {
251        let values = [
252            0.0,
253            -0.0,
254            1.0,
255            0.1,
256            1e20,
257            1e21,
258            1e300,
259            1e-300,
260            1e-6,
261            1e-7,
262            5e-324,
263            f64::MAX,
264            f64::MIN,
265            f64::MIN_POSITIVE,
266            123456.789,
267            2f64.powi(53),
268            9.999999999999999e20,
269        ];
270        let mut scratch = String::new();
271        for v in values {
272            let t = write_value(v, &mut scratch);
273            assert!(is_json_number(t), "{v:e} wrote {t:?}");
274            let back: f64 = t.parse().unwrap_or(f64::NAN);
275            assert_eq!(back.to_bits(), v.to_bits(), "{v:e} wrote {t:?}");
276        }
277    }
278
279    #[test]
280    fn the_reviewers_probe_is_bytes_not_hundreds_of_digits() {
281        let total: usize = [1e300, 1e-300, 1.5e17, 1.23456789e18]
282            .iter()
283            .map(|&v| text(v).len())
284            .sum();
285        assert_eq!(total, 5 + 6 + 18 + 19);
286    }
287}