From ecaead4c12c11b2cfa9489a929606c1c76e06bb1 Mon Sep 17 00:00:00 2001 From: lloyd Date: Fri, 9 Oct 2026 20:36:08 +0200 Subject: [PATCH] DATE(1900,2,29) is serial 60, as in Excel 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 --- crates/core/src/date.rs | 82 ++++++++++++++++++++++++++++++------ crates/core/src/parse.rs | 17 +++++++- crates/functions/src/date.rs | 47 +++++++++++++++++++-- 3 files changed, 129 insertions(+), 17 deletions(-) diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 7fba23fe5..b82443470 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -69,26 +69,34 @@ pub fn days_in_month(y: i32, m: u32) -> u32 { } } +/// Days in a month of a date system's calendar: like [`days_in_month`], except that February +/// 1900 has 29 days in the 1900 system (serial 60 is the fictitious 1900-02-29). +pub fn days_in_month_in(sys: DateSystem, y: i32, m: u32) -> u32 { + if sys == DateSystem::D1900 && y == 1900 && m == 2 { 29 } else { days_in_month(y, m) } +} + /// Serial for a date. Month and day overflow roll over like `DATE()` (month 13 = January next -/// year, day 0 = last day of the previous month). `None` before the epoch or after 9999-12-31. +/// year, day 0 = last day of the previous month), in the date system's own calendar: in the 1900 +/// system February 1900 has 29 days, so `(1900, 2, 29)` and `(1900, 3, 0)` are serial 60. +/// `None` before the epoch or after 9999-12-31. pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Option { // DATE(): years 0–1899 are added to 1900. - let mut y = if (0..1900).contains(&year) { year + 1900 } else { year }; - let m0 = month - 1; - y += m0.div_euclid(12); + let y = if (0..1900).contains(&year) { year + 1900 } else { year }; + let m0 = month.checked_sub(1)?; + let y = y.checked_add(m0.div_euclid(12))?; let m = m0.rem_euclid(12) + 1; if !(1900..=9999).contains(&y) && sys == DateSystem::D1900 || !(1904..=9999).contains(&y) && sys == DateSystem::D1904 { return None; } - let base = days_from_civil(y as i32, m as u32, 1) + day - 1; - let serial = match sys { - DateSystem::D1900 => { - let s = base - UNIX_1899_12_30; - // Before the fictitious 1900-02-29 serials are one lower than the day count from 1899-12-30. - if s <= 60 { s - 1 } else { s } - } - DateSystem::D1904 => base - UNIX_1904_01_01, + let first = days_from_civil(y as i32, m as u32, 1); + // Serial of the first of the month; the day (and any overflow) counts on from there. + let first = match sys { + // Up to February 1900 the serial is one lower than the day count from 1899-12-30: the + // fictitious 1900-02-29 (serial 60) comes before 1 March. + DateSystem::D1900 => first - UNIX_1899_12_30 - i64::from(y == 1900 && m <= 2), + DateSystem::D1904 => first - UNIX_1904_01_01, }; + let serial = first.checked_add(day)?.checked_sub(1)?; if !(0..=2_958_465).contains(&serial) { return None; } @@ -168,10 +176,58 @@ mod tests { assert_eq!(serial_from_ymd(DateSystem::D1904, 2000, 1, 1), Some(35064.0)); } + #[test] + fn fictitious_1900_02_29() { + // Excel's 1900 calendar has a 29 February 1900 (serial 60); day and month overflow count + // through it. + let s = DateSystem::D1900; + assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1900, 3, 0), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1900, 2, 30), Some(61.0)); + assert_eq!(serial_from_ymd(s, 1900, 1, 60), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1900, 1, 61), Some(61.0)); + assert_eq!(serial_from_ymd(s, 1900, 1, 400), Some(400.0)); + assert_eq!(serial_from_ymd(s, 1900, 4, -30), Some(61.0)); + assert_eq!(serial_from_ymd(s, 1900, 4, -31), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1900, 3, -1), Some(59.0)); + assert_eq!(serial_from_ymd(s, 1901, -10, 29), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1900, 1, 0), Some(0.0)); + assert_eq!(serial_from_ymd(s, 1900, 1, -1), None); + assert_eq!(serial_from_ymd(s, 1900, 0, 1), None); + // Later years keep the real calendar. + assert_eq!(serial_from_ymd(s, 1901, 2, 29), serial_from_ymd(s, 1901, 3, 1)); + assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(1521.0)); + // Every serial up to 1900-12-31 comes back from its own year, month and day. + for serial in 0..=366 { + let d = datetime_from_serial(s, serial as f64).unwrap(); + assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial as f64), "{d:?}"); + } + assert_eq!(days_in_month_in(s, 1900, 2), 29); + assert_eq!(days_in_month_in(s, 1901, 2), 28); + assert_eq!(days_in_month_in(DateSystem::D1904, 1900, 2), 28); + // The 1904 system has no fictitious day: 1904 is a real leap year. + let s = DateSystem::D1904; + assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(59.0)); + assert_eq!(serial_from_ymd(s, 1904, 3, 0), Some(59.0)); + assert_eq!(serial_from_ymd(s, 1904, 3, 1), Some(60.0)); + assert_eq!(serial_from_ymd(s, 1904, 1, 0), None); + assert_eq!(serial_from_ymd(s, 1900, 2, 29), None); + } + + #[test] + fn hostile_month_and_day() { + let s = DateSystem::D1900; + assert_eq!(serial_from_ymd(s, 2000, i64::MIN, 1), None); + assert_eq!(serial_from_ymd(s, 2000, i64::MAX, 1), None); + assert_eq!(serial_from_ymd(s, 2000, 1, i64::MAX), None); + assert_eq!(serial_from_ymd(s, 2000, 1, i64::MIN), None); + assert_eq!(serial_from_ymd(s, i64::MAX, 1, 1), None); + } + #[test] fn roundtrip() { let s = DateSystem::D1900; - for serial in [1.0, 59.0, 61.0, 36526.0, 46302.0, 2958465.0] { + for serial in [1.0, 59.0, 60.0, 61.0, 36526.0, 46302.0, 2958465.0] { let d = datetime_from_serial(s, serial).unwrap(); assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial)); } diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b08cf2aab..5d0be7ace 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -266,7 +266,7 @@ fn valid_ymd(sys: DateSystem, y: i64, m: i64, d: i64) -> Option { if !(1..=12).contains(&m) || d < 1 { return None; } - let max = if y == 1900 && m == 2 { 29 } else { crate::date::days_in_month(y as i32, m as u32) as i64 }; + let max = crate::date::days_in_month_in(sys, y as i32, m as u32) as i64; if d > max { return None; } @@ -357,4 +357,19 @@ mod tests { 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)); + } } diff --git a/crates/functions/src/date.rs b/crates/functions/src/date.rs index f1f1becb3..258aea6c9 100644 --- a/crates/functions/src/date.rs +++ b/crates/functions/src/date.rs @@ -1,6 +1,6 @@ //! Date and time functions. -use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, is_leap, serial_from_ymd}; +use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, days_in_month_in, is_leap, serial_from_ymd}; use gridcraft_core::parse::parse_input; use gridcraft_core::{CellError, DateSystem, Value}; @@ -164,7 +164,7 @@ fn edate(a: &[Arg], c: &mut dyn Ctx) -> R { if !(1900..=9999).contains(&y) { return Err(CellError::Num); } - let dd = (d.day.max(1) as i64).min(days_in_month(y as i32, mo as u32) as i64); + let dd = (d.day.max(1) as i64).min(days_in_month_in(sys, y as i32, mo as u32) as i64); num_val(ymd_serial(sys, y, mo, dd)? as f64) } @@ -177,7 +177,7 @@ fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R { if !(1900..=9999).contains(&y) { return Err(CellError::Num); } - num_val(ymd_serial(sys, y, mo, days_in_month(y as i32, mo as u32) as i64)? as f64) + num_val(ymd_serial(sys, y, mo, days_in_month_in(sys, y as i32, mo as u32) as i64)? as f64) } fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R { @@ -652,6 +652,36 @@ mod tests { is_err(ev("EDATE", vec![n(1.0), n(1e12)]), CellError::Num); } + #[test] + fn fictitious_1900_02_29() { + // Excel's 1900 calendar has 29 February 1900 = serial 60, and DATE counts day and month + // overflow through it. + assert_eq!(d(1900.0, 2.0, 29.0), 60.0); + assert_eq!(d(1900.0, 3.0, 0.0), 60.0); + assert_eq!(d(1900.0, 2.0, 28.0), 59.0); + assert_eq!(d(1900.0, 3.0, 1.0), 61.0); + assert_eq!(d(1900.0, 2.0, 30.0), 61.0); + assert_eq!(d(1900.0, 1.0, 60.0), 60.0); + assert_eq!(d(1900.0, 4.0, -31.0), 60.0); + assert_eq!(d(1901.0, -10.0, 29.0), 60.0); + assert_eq!(d(0.0, 2.0, 29.0), 60.0); + assert_eq!(d(1900.0, 1.0, 0.0), 0.0); + close(ev("DAY", vec![n(d(1900.0, 3.0, 0.0))]), 29.0); + close(ev("DATEVALUE", vec![t("1900-02-29")]), 60.0); + close(ev("DATEVALUE", vec![t("2/29/1900")]), 60.0); + close(ev("DATEVALUE", vec![t("3/1/1900")]), 61.0); + is_err(ev("DATEVALUE", vec![t("2/29/1901")]), CellError::Value); + // EDATE and EOMONTH land on it as the end of February 1900. + close(ev("EOMONTH", vec![n(d(1900.0, 1.0, 15.0)), n(1.0)]), 60.0); + close(ev("EOMONTH", vec![n(60.0), n(0.0)]), 60.0); + close(ev("EOMONTH", vec![n(d(1900.0, 3.0, 15.0)), n(-1.0)]), 60.0); + close(ev("EOMONTH", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0)); + close(ev("EDATE", vec![n(d(1900.0, 1.0, 31.0)), n(1.0)]), 60.0); + close(ev("EDATE", vec![n(d(1900.0, 3.0, 31.0)), n(-1.0)]), 60.0); + close(ev("EDATE", vec![n(60.0), n(1.0)]), d(1900.0, 3.0, 29.0)); + close(ev("EDATE", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0)); + } + #[test] fn datedif_units() { let s = n(d(2001.0, 6.0, 1.0)); @@ -755,5 +785,16 @@ mod tests { close(crate::call(spec, &[n(0.0)], &mut ctx), 6.0); let spec = crate::lookup("YEAR").unwrap(); close(crate::call(spec, &[n(0.0)], &mut ctx), 1904.0); + // No fictitious day here: 1904 is a real leap year and 1900 is before the epoch. + let spec = crate::lookup("DATE").unwrap(); + close(crate::call(spec, &[n(1904.0), n(2.0), n(29.0)], &mut ctx), 59.0); + close(crate::call(spec, &[n(1904.0), n(3.0), n(0.0)], &mut ctx), 59.0); + close(crate::call(spec, &[n(1904.0), n(3.0), n(1.0)], &mut ctx), 60.0); + is_err(crate::call(spec, &[n(1900.0), n(2.0), n(29.0)], &mut ctx), CellError::Num); + let spec = crate::lookup("EOMONTH").unwrap(); + close(crate::call(spec, &[n(31.0), n(0.0)], &mut ctx), 59.0); + let spec = crate::lookup("DATEVALUE").unwrap(); + close(crate::call(spec, &[t("2/29/1904")], &mut ctx), 59.0); + is_err(crate::call(spec, &[t("2/29/1900")], &mut ctx), CellError::Value); } }