1use std::sync::OnceLock;
2
3#[non_exhaustive]
5#[derive(Clone, Debug, PartialEq, Eq)]
6pub enum FormatClass {
7 General,
8 Number { decimals: u8, thousands: bool },
9 Date,
10 Time,
11 DateTime,
12 Duration,
13 Percent { decimals: u8 },
14 Currency { decimals: u8 },
15 Text,
16 Scientific,
17 Fraction,
18 Other,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct NumberFormat {
24 code: Box<str>,
25 class: FormatClass,
26}
27
28impl NumberFormat {
29 pub fn parse(code: &str) -> Self {
31 let code = canonicalize(code);
32 let class = classify(&code);
33 Self { code, class }
34 }
35
36 pub fn class(&self) -> &FormatClass {
37 &self.class
38 }
39
40 pub fn code(&self) -> &str {
41 &self.code
42 }
43
44 pub fn builtin(id: u16) -> Option<&'static NumberFormat> {
46 builtin_code(id).map(|_| {
47 static BUILTINS: OnceLock<Vec<Option<NumberFormat>>> = OnceLock::new();
48 BUILTINS
49 .get_or_init(|| {
50 (0..=49)
51 .map(|candidate| builtin_code(candidate).map(NumberFormat::parse))
52 .collect()
53 })
54 .get(id as usize)
55 .and_then(Option::as_ref)
56 .expect("known builtin id")
57 })
58 }
59}
60
61fn canonicalize(code: &str) -> Box<str> {
62 code.trim().into()
63}
64
65fn decimal_count(section: &str) -> u8 {
66 let visible = visible_format(section);
67 let Some(dot) = visible.find('.') else {
68 return 0;
69 };
70 visible[dot + 1..]
71 .chars()
72 .take_while(|ch| matches!(ch, '0' | '#' | '?'))
73 .count()
74 .min(u8::MAX as usize) as u8
75}
76
77fn visible_format(code: &str) -> String {
78 let mut out = String::with_capacity(code.len());
79 let mut chars = code.chars().peekable();
80 let mut quoted = false;
81 while let Some(ch) = chars.next() {
82 if quoted {
83 if ch == '"' {
84 quoted = false;
85 }
86 continue;
87 }
88 match ch {
89 '"' => quoted = true,
90 '\\' | '_' | '*' => {
91 chars.next();
92 }
93 '[' => {
94 let mut bracket = String::new();
95 for next in chars.by_ref() {
96 if next == ']' {
97 break;
98 }
99 bracket.push(next);
100 }
101 let lower = bracket.to_ascii_lowercase();
102 if lower.chars().all(|c| matches!(c, 'h' | 'm' | 's' | ':'))
103 && lower.chars().any(|c| matches!(c, 'h' | 'm' | 's'))
104 {
105 out.push('[');
106 out.push_str(&lower);
107 out.push(']');
108 }
109 }
110 _ => out.push(ch.to_ascii_lowercase()),
111 }
112 }
113 out
114}
115
116fn classify(code: &str) -> FormatClass {
117 if code.eq_ignore_ascii_case("general") {
118 return FormatClass::General;
119 }
120 let first = code.split(';').next().unwrap_or(code);
121 let visible = visible_format(first);
122 if visible.trim() == "@" {
123 return FormatClass::Text;
124 }
125 if visible.contains("[h]") || visible.contains("[m]") || visible.contains("[s]") {
126 return FormatClass::Duration;
127 }
128
129 let am_pm = visible.contains("am/pm") || visible.contains("a/p");
130 let has_year = visible.contains('y');
131 let has_day = visible.contains('d');
132 let has_hour = visible.contains('h');
133 let has_second = visible.contains('s');
134 let has_date = has_year || has_day;
136 let has_time = has_hour || has_second || am_pm;
137 if has_date && has_time {
138 return FormatClass::DateTime;
139 }
140 if has_date {
141 return FormatClass::Date;
142 }
143 if has_time {
144 return FormatClass::Time;
145 }
146 if visible.contains('%') {
147 return FormatClass::Percent {
148 decimals: decimal_count(first),
149 };
150 }
151 if visible.contains("e+") || visible.contains("e-") {
152 return FormatClass::Scientific;
153 }
154 if visible.contains('/') && visible.chars().any(|ch| ch == '?' || ch == '#') {
155 return FormatClass::Fraction;
156 }
157 let currency = visible.contains('$')
158 || visible.contains('€')
159 || visible.contains('£')
160 || visible.contains('¥');
161 if currency {
162 return FormatClass::Currency {
163 decimals: decimal_count(first),
164 };
165 }
166 if visible.chars().any(|ch| matches!(ch, '0' | '#' | '?')) {
167 return FormatClass::Number {
168 decimals: decimal_count(first),
169 thousands: visible.contains(','),
170 };
171 }
172 FormatClass::Other
173}
174
175pub fn builtin_code(id: u16) -> Option<&'static str> {
177 match id {
178 0 => Some("General"),
179 1 => Some("0"),
180 2 => Some("0.00"),
181 3 => Some("#,##0"),
182 4 => Some("#,##0.00"),
183 5 => Some("$#,##0_);($#,##0)"),
184 6 => Some("$#,##0_);[Red]($#,##0)"),
185 7 => Some("$#,##0.00_);($#,##0.00)"),
186 8 => Some("$#,##0.00_);[Red]($#,##0.00)"),
187 9 => Some("0%"),
188 10 => Some("0.00%"),
189 11 => Some("0.00E+00"),
190 12 => Some("# ?/?"),
191 13 => Some("# ??/??"),
192 14 => Some("m/d/yy"),
193 15 => Some("d-mmm-yy"),
194 16 => Some("d-mmm"),
195 17 => Some("mmm-yy"),
196 18 => Some("h:mm AM/PM"),
197 19 => Some("h:mm:ss AM/PM"),
198 20 => Some("h:mm"),
199 21 => Some("h:mm:ss"),
200 22 => Some("m/d/yy h:mm"),
201 37 => Some("#,##0_);(#,##0)"),
202 38 => Some("#,##0_);[Red](#,##0)"),
203 39 => Some("#,##0.00_);(#,##0.00)"),
204 40 => Some("#,##0.00_);[Red](#,##0.00)"),
205 41 => Some("_(* #,##0_);_(* (#,##0);_(* \"-\"_);_(@_)"),
206 42 => Some("_($* #,##0_);_($* (#,##0);_($* \"-\"_);_(@_)"),
207 43 => Some("_(* #,##0.00_);_(* (#,##0.00);_(* \"-\"??_);_(@_)"),
208 44 => Some("_($* #,##0.00_);_($* (#,##0.00);_($* \"-\"??_);_(@_)"),
209 45 => Some("mm:ss"),
210 46 => Some("[h]:mm:ss"),
211 47 => Some("mmss.0"),
212 48 => Some("##0.0E+0"),
213 49 => Some("@"),
214 _ => None,
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn classifies_temporal_codes_without_being_fooled_by_literals() {
224 assert_eq!(
225 NumberFormat::parse("yyyy-mm-dd").class(),
226 &FormatClass::Date
227 );
228 assert_eq!(NumberFormat::parse("h:mm:ss").class(), &FormatClass::Time);
229 assert_eq!(
230 NumberFormat::parse("yyyy-mm-dd hh:mm").class(),
231 &FormatClass::DateTime
232 );
233 assert_eq!(
234 NumberFormat::parse("[h]:mm:ss").class(),
235 &FormatClass::Duration
236 );
237 assert_eq!(
238 NumberFormat::parse("0.00 \"days\"").class(),
239 &FormatClass::Number {
240 decimals: 2,
241 thousands: false
242 }
243 );
244 }
245
246 #[test]
247 fn classifies_non_temporal_codes() {
248 assert_eq!(
249 NumberFormat::parse("General").class(),
250 &FormatClass::General
251 );
252 assert_eq!(NumberFormat::parse("@").class(), &FormatClass::Text);
253 assert_eq!(
254 NumberFormat::parse("0.00%").class(),
255 &FormatClass::Percent { decimals: 2 }
256 );
257 assert_eq!(
258 NumberFormat::parse("0.00E+00").class(),
259 &FormatClass::Scientific
260 );
261 assert_eq!(
262 NumberFormat::parse("# ??/??").class(),
263 &FormatClass::Fraction
264 );
265 assert_eq!(
266 NumberFormat::parse("$#,##0.00").class(),
267 &FormatClass::Currency { decimals: 2 }
268 );
269 }
270
271 #[test]
272 fn builtin_table_has_expected_generic_classes() {
273 assert_eq!(
274 NumberFormat::builtin(0).unwrap().class(),
275 &FormatClass::General
276 );
277 assert_eq!(
278 NumberFormat::builtin(14).unwrap().class(),
279 &FormatClass::Date
280 );
281 assert_eq!(
282 NumberFormat::builtin(21).unwrap().class(),
283 &FormatClass::Time
284 );
285 assert_eq!(
286 NumberFormat::builtin(22).unwrap().class(),
287 &FormatClass::DateTime
288 );
289 assert_eq!(
290 NumberFormat::builtin(46).unwrap().class(),
291 &FormatClass::Duration
292 );
293 assert_eq!(
294 NumberFormat::builtin(49).unwrap().class(),
295 &FormatClass::Text
296 );
297 assert!(NumberFormat::builtin(23).is_none());
298 assert!(NumberFormat::builtin(50).is_none());
299 }
300}