1#[must_use]
5pub const fn is_whitespace(character: char) -> bool {
6 matches!(
7 character,
8 '\u{0009}'..='\u{000d}'
9 | '\u{001c}'..='\u{0020}'
10 | '\u{1680}'
11 | '\u{2000}'..='\u{2006}'
12 | '\u{2008}'..='\u{200a}'
13 | '\u{2028}'
14 | '\u{2029}'
15 | '\u{205f}'
16 | '\u{3000}'
17 )
18}
19
20#[must_use]
22pub const fn is_space_char(character: char) -> bool {
23 matches!(
24 character,
25 '\u{0020}' | '\u{00a0}' | '\u{1680}' | '\u{2000}'
26 ..='\u{200a}' | '\u{2028}' | '\u{2029}' | '\u{202f}' | '\u{205f}' | '\u{3000}'
27 )
28}
29
30#[must_use]
32pub fn is_blank(value: &str) -> bool {
33 value
34 .chars()
35 .all(|character| is_whitespace(character) || is_space_char(character))
36}
37
38#[must_use]
40pub fn float_to_string(value: f32) -> String {
41 floating_to_string(
42 value.is_sign_negative(),
43 value.is_nan(),
44 value.is_infinite(),
45 value == 0.0,
46 &format!("{:.8e}", value.abs()),
47 9,
48 |precision| format!("{:.*e}", precision, value.abs()),
49 |candidate| candidate.parse::<f32>().ok().map(f32::to_bits) == Some(value.abs().to_bits()),
50 )
51}
52
53pub(crate) fn double_to_string(value: f64) -> String {
54 floating_to_string(
55 value.is_sign_negative(),
56 value.is_nan(),
57 value.is_infinite(),
58 value == 0.0,
59 &format!("{:.16e}", value.abs()),
60 17,
61 |precision| format!("{:.*e}", precision, value.abs()),
62 |candidate| candidate.parse::<f64>().ok().map(f64::to_bits) == Some(value.abs().to_bits()),
63 )
64}
65
66#[expect(
67 clippy::too_many_arguments,
68 clippy::fn_params_excessive_bools,
69 reason = "shared Java float formatting parameters"
70)]
71fn floating_to_string(
72 negative: bool,
73 nan: bool,
74 infinite: bool,
75 zero: bool,
76 precise: &str,
77 max_digits: usize,
78 scientific: impl Fn(usize) -> String,
79 rounds_to_value: impl Fn(&str) -> bool,
80) -> String {
81 if nan {
82 return "NaN".to_owned();
83 }
84 if infinite {
85 return if negative { "-Infinity" } else { "Infinity" }.to_owned();
86 }
87 if zero {
88 return if negative { "-0.0" } else { "0.0" }.to_owned();
89 }
90
91 let Some((precise_mantissa, _)) = precise.split_once('e') else {
92 panic!("Rust scientific formatting omitted its exponent");
93 };
94 let precise_digits = precise_mantissa.replace('.', "");
95 let one_digit_is_exact = precise_digits[1..].bytes().all(|digit| digit == b'0');
96 let mut selected = None;
97 for length in 1..=max_digits {
98 let formatted = scientific(length - 1);
99 let Some((mantissa, exponent)) = formatted.split_once('e') else {
100 panic!("Rust scientific formatting omitted its exponent");
101 };
102 if !rounds_to_value(&formatted) {
103 continue;
104 }
105 let Ok(exponent) = exponent.parse::<i32>() else {
106 panic!("Rust scientific formatting emitted a non-decimal exponent");
107 };
108 let digits = mantissa.replace('.', "");
109 selected = Some((digits, exponent - length as i32 + 1));
110 if length >= 2 || one_digit_is_exact {
111 break;
112 }
113 }
114 let Some((mut digits, decimal_exponent)) = selected else {
115 panic!("full-precision Rust decimal did not round-trip");
116 };
117 while digits.ends_with('0') {
118 digits.pop();
119 }
120 let scientific_exponent = digits.len() as i32 + decimal_exponent - 1;
121 let mut output = if (-3..0).contains(&scientific_exponent) {
122 format!(
123 "0.{}{}",
124 "0".repeat((-scientific_exponent - 1) as usize),
125 digits
126 )
127 } else if (0..7).contains(&scientific_exponent) {
128 let decimal_position = (scientific_exponent + 1) as usize;
129 if decimal_position >= digits.len() {
130 format!(
131 "{}{}.0",
132 digits,
133 "0".repeat(decimal_position - digits.len())
134 )
135 } else {
136 format!(
137 "{}.{}",
138 &digits[..decimal_position],
139 &digits[decimal_position..]
140 )
141 }
142 } else {
143 let fraction = if digits.len() == 1 { "0" } else { &digits[1..] };
144 format!("{}.{}E{scientific_exponent}", &digits[..1], fraction)
145 };
146 if negative {
147 output.insert(0, '-');
148 }
149 output
150}
151
152#[cfg(test)]
153mod tests {
154 use super::{float_to_string, is_blank, is_space_char, is_whitespace};
155
156 #[test]
157 fn matches_java_whitespace_exclusions() {
158 assert!(is_whitespace(' '));
159 assert!(is_whitespace('\u{1680}'));
160 for non_breaking_space in ['\u{0085}', '\u{00a0}', '\u{2007}', '\u{202f}'] {
161 assert!(!is_whitespace(non_breaking_space));
162 }
163 }
164
165 #[test]
166 fn space_char_includes_unicode_space_separators() {
167 for space in [' ', '\u{00a0}', '\u{2007}', '\u{202f}'] {
168 assert!(is_space_char(space));
169 }
170 assert!(!is_space_char('\u{0085}'));
171 }
172
173 #[test]
174 fn blank_combines_java_whitespace_and_space_char() {
175 assert!(is_blank(""));
176 assert!(is_blank("\u{001c}\u{00a0}\u{202f}"));
177 assert!(!is_blank("\u{0085}"));
178 assert!(!is_blank(" text "));
179 }
180
181 #[test]
182 fn float_string_matches_java_integral_values() {
183 assert_eq!(float_to_string(0.0), "0.0");
184 assert_eq!(float_to_string(-0.0), "-0.0");
185 assert_eq!(float_to_string(90.0), "90.0");
186 assert_eq!(float_to_string(45.5), "45.5");
187 }
188}