Skip to main content

ravenrs_sheet/base/
parse.rs

1//! Parsing what people type into a cell (and numeric text in formulas).
2//!
3//! Recognises numbers with thousands separators, percentages, currency, scientific notation,
4//! fractions (`1 1/2`), dates (`3/4/2026`, `2026-03-04`, `4-Mar-2026`, `Mar 4, 2026`), times
5//! (`10:30`, `10:30:15 PM`) and date-times. Locale: en-US.
6
7use crate::base::date::{DateSystem, MONTHS, serial_from_ymd, time_fraction};
8use crate::base::value::{CellError, Value};
9
10/// A parsed entry: its value and the number format Excel would apply automatically.
11#[derive(Clone, Debug, PartialEq)]
12pub struct Parsed {
13    pub value: Value,
14    pub format: Option<&'static str>,
15}
16
17/// Interprets typed cell input (not formulas). Text that isn't a number, date, boolean or error
18/// stays text. A leading apostrophe forces text.
19pub fn parse_input(s: &str, sys: DateSystem) -> Parsed {
20    if s.is_empty() {
21        return Parsed { value: Value::Empty, format: None };
22    }
23    if let Some(rest) = s.strip_prefix('\'') {
24        return Parsed { value: Value::text(rest), format: None };
25    }
26    let t = s.trim();
27    if t.eq_ignore_ascii_case("TRUE") {
28        return Parsed { value: Value::Bool(true), format: None };
29    }
30    if t.eq_ignore_ascii_case("FALSE") {
31        return Parsed { value: Value::Bool(false), format: None };
32    }
33    if let Some(e) = CellError::parse(t) {
34        return Parsed { value: Value::Error(e), format: None };
35    }
36    if let Some((n, f)) = parse_numeric(t, sys) {
37        return Parsed { value: Value::number(n), format: f };
38    }
39    Parsed { value: Value::text(s), format: None }
40}
41
42/// Numeric text as arithmetic coerces it ("1,000", "50%", "$3", "1/2/2020", "10:00").
43pub fn parse_number_text(s: &str) -> Option<f64> {
44    parse_number_text_in(s, DateSystem::D1900)
45}
46
47/// Like [`parse_number_text`], with date text read in the given workbook date system.
48pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option<f64> {
49    parse_numeric(s.trim(), sys).map(|(n, _)| n)
50}
51
52/// Text that Excel's "Number Stored as Text" check flags: it would be a number if typed (signs,
53/// thousands separators, currency, percent, exponent, a parenthesised negative). Dates, times and
54/// fractions kept as text are not flagged, as in Excel.
55pub fn number_stored_as_text(s: &str) -> Option<f64> {
56    let t = s.trim();
57    // The same parentheses typed entry reads, accounting forms included: `$(5)`, `(5)%`.
58    match parenthesized(t) {
59        Some(inner) if inner.strip_prefix('$').map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) => None,
60        Some(inner) => parse_plain(&inner).map(|(n, _)| -n),
61        None => parse_plain(t).map(|(n, _)| n),
62    }
63}
64
65fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
66    // Parenthesised negatives: (100) = -100, and the accounting forms `$(100)` and `(5)%`.
67    if let Some(inner) = parenthesized(t) {
68        // Excel reads no sign and no nested parentheses inside: `(-5)`, `($-5)`, `((5))`.
69        if inner.strip_prefix('$').map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) {
70            return None;
71        }
72        let r = parse_plain(&inner).or_else(|| parse_fraction(&inner).map(|n| (n, Some("# ?/?"))));
73        return r.map(|(n, f)| (-n, f));
74    }
75    parse_unparenthesised(t, sys)
76}
77
78fn parse_unparenthesised(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
79    if t.is_empty() {
80        return None;
81    }
82    if let Some(r) = parse_plain(t) {
83        return Some(r);
84    }
85    if let Some(r) = parse_fraction(t) {
86        return Some((r, Some("# ?/?")));
87    }
88    if let Some(r) = parse_datetime(t, sys) {
89        return Some(r);
90    }
91    None
92}
93
94/// The number inside accounting parentheses, as plain text: `(5)` gives `5`. A dollar sign may
95/// stand before them (`$(5)` reads as `($5)`) and a percent sign after them (`(5)%` reads as
96/// `(5%)`), with spaces around either. The caller reads only a plain number or a fraction from it.
97fn parenthesized(t: &str) -> Option<String> {
98    let (currency, rest) = match t.strip_prefix('$') {
99        Some(r) => ("$", r.trim_start()),
100        None => ("", t),
101    };
102    let rest = rest.strip_prefix('(')?;
103    let (body, percent) = match rest.strip_suffix(')') {
104        Some(b) => (b, ""),
105        None => (rest.strip_suffix('%')?.trim_end().strip_suffix(')')?, "%"),
106    };
107    Some(format!("{currency}{}{percent}", body.trim()))
108}
109
110/// Signs, currency, thousands separators, percent, exponent.
111fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
112    let mut s = t;
113    let mut neg = false;
114    let mut currency = false;
115    let mut percent = false;
116    if let Some(r) = s.strip_prefix('-') {
117        neg = true;
118        s = r.trim_start();
119    } else if let Some(r) = s.strip_prefix('+') {
120        s = r.trim_start();
121    }
122    if let Some(r) = s.strip_prefix('$') {
123        currency = true;
124        s = r.trim_start();
125        if !neg && let Some(r2) = s.strip_prefix('-') {
126            neg = true;
127            s = r2;
128        }
129    }
130    if let Some(r) = s.strip_suffix('%') {
131        percent = true;
132        s = r.trim_end();
133    }
134    // Excel reads `$5` and `5%` but not `$5%`.
135    if s.is_empty() || currency && percent {
136        return None;
137    }
138    let has_sep = s.contains(',');
139    let digits: String = if has_sep {
140        // Thousands separators must be in groups of three before the decimal point.
141        let (int, frac) = s.split_once('.').map_or((s, None), |(a, b)| (a, Some(b)));
142        let groups: Vec<&str> = int.split(',').collect();
143        let first = groups.first()?;
144        if first.is_empty() || first.len() > 3 || groups.iter().skip(1).any(|g| g.len() != 3) {
145            return None;
146        }
147        let mut d = groups.concat();
148        if let Some(f) = frac {
149            d.push('.');
150            d.push_str(f);
151        }
152        d
153    } else {
154        s.to_string()
155    };
156    let b = digits.as_bytes();
157    if !b.iter().all(|c| c.is_ascii_digit() || matches!(c, b'.' | b'e' | b'E' | b'+' | b'-')) {
158        return None;
159    }
160    if !b.first().is_some_and(|c| c.is_ascii_digit() || *c == b'.') {
161        return None;
162    }
163    let mut n: f64 = digits.parse().ok()?;
164    if !n.is_finite() {
165        return None;
166    }
167    let sci = digits.contains(['e', 'E']);
168    if percent {
169        n /= 100.0;
170    }
171    if neg {
172        n = -n;
173    }
174    let fmt = if currency {
175        Some(if digits.contains('.') { "\"$\"#,##0.00" } else { "\"$\"#,##0" })
176    } else if percent {
177        Some(if digits.contains('.') { "0.00%" } else { "0%" })
178    } else if sci {
179        Some("0.00E+00")
180    } else if has_sep {
181        Some(if digits.contains('.') { "#,##0.00" } else { "#,##0" })
182    } else {
183        None
184    };
185    Some((n, fmt))
186}
187
188/// A plain ASCII number: digits, with an optional `.digits` fraction when `frac` is set. Rejects
189/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept, and digits too many for a
190/// finite number (#2163).
191fn plain_number(s: &str, frac: bool) -> Option<f64> {
192    let (int, dec) = match s.split_once('.') {
193        Some((i, d)) if frac => (i, Some(d)),
194        _ => (s, None),
195    };
196    let digits = |x: &str| !x.is_empty() && x.bytes().all(|c| c.is_ascii_digit());
197    if !digits(int) || dec.is_some_and(|d| !digits(d)) {
198        return None;
199    }
200    s.parse().ok().filter(|x: &f64| x.is_finite())
201}
202
203/// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`.
204fn parse_fraction(t: &str) -> Option<f64> {
205    let (whole, frac) = t.split_once(' ')?;
206    let neg = whole.starts_with('-');
207    let w = plain_number(whole.trim_start_matches('-'), false)?;
208    let (a, b) = frac.trim().split_once('/')?;
209    let a = plain_number(a, false)?;
210    let b = plain_number(b, false)?;
211    if b == 0.0 || !w.is_finite() {
212        return None;
213    }
214    let v = w + a / b;
215    if !v.is_finite() {
216        return None;
217    }
218    Some(if neg { -v } else { v })
219}
220
221fn month_from_name(s: &str) -> Option<u32> {
222    let l = s.to_ascii_lowercase();
223    if l.len() < 3 {
224        return None;
225    }
226    MONTHS.iter().position(|m| m.to_ascii_lowercase().starts_with(&l)).map(|i| i as u32 + 1)
227}
228
229/// Time part: `h:mm`, `h:mm:ss`, `h:mm:ss.000`, `h AM`, with optional AM/PM.
230fn parse_time(t: &str) -> Option<(f64, bool, bool)> {
231    let t = t.trim();
232    let lower = t.to_ascii_lowercase();
233    let (body, ampm) = if let Some(b) = lower.strip_suffix("am").or_else(|| lower.strip_suffix('a')) {
234        (b.trim().to_string(), Some(false))
235    } else if let Some(b) = lower.strip_suffix("pm").or_else(|| lower.strip_suffix('p')) {
236        (b.trim().to_string(), Some(true))
237    } else {
238        (lower.clone(), None)
239    };
240    let parts: Vec<&str> = body.split(':').collect();
241    if parts.len() == 1 && ampm.is_none() {
242        return None;
243    }
244    if parts.len() > 3 || parts.iter().any(|p| p.is_empty() || p.starts_with('+')) {
245        return None;
246    }
247    let mut h = plain_number(parts.first()?, false)?;
248    let m = parts.get(1).map_or(Some(0.0), |p| plain_number(p, false))?;
249    let s = parts.get(2).map_or(Some(0.0), |p| plain_number(p, true))?;
250    if m >= 60.0 || s >= 60.0 || h < 0.0 || m < 0.0 || s < 0.0 {
251        return None;
252    }
253    if let Some(pm) = ampm {
254        if !(0.0..=12.0).contains(&h) {
255            return None;
256        }
257        if h == 12.0 {
258            h = 0.0;
259        }
260        if pm {
261            h += 12.0;
262        }
263    } else if h >= 10000.0 {
264        return None;
265    }
266    Some((time_fraction(h, m, s), parts.len() == 3, ampm.is_some()))
267}
268
269fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> {
270    let t = t.trim().trim_end_matches(',');
271    let num = |s: &str| {
272        let s = s.trim();
273        if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
274            return None;
275        }
276        s.parse::<i64>().ok()
277    };
278    // ISO yyyy-mm-dd or yyyy/mm/dd
279    let seps: &[char] = &['/', '-', '.'];
280    let parts: Vec<&str> = t.split(seps).collect();
281    if parts.len() == 3 {
282        let (a, b, c) = (parts[0].trim(), parts[1].trim(), parts[2].trim());
283        if a.len() == 4
284            && let (Some(y), Some(m), Some(d)) = (num(a), num(b), num(c))
285        {
286            return valid_ymd(sys, y, m, d).map(|s| (s, "yyyy-mm-dd"));
287        }
288        if let (Some(m), Some(d), Some(y)) = (num(a), num(b), num(c)) {
289            let y = if c.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y };
290            return valid_ymd(sys, y, m, d).map(|s| (s, "m/d/yyyy"));
291        }
292        // 4-Mar-2026
293        if let (Some(d), Some(m), Some(y)) = (num(a), month_from_name(b), num(c)) {
294            let y = if c.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y };
295            return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy"));
296        }
297    }
298    if parts.len() == 2 {
299        let (a, b) = (parts[0].trim(), parts[1].trim());
300        // m/d in the current year is ambiguous without a clock; use month/day of 2026? Excel uses
301        // the current year. We accept d-mmm / mmm-d only with month names, and m/d → current year.
302        if let (Some(m), Some(d)) = (num(a), num(b)) {
303            let y = current_year();
304            if (1..=12).contains(&m) && (1..=31).contains(&d) {
305                return valid_ymd(sys, y, m, d).map(|s| (s, "d-mmm"));
306            }
307            // m/yyyy
308            if (1..=12).contains(&m) && b.len() == 4 {
309                return valid_ymd(sys, d, m, 1).map(|s| (s, "mmm-yy"));
310            }
311        }
312        if let (Some(d), Some(m)) = (num(a), month_from_name(b)) {
313            return valid_ymd(sys, current_year(), m as i64, d).map(|s| (s, "d-mmm"));
314        }
315        if let (Some(m), Some(y)) = (month_from_name(a), num(b)) {
316            let y = if b.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y };
317            return valid_ymd(sys, y, m as i64, 1).map(|s| (s, "mmm-yy"));
318        }
319    }
320    // "Mar 4, 2026" / "March 4 2026" / "4 March 2026"
321    let words: Vec<&str> = t.split([' ', ',']).filter(|w| !w.is_empty()).collect();
322    if words.len() == 3 {
323        // Two-digit years follow the same 2029 rule as the dashed forms above.
324        let expand = |w: &str, y: i64| if w.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y };
325        if let (Some(m), Some(d), Some(y)) = (month_from_name(words[0]), num(words[1]), num(words[2])) {
326            let y = expand(words[2], y);
327            return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy"));
328        }
329        if let (Some(d), Some(m), Some(y)) = (num(words[0]), month_from_name(words[1]), num(words[2])) {
330            let y = expand(words[2], y);
331            return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy"));
332        }
333    }
334    None
335}
336
337fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option<f64> {
338    // Dates before 1900 stay text (`DATE()` would add 1900 to such a year).
339    if !(1..=12).contains(&m) || d < 1 || y < 1900 {
340        return None;
341    }
342    let max = crate::base::date::days_in_month_in(sys, y as i32, m as u32) as i64;
343    if d > max {
344        return None;
345    }
346    serial_from_ymd(sys, y, m, d)
347}
348
349/// The year used for dates typed without one, from the system clock (browser clock on wasm).
350/// Falls back to 2026 only when no clock is available.
351pub fn current_year() -> i64 {
352    if let Some(ms) = crate::base::date::unix_now_ms() {
353        let days = (ms / 86_400_000.0).floor() as i64;
354        return crate::base::date::civil_from_days(days).0 as i64;
355    }
356    2026
357}
358
359fn parse_datetime(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
360    if let Some((tf, secs, ampm)) = parse_time(t) {
361        let f = match (secs, ampm) {
362            // 24 hours or more: elapsed hours, so 25:00 shows as 25:00:00, not 1:00.
363            _ if tf >= 1.0 => "[h]:mm:ss",
364            (false, false) => "h:mm",
365            (true, false) => "h:mm:ss",
366            (false, true) => "h:mm AM/PM",
367            (true, true) => "h:mm:ss AM/PM",
368        };
369        return Some((tf, Some(f)));
370    }
371    if let Some((d, f)) = parse_date(t, sys) {
372        return Some((d, Some(f)));
373    }
374    // date + time: split at the last space(s) before a time-looking tail. Only splits where the
375    // tail could be a time are tried: a long tail can't parse, so this stays linear in the text
376    // instead of reparsing every prefix for every space (one long text field quadratic).
377    for (i, _) in t.match_indices(' ') {
378        let Some(b) = t.get(i + 1..) else { continue };
379        let bytes = b.as_bytes();
380        if b.len() > 36 || !bytes.first().is_some_and(u8::is_ascii_digit) || !b.contains(':') {
381            continue;
382        }
383        if let (Some((d, _)), Some((tf, _, _))) = (t.get(..i).and_then(|a| parse_date(a, sys)), parse_time(b)) {
384            return Some((d + tf, Some("m/d/yyyy h:mm")));
385        }
386    }
387    None
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    fn p(s: &str) -> Parsed {
395        parse_input(s, DateSystem::D1900)
396    }
397
398    /// #2163: digits too many for a finite number don't make one (∞/∞ was NaN).
399    #[test]
400    fn overlong_digits_stay_text() {
401        let big = "9".repeat(400);
402        for s in [format!("1 {big}/{big}"), format!("1 {big}/2"), format!("1 1/{big}0"), format!("{big}:00"), format!("1:00:{big}.5")] {
403            assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
404            assert_eq!(parse_number_text(&s), None, "{s}");
405        }
406        assert_eq!(parse_number_text("1 1/2"), Some(1.5));
407    }
408
409    #[test]
410    fn overlong_time_fields_stay_text() {
411        // #2168: all-digit field lengths are gated before allocation/derivation,
412        // so hostile strings never spin the time-derivation machinery.
413        let big = "9".repeat(500);
414        for s in [format!("{big}:00"), format!("1:00:{big}"), format!("{big}:{big}:{big}"), format!("1:0{big}"), format!("00:00:{big}.1")] {
415            assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
416            assert_eq!(parse_number_text(&s), None, "{s}");
417        }
418        // Within the gate, times still work.
419        assert_eq!(p("01:02:03").value, Value::number(3.0 / 86_400.0 + 1.0 / 24.0 + 2.0 / 1440.0));
420        assert_eq!(parse_number_text("13:14:15"), Some(0.5515625));
421    }
422
423    #[test]
424    fn non_digit_time_and_fraction_parts_stay_text() {
425        for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
426            assert_eq!(p(s).value, Value::text(s), "{s}");
427        }
428    }
429
430    #[test]
431    fn signed_date_parts_stay_text() {
432        for s in ["+3/4/2026", "3/+4/2026", "3/4/+2026", "1/1/2020 +5:00"] {
433            assert_eq!(p(s).value, Value::text(s), "{s}");
434        }
435        assert_eq!(p("3/4/2026").value, Value::Number(46085.0));
436    }
437
438    #[test]
439    fn month_name_short_year() {
440        for s in ["Mar 4, 26", "Mar 4 26", "March 4 26", "4 March 26", "Mar 4, 2026"] {
441            assert_eq!(p(s).value, Value::Number(46085.0), "{s}");
442        }
443        assert_eq!(p("Mar 4, 99").value, p("Mar 4, 1999").value);
444        assert_eq!(p("Mar 4, 1776").value, Value::text("Mar 4, 1776"));
445        assert_eq!(p("Feb 30, 26").value, Value::text("Feb 30, 26"));
446    }
447
448    #[test]
449    fn numbers() {
450        assert_eq!(p("42").value, Value::Number(42.0));
451        assert_eq!(p("-3.5").value, Value::Number(-3.5));
452        assert_eq!(p("1,234").format, Some("#,##0"));
453        assert_eq!(p("1,234").value, Value::Number(1234.0));
454        assert_eq!(p("1,23").value, Value::text("1,23"));
455        assert_eq!(p("12%").value, Value::Number(0.12));
456        assert_eq!(p("$1,200.50").value, Value::Number(1200.5));
457        assert_eq!(p("$1,200.50").format, Some("\"$\"#,##0.00"));
458        assert_eq!(p("(100)").value, Value::Number(-100.0));
459        assert_eq!(p("1e3").value, Value::Number(1000.0));
460        assert_eq!(p("1 1/2").value, Value::Number(1.5));
461        assert_eq!(p(".5").value, Value::Number(0.5));
462    }
463
464    /// Observed in Excel 365 (en-US): typed entry, VALUE and `--"text"` agree.
465    #[test]
466    fn accounting_parentheses() {
467        let accepted: [(&str, f64, Option<&str>); 22] = [
468            ("$(5)", -5.0, Some("\"$\"#,##0")),
469            ("$ (5)", -5.0, Some("\"$\"#,##0")),
470            ("$( 5 )", -5.0, Some("\"$\"#,##0")),
471            ("$ ( 1,234 )", -1234.0, Some("\"$\"#,##0")),
472            ("$(1,234)", -1234.0, Some("\"$\"#,##0")),
473            ("$(1,000.50)", -1000.5, Some("\"$\"#,##0.00")),
474            ("$(1,234.)", -1234.0, Some("\"$\"#,##0.00")),
475            ("$(.5)", -0.5, Some("\"$\"#,##0.00")),
476            ("$(0)", 0.0, Some("\"$\"#,##0")),
477            (" $(5) ", -5.0, Some("\"$\"#,##0")),
478            ("($5)", -5.0, Some("\"$\"#,##0")),
479            ("( $ 1,234 )", -1234.0, Some("\"$\"#,##0")),
480            ("(5)", -5.0, None),
481            ("( 5 )", -5.0, None),
482            ("(1,234.5)", -1234.5, Some("#,##0.00")),
483            ("(1e-3)", -0.001, Some("0.00E+00")),
484            ("(5)%", -0.05, Some("0%")),
485            ("( 5 ) %", -0.05, Some("0%")),
486            ("(5%)", -0.05, Some("0%")),
487            ("(5.)%", -0.05, Some("0.00%")),
488            ("(1,234)%", -12.34, Some("0%")),
489            ("(1 1/2)", -1.5, Some("# ?/?")),
490        ];
491        for (s, n, f) in accepted {
492            assert_eq!(p(s), Parsed { value: Value::Number(n), format: f }, "{s}");
493            assert_eq!(parse_number_text(s), Some(n), "{s}");
494        }
495        for s in [
496            "$5%",
497            "$(5%)",
498            "$(5)%",
499            "($5%)",
500            "($5)%",
501            "$ ( 5 ) %",
502            "(5)%%",
503            "(5%)%",
504            "((5))",
505            "(-5)",
506            "(+5)",
507            "($-5)",
508            "($ -5)",
509            "($+5)",
510            "(-$5)",
511            "$(-5)",
512            "$ (-5)",
513            "$(+5)",
514            "-(5)",
515            "-$(5)",
516            "+$(5)",
517            "$+(5)",
518            "$$(5)",
519            "$ $(5)",
520            "$((5))",
521            "($(5))",
522            "(-1 1/2)",
523            "()",
524            "$()",
525            "(5)$",
526            "$(5)$",
527            "(5)(5)",
528            "$(5)0",
529            "(5)e3",
530            "(5)-",
531            "(5-)",
532            "$(5-)",
533            "$(5",
534            "$5)",
535            "(5",
536            "$(1,23)",
537            "$(,5)",
538            "(5e)",
539            "(1/2)",
540            "$(1/2)",
541            "(3/4/2026)",
542            "$(3/4/2026)",
543            "(10:30)",
544            "$(10:30)",
545        ] {
546            assert_eq!(p(s).value, Value::text(s), "{s}");
547            assert_eq!(parse_number_text(s), None, "{s}");
548        }
549    }
550
551    #[test]
552    fn deeply_nested_parentheses_do_not_overflow() {
553        // #2180: coercion used to recurse once per parenthesis and overflow the stack. Excel reads
554        // one pair only, so nesting is text now, and it is rejected without recursing.
555        let depth = 100_000;
556        let s = format!("{}1{}", "(".repeat(depth), ")".repeat(depth));
557        assert_eq!(parse_number_text(&s), None);
558        assert_eq!(p(&s).value, Value::text(s.as_str()));
559        let odd = format!("{}1{}", "(".repeat(depth + 1), ")".repeat(depth + 1));
560        assert_eq!(parse_number_text(&odd), None);
561        let unbalanced = format!("{}1{}", "(".repeat(depth), ")".repeat(depth - 1));
562        assert_eq!(parse_number_text(&unbalanced), None);
563        // A single pair still reads.
564        assert_eq!(parse_number_text("(100)"), Some(-100.0));
565        assert_eq!(parse_number_text("( $1,200.50 )"), Some(-1200.5));
566        assert_eq!(parse_number_text("()"), None);
567    }
568
569    #[test]
570    fn texts_and_specials() {
571        assert_eq!(p("hello").value, Value::text("hello"));
572        assert_eq!(p("'123").value, Value::text("123"));
573        assert_eq!(p("true").value, Value::Bool(true));
574        assert_eq!(p("#N/A").value, Value::Error(CellError::NA));
575        assert_eq!(p("e").value, Value::text("e"));
576        assert_eq!(p("-").value, Value::text("-"));
577        assert_eq!(p("1.2.3.4").value, Value::text("1.2.3.4"));
578    }
579
580    #[test]
581    fn dates_and_times() {
582        assert_eq!(p("10/7/2026").value, Value::Number(46302.0));
583        assert_eq!(p("2026-10-07").value, Value::Number(46302.0));
584        assert_eq!(p("7-Oct-2026").value, Value::Number(46302.0));
585        assert_eq!(p("Oct 7, 2026").value, Value::Number(46302.0));
586        assert_eq!(p("2/30/2026").value, Value::text("2/30/2026"));
587        assert_eq!(p("7/4/1776").value, Value::text("7/4/1776"));
588        assert_eq!(p("1776-07-04").value, Value::text("1776-07-04"));
589        assert_eq!(p("4-Jul-1776").value, Value::text("4-Jul-1776"));
590        assert_eq!(p("12/31/1899").value, Value::text("12/31/1899"));
591        assert_eq!(p("1/1/1900").value, Value::Number(1.0));
592        assert_eq!(p("12:00").value, Value::Number(0.5));
593        assert_eq!(p("6:00 PM").value, Value::Number(0.75));
594        assert_eq!(p("12:00 AM").value, Value::Number(0.0));
595        assert_eq!(p("10/7/2026 18:00").value, Value::Number(46302.75));
596        assert_eq!(p("25:00").value, Value::Number(25.0 / 24.0));
597        assert_eq!(p("25:00").format, Some("[h]:mm:ss"));
598        assert_eq!(p("23:59").format, Some("h:mm"));
599    }
600
601    #[test]
602    fn fictitious_1900_02_29() {
603        // Excel accepts 29 February 1900 (serial 60) and keeps 1 March 1900 at 61.
604        assert_eq!(p("2/29/1900").value, Value::Number(60.0));
605        assert_eq!(p("1900-02-29").value, Value::Number(60.0));
606        assert_eq!(p("29-Feb-1900").value, Value::Number(60.0));
607        assert_eq!(p("2/28/1900").value, Value::Number(59.0));
608        assert_eq!(p("3/1/1900").value, Value::Number(61.0));
609        assert_eq!(p("2/29/1901").value, Value::text("2/29/1901"));
610        assert_eq!(p("2/30/1900").value, Value::text("2/30/1900"));
611        // The 1904 system starts after it.
612        assert_eq!(parse_input("2/29/1900", DateSystem::D1904).value, Value::text("2/29/1900"));
613        assert_eq!(parse_input("2/29/1904", DateSystem::D1904).value, Value::Number(59.0));
614    }
615
616    #[test]
617    fn numbers_stored_as_text() {
618        for (text, n) in [
619            ("123", 123.0),
620            (" 42 ", 42.0),
621            ("-1,000.5", -1000.5),
622            ("$3", 3.0),
623            ("50%", 0.5),
624            ("1E3", 1000.0),
625            ("(100)", -100.0),
626            ("$(5)", -5.0),
627            ("(5)%", -0.05),
628        ] {
629            assert_eq!(number_stored_as_text(text), Some(n), "{text}");
630        }
631        // Dates, times, fractions, words and blanks are not flagged.
632        for text in ["3/4/2026", "10:30", "1 1/2", "abc", "12abc", "", "  ", "TRUE", "#N/A", "((1))", "(-5)", "$(5)%", "(1/2)"] {
633            assert_eq!(number_stored_as_text(text), None, "{text}");
634        }
635    }
636}