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); } }