mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 03:36:54 -06:00
serial_from_ymd worked out the real calendar date and then moved serials
up to 60 down by one. February 1900 has no 29th in the real calendar, so
DATE(1900,2,29) and DATEVALUE("2/29/1900") rolled over to 1 March (61),
and DATE(1900,3,0) gave 28 February (59). Excel's 1900 system keeps the
Lotus leap day: serial 60 is 29 February 1900, and DAY(60) already
said so.
The serial of the first of the month is now taken in Excel's calendar
(one lower up to February 1900), and the day, with any overflow, counts
on from there. So day and month overflow pass through serial 60 both
ways: DATE(1900,1,60), DATE(1900,4,-31) and DATE(1901,-10,29) are 60 too.
Month and day arithmetic is checked, so hostile values return None.
days_in_month_in gives February 1900 29 days in the 1900 system; EDATE
and EOMONTH use it, so EOMONTH(60,0) is 60 rather than the day before,
and the text parser uses it in place of its own special case. The 1904
system is unchanged (1900 is before its epoch).
Tests: serials around the leap day (overflow both ways, every serial
0-366 round-trips through its year, month and day), hostile months and
days, parsing 2/29/1900 and 1900-02-29, DATE/DATEVALUE/EDATE/EOMONTH
in the 1900 system and the 1904 system. The DATE, DATEVALUE and serial
cases fail before the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
376 lines
14 KiB
Rust
376 lines
14 KiB
Rust
//! Parsing what people type into a cell (and numeric text in formulas).
|
|
//!
|
|
//! Recognises numbers with thousands separators, percentages, currency, scientific notation,
|
|
//! fractions (`1 1/2`), dates (`3/4/2026`, `2026-03-04`, `4-Mar-2026`, `Mar 4, 2026`), times
|
|
//! (`10:30`, `10:30:15 PM`) and date-times. Locale: en-US.
|
|
|
|
use crate::date::{DateSystem, MONTHS, serial_from_ymd, time_fraction};
|
|
use crate::value::{CellError, Value};
|
|
|
|
/// A parsed entry: its value and the number format Excel would apply automatically.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Parsed {
|
|
pub value: Value,
|
|
pub format: Option<&'static str>,
|
|
}
|
|
|
|
/// Interprets typed cell input (not formulas). Text that isn't a number, date, boolean or error
|
|
/// stays text. A leading apostrophe forces text.
|
|
pub fn parse_input(s: &str, sys: DateSystem) -> Parsed {
|
|
if s.is_empty() {
|
|
return Parsed { value: Value::Empty, format: None };
|
|
}
|
|
if let Some(rest) = s.strip_prefix('\'') {
|
|
return Parsed { value: Value::text(rest), format: None };
|
|
}
|
|
let t = s.trim();
|
|
if t.eq_ignore_ascii_case("TRUE") {
|
|
return Parsed { value: Value::Bool(true), format: None };
|
|
}
|
|
if t.eq_ignore_ascii_case("FALSE") {
|
|
return Parsed { value: Value::Bool(false), format: None };
|
|
}
|
|
if let Some(e) = CellError::parse(t) {
|
|
return Parsed { value: Value::Error(e), format: None };
|
|
}
|
|
if let Some((n, f)) = parse_numeric(t, sys) {
|
|
return Parsed { value: Value::number(n), format: f };
|
|
}
|
|
Parsed { value: Value::text(s), format: None }
|
|
}
|
|
|
|
/// Numeric text as arithmetic coerces it ("1,000", "50%", "$3", "1/2/2020", "10:00").
|
|
pub fn parse_number_text(s: &str) -> Option<f64> {
|
|
parse_numeric(s.trim(), DateSystem::D1900).map(|(n, _)| n)
|
|
}
|
|
|
|
fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
|
|
if t.is_empty() {
|
|
return None;
|
|
}
|
|
// Parenthesised negatives: (100) = -100.
|
|
if let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
|
|
return parse_numeric(inner.trim(), sys).map(|(n, f)| (-n, f));
|
|
}
|
|
if let Some(r) = parse_plain(t) {
|
|
return Some(r);
|
|
}
|
|
if let Some(r) = parse_fraction(t) {
|
|
return Some((r, Some("# ?/?")));
|
|
}
|
|
if let Some(r) = parse_datetime(t, sys) {
|
|
return Some(r);
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Signs, currency, thousands separators, percent, exponent.
|
|
fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
|
|
let mut s = t;
|
|
let mut neg = false;
|
|
let mut currency = false;
|
|
let mut percent = false;
|
|
if let Some(r) = s.strip_prefix('-') {
|
|
neg = true;
|
|
s = r.trim_start();
|
|
} else if let Some(r) = s.strip_prefix('+') {
|
|
s = r.trim_start();
|
|
}
|
|
if let Some(r) = s.strip_prefix('$') {
|
|
currency = true;
|
|
s = r.trim_start();
|
|
if !neg && let Some(r2) = s.strip_prefix('-') {
|
|
neg = true;
|
|
s = r2;
|
|
}
|
|
}
|
|
if let Some(r) = s.strip_suffix('%') {
|
|
percent = true;
|
|
s = r.trim_end();
|
|
}
|
|
if s.is_empty() {
|
|
return None;
|
|
}
|
|
let has_sep = s.contains(',');
|
|
let digits: String = if has_sep {
|
|
// Thousands separators must be in groups of three before the decimal point.
|
|
let (int, frac) = s.split_once('.').map_or((s, None), |(a, b)| (a, Some(b)));
|
|
let groups: Vec<&str> = int.split(',').collect();
|
|
let first = groups.first()?;
|
|
if first.is_empty() || first.len() > 3 || groups.iter().skip(1).any(|g| g.len() != 3) {
|
|
return None;
|
|
}
|
|
let mut d = groups.concat();
|
|
if let Some(f) = frac {
|
|
d.push('.');
|
|
d.push_str(f);
|
|
}
|
|
d
|
|
} else {
|
|
s.to_string()
|
|
};
|
|
let b = digits.as_bytes();
|
|
if !b.iter().all(|c| c.is_ascii_digit() || matches!(c, b'.' | b'e' | b'E' | b'+' | b'-')) {
|
|
return None;
|
|
}
|
|
if !b.first().is_some_and(|c| c.is_ascii_digit() || *c == b'.') {
|
|
return None;
|
|
}
|
|
let mut n: f64 = digits.parse().ok()?;
|
|
if !n.is_finite() {
|
|
return None;
|
|
}
|
|
let sci = digits.contains(['e', 'E']);
|
|
if percent {
|
|
n /= 100.0;
|
|
}
|
|
if neg {
|
|
n = -n;
|
|
}
|
|
let fmt = if currency {
|
|
Some(if digits.contains('.') { "\"$\"#,##0.00" } else { "\"$\"#,##0" })
|
|
} else if percent {
|
|
Some(if digits.contains('.') { "0.00%" } else { "0%" })
|
|
} else if sci {
|
|
Some("0.00E+00")
|
|
} else if has_sep {
|
|
Some(if digits.contains('.') { "#,##0.00" } else { "#,##0" })
|
|
} else {
|
|
None
|
|
};
|
|
Some((n, fmt))
|
|
}
|
|
|
|
/// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`.
|
|
fn parse_fraction(t: &str) -> Option<f64> {
|
|
let (whole, frac) = t.split_once(' ')?;
|
|
let neg = whole.starts_with('-');
|
|
let w: f64 = whole.trim_start_matches('-').parse().ok()?;
|
|
let (a, b) = frac.trim().split_once('/')?;
|
|
let a: f64 = a.parse().ok()?;
|
|
let b: f64 = b.parse().ok()?;
|
|
if b == 0.0 || !w.is_finite() {
|
|
return None;
|
|
}
|
|
let v = w + a / b;
|
|
Some(if neg { -v } else { v })
|
|
}
|
|
|
|
fn month_from_name(s: &str) -> Option<u32> {
|
|
let l = s.to_ascii_lowercase();
|
|
if l.len() < 3 {
|
|
return None;
|
|
}
|
|
MONTHS.iter().position(|m| m.to_ascii_lowercase().starts_with(&l)).map(|i| i as u32 + 1)
|
|
}
|
|
|
|
/// Time part: `h:mm`, `h:mm:ss`, `h:mm:ss.000`, `h AM`, with optional AM/PM.
|
|
fn parse_time(t: &str) -> Option<(f64, bool, bool)> {
|
|
let t = t.trim();
|
|
let lower = t.to_ascii_lowercase();
|
|
let (body, ampm) = if let Some(b) = lower.strip_suffix("am").or_else(|| lower.strip_suffix('a')) {
|
|
(b.trim().to_string(), Some(false))
|
|
} else if let Some(b) = lower.strip_suffix("pm").or_else(|| lower.strip_suffix('p')) {
|
|
(b.trim().to_string(), Some(true))
|
|
} else {
|
|
(lower.clone(), None)
|
|
};
|
|
let parts: Vec<&str> = body.split(':').collect();
|
|
if parts.len() == 1 && ampm.is_none() {
|
|
return None;
|
|
}
|
|
if parts.len() > 3 || parts.iter().any(|p| p.is_empty()) {
|
|
return None;
|
|
}
|
|
let mut h: f64 = parts.first()?.parse().ok()?;
|
|
let m: f64 = parts.get(1).map_or(Some(0.0), |p| p.parse().ok())?;
|
|
let s: f64 = parts.get(2).map_or(Some(0.0), |p| p.parse().ok())?;
|
|
if m >= 60.0 || s >= 60.0 || h < 0.0 || m < 0.0 || s < 0.0 {
|
|
return None;
|
|
}
|
|
if let Some(pm) = ampm {
|
|
if !(0.0..=12.0).contains(&h) {
|
|
return None;
|
|
}
|
|
if h == 12.0 {
|
|
h = 0.0;
|
|
}
|
|
if pm {
|
|
h += 12.0;
|
|
}
|
|
} else if h >= 10000.0 {
|
|
return None;
|
|
}
|
|
Some((time_fraction(h, m, s), parts.len() == 3, ampm.is_some()))
|
|
}
|
|
|
|
fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> {
|
|
let t = t.trim().trim_end_matches(',');
|
|
let num = |s: &str| s.trim().parse::<i64>().ok();
|
|
// ISO yyyy-mm-dd or yyyy/mm/dd
|
|
let seps: &[char] = &['/', '-', '.'];
|
|
let parts: Vec<&str> = t.split(seps).collect();
|
|
if parts.len() == 3 {
|
|
let (a, b, c) = (parts[0].trim(), parts[1].trim(), parts[2].trim());
|
|
if a.len() == 4
|
|
&& let (Some(y), Some(m), Some(d)) = (num(a), num(b), num(c))
|
|
{
|
|
return valid_ymd(sys, y, m, d).map(|s| (s, "yyyy-mm-dd"));
|
|
}
|
|
if let (Some(m), Some(d), Some(y)) = (num(a), num(b), num(c)) {
|
|
let y = if c.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y };
|
|
return valid_ymd(sys, y, m, d).map(|s| (s, "m/d/yyyy"));
|
|
}
|
|
// 4-Mar-2026
|
|
if let (Some(d), Some(m), Some(y)) = (num(a), month_from_name(b), num(c)) {
|
|
let y = if c.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y };
|
|
return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy"));
|
|
}
|
|
}
|
|
if parts.len() == 2 {
|
|
let (a, b) = (parts[0].trim(), parts[1].trim());
|
|
// m/d in the current year is ambiguous without a clock; use month/day of 2026? Excel uses
|
|
// the current year. We accept d-mmm / mmm-d only with month names, and m/d → current year.
|
|
if let (Some(m), Some(d)) = (num(a), num(b)) {
|
|
let y = current_year();
|
|
if (1..=12).contains(&m) && (1..=31).contains(&d) {
|
|
return valid_ymd(sys, y, m, d).map(|s| (s, "d-mmm"));
|
|
}
|
|
// m/yyyy
|
|
if (1..=12).contains(&m) && b.len() == 4 {
|
|
return valid_ymd(sys, d, m, 1).map(|s| (s, "mmm-yy"));
|
|
}
|
|
}
|
|
if let (Some(d), Some(m)) = (num(a), month_from_name(b)) {
|
|
return valid_ymd(sys, current_year(), m as i64, d).map(|s| (s, "d-mmm"));
|
|
}
|
|
if let (Some(m), Some(y)) = (month_from_name(a), num(b)) {
|
|
let y = if b.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y };
|
|
return valid_ymd(sys, y, m as i64, 1).map(|s| (s, "mmm-yy"));
|
|
}
|
|
}
|
|
// "Mar 4, 2026" / "March 4 2026" / "4 March 2026"
|
|
let words: Vec<&str> = t.split([' ', ',']).filter(|w| !w.is_empty()).collect();
|
|
if words.len() == 3 {
|
|
if let (Some(m), Some(d), Some(y)) = (month_from_name(words[0]), num(words[1]), num(words[2])) {
|
|
return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy"));
|
|
}
|
|
if let (Some(d), Some(m), Some(y)) = (num(words[0]), month_from_name(words[1]), num(words[2])) {
|
|
return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy"));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option<f64> {
|
|
if !(1..=12).contains(&m) || d < 1 {
|
|
return None;
|
|
}
|
|
let max = crate::date::days_in_month_in(sys, y as i32, m as u32) as i64;
|
|
if d > max {
|
|
return None;
|
|
}
|
|
serial_from_ymd(sys, y, m, d)
|
|
}
|
|
|
|
/// The year used for dates typed without one. Fixed per process from the system clock when
|
|
/// available (wasm without clock falls back to 2026).
|
|
pub fn current_year() -> i64 {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
|
|
let days = (d.as_secs() / 86400) as i64;
|
|
return crate::date::civil_from_days(days).0 as i64;
|
|
}
|
|
}
|
|
2026
|
|
}
|
|
|
|
fn parse_datetime(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
|
|
if let Some((tf, secs, ampm)) = parse_time(t) {
|
|
let f = match (secs, ampm) {
|
|
(false, false) => "h:mm",
|
|
(true, false) => "h:mm:ss",
|
|
(false, true) => "h:mm AM/PM",
|
|
(true, true) => "h:mm:ss AM/PM",
|
|
};
|
|
return Some((tf, Some(f)));
|
|
}
|
|
if let Some((d, f)) = parse_date(t, sys) {
|
|
return Some((d, Some(f)));
|
|
}
|
|
// date + time: split at the last space(s) before a time-looking tail.
|
|
for (i, _) in t.match_indices(' ') {
|
|
let (a, b) = (t.get(..i)?, t.get(i + 1..)?);
|
|
if let (Some((d, _)), Some((tf, _, _))) = (parse_date(a, sys), parse_time(b)) {
|
|
return Some((d + tf, Some("m/d/yyyy h:mm")));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn p(s: &str) -> Parsed {
|
|
parse_input(s, DateSystem::D1900)
|
|
}
|
|
|
|
#[test]
|
|
fn numbers() {
|
|
assert_eq!(p("42").value, Value::Number(42.0));
|
|
assert_eq!(p("-3.5").value, Value::Number(-3.5));
|
|
assert_eq!(p("1,234").format, Some("#,##0"));
|
|
assert_eq!(p("1,234").value, Value::Number(1234.0));
|
|
assert_eq!(p("1,23").value, Value::text("1,23"));
|
|
assert_eq!(p("12%").value, Value::Number(0.12));
|
|
assert_eq!(p("$1,200.50").value, Value::Number(1200.5));
|
|
assert_eq!(p("$1,200.50").format, Some("\"$\"#,##0.00"));
|
|
assert_eq!(p("(100)").value, Value::Number(-100.0));
|
|
assert_eq!(p("1e3").value, Value::Number(1000.0));
|
|
assert_eq!(p("1 1/2").value, Value::Number(1.5));
|
|
assert_eq!(p(".5").value, Value::Number(0.5));
|
|
}
|
|
|
|
#[test]
|
|
fn texts_and_specials() {
|
|
assert_eq!(p("hello").value, Value::text("hello"));
|
|
assert_eq!(p("'123").value, Value::text("123"));
|
|
assert_eq!(p("true").value, Value::Bool(true));
|
|
assert_eq!(p("#N/A").value, Value::Error(CellError::NA));
|
|
assert_eq!(p("e").value, Value::text("e"));
|
|
assert_eq!(p("-").value, Value::text("-"));
|
|
assert_eq!(p("1.2.3.4").value, Value::text("1.2.3.4"));
|
|
}
|
|
|
|
#[test]
|
|
fn dates_and_times() {
|
|
assert_eq!(p("10/7/2026").value, Value::Number(46302.0));
|
|
assert_eq!(p("2026-10-07").value, Value::Number(46302.0));
|
|
assert_eq!(p("7-Oct-2026").value, Value::Number(46302.0));
|
|
assert_eq!(p("Oct 7, 2026").value, Value::Number(46302.0));
|
|
assert_eq!(p("2/30/2026").value, Value::text("2/30/2026"));
|
|
assert_eq!(p("12:00").value, Value::Number(0.5));
|
|
assert_eq!(p("6:00 PM").value, Value::Number(0.75));
|
|
assert_eq!(p("12:00 AM").value, Value::Number(0.0));
|
|
assert_eq!(p("10/7/2026 18:00").value, Value::Number(46302.75));
|
|
assert_eq!(p("25:00").value, Value::Number(25.0 / 24.0));
|
|
}
|
|
|
|
#[test]
|
|
fn fictitious_1900_02_29() {
|
|
// Excel accepts 29 February 1900 (serial 60) and keeps 1 March 1900 at 61.
|
|
assert_eq!(p("2/29/1900").value, Value::Number(60.0));
|
|
assert_eq!(p("1900-02-29").value, Value::Number(60.0));
|
|
assert_eq!(p("29-Feb-1900").value, Value::Number(60.0));
|
|
assert_eq!(p("2/28/1900").value, Value::Number(59.0));
|
|
assert_eq!(p("3/1/1900").value, Value::Number(61.0));
|
|
assert_eq!(p("2/29/1901").value, Value::text("2/29/1901"));
|
|
assert_eq!(p("2/30/1900").value, Value::text("2/30/1900"));
|
|
// The 1904 system starts after it.
|
|
assert_eq!(parse_input("2/29/1900", DateSystem::D1904).value, Value::text("2/29/1900"));
|
|
assert_eq!(parse_input("2/29/1904", DateSystem::D1904).value, Value::Number(59.0));
|
|
}
|
|
}
|