From 7c32088480aa72ce52cb3945a27e410c7a4d4875 Mon Sep 17 00:00:00 2001 From: Shalom Yiblet Date: Tue, 17 Dec 2024 23:45:08 -0800 Subject: [PATCH] feat: update date --- base/src/constants.rs | 5 ++ base/src/formatter/dates.rs | 120 +++++++++++++++++++++++++++- base/src/functions/date_and_time.rs | 21 +---- base/src/test/test_date_and_time.rs | 26 +++--- 4 files changed, 144 insertions(+), 28 deletions(-) diff --git a/base/src/constants.rs b/base/src/constants.rs index d510018..0da0afd 100644 --- a/base/src/constants.rs +++ b/base/src/constants.rs @@ -16,3 +16,8 @@ pub(crate) const LAST_ROW: i32 = 1_048_576; // NaiveDate::from_ymd(1900, 1, 1).num_days_from_ce() - 2 // The 2 days offset is because of Excel 1900 bug pub(crate) const EXCEL_DATE_BASE: i32 = 693_594; + +// Excel can handle dates until the year 0000-01-01 +pub(crate) const EXCEL_DATE_MIN: i32 = -693_959; +// Excel can handle dates until the year 9999-12-31 +pub(crate) const EXCEL_DATE_MAX: i32 = 2958465; diff --git a/base/src/formatter/dates.rs b/base/src/formatter/dates.rs index 0715281..0705ce0 100644 --- a/base/src/formatter/dates.rs +++ b/base/src/formatter/dates.rs @@ -1,8 +1,22 @@ use chrono::Datelike; +use chrono::Days; use chrono::Duration; +use chrono::Months; use chrono::NaiveDate; use crate::constants::EXCEL_DATE_BASE; +use crate::constants::EXCEL_DATE_MAX; +use crate::constants::EXCEL_DATE_MIN; + +#[inline] +fn convert_to_serial_number(date: NaiveDate) -> i32 { + date.num_days_from_ce() - EXCEL_DATE_BASE +} + +fn is_date_within_range(date: NaiveDate) -> bool { + convert_to_serial_number(date) >= EXCEL_DATE_MIN + && convert_to_serial_number(date) <= EXCEL_DATE_MAX +} pub fn from_excel_date(days: i64) -> NaiveDate { #[allow(clippy::expect_used)] @@ -12,7 +26,111 @@ pub fn from_excel_date(days: i64) -> NaiveDate { pub fn date_to_serial_number(day: u32, month: u32, year: i32) -> Result { match NaiveDate::from_ymd_opt(year, month, day) { - Some(native_date) => Ok(native_date.num_days_from_ce() - EXCEL_DATE_BASE), + Some(native_date) => Ok(convert_to_serial_number(native_date)), None => Err("Out of range parameters for date".to_string()), } } + +pub fn permissive_date_to_serial_number(day: i32, month: i32, year: i32) -> Result { + // Excel parses `DATE` very permissively. It allows not just for valid date values, but it + // allows for invalid dates as well. If you for example enter `DATE(1900, 1, 32)` it will + // return the date `1900-02-01`. Despite giving a day that is out of range it will just + // wrap the month and year around. + // + // This function applies that same logic to dates. And does it in the most compatible way as + // possible. + + let Some(mut date) = NaiveDate::from_ymd_opt(year, 1, 1) else { + return Err("Out of range parameters for date".to_string()); + }; + + // One thing to note for example is that even if you started with a year out of range + // but tried to increment the months so that it wraps around into within range, excel + // would still return an error. + // + // I.E. DATE(0,13,-1) will return an error, despite it being equivalent to DATE(1,1,0) which + // is within range. + // + // As a result, we have to run range checks as we parse the date from the biggest unit to the + // smallest unit. + if !is_date_within_range(date) { + return Err("Out of range parameters for date".to_string()); + } + + date = { + let abs_month = month.unsigned_abs(); + if month <= 0 { + date = date - Months::new(abs_month + 1); + } else { + date = date + Months::new(abs_month - 1); + } + if !is_date_within_range(date) { + return Err("Out of range parameters for date".to_string()); + } + date + }; + + date = { + let abs_day = day.unsigned_abs() as u64; + if day <= 0 { + date = date - Days::new(abs_day + 1); + } else { + date = date + Days::new(abs_day - 1); + } + if !is_date_within_range(date) { + return Err("Out of range parameters for date".to_string()); + } + date + }; + + Ok(convert_to_serial_number(date)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_permissive_date_to_serial_number() { + assert_eq!( + permissive_date_to_serial_number(42, 42, 2002), + date_to_serial_number(12, 7, 2005) + ); + assert_eq!( + permissive_date_to_serial_number(1, 42, 2002), + date_to_serial_number(1, 6, 2005) + ); + assert_eq!( + permissive_date_to_serial_number(1, 15, 2000), + date_to_serial_number(1, 3, 2001) + ); + assert_eq!( + permissive_date_to_serial_number(1, 49, 2000), + date_to_serial_number(1, 1, 2004) + ); + assert_eq!( + permissive_date_to_serial_number(31, 49, 2000), + date_to_serial_number(31, 1, 2004) + ); + assert_eq!( + permissive_date_to_serial_number(256, 49, 2000), + date_to_serial_number(12, 9, 2004) + ); + assert_eq!( + permissive_date_to_serial_number(256, 1, 2004), + date_to_serial_number(12, 9, 2004) + ); + } + + #[test] + fn test_max_and_min_dates() { + assert_eq!( + permissive_date_to_serial_number(31, 12, 9999), + Ok(EXCEL_DATE_MAX), + ); + assert_eq!( + permissive_date_to_serial_number(1, 1, 0), + Ok(EXCEL_DATE_MIN), + ); + } +} diff --git a/base/src/functions/date_and_time.rs b/base/src/functions/date_and_time.rs index c8c4b6c..c50775a 100644 --- a/base/src/functions/date_and_time.rs +++ b/base/src/functions/date_and_time.rs @@ -5,6 +5,7 @@ use chrono::Timelike; use crate::expressions::types::CellReferenceIndex; use crate::formatter::dates::date_to_serial_number; +use crate::formatter::dates::permissive_date_to_serial_number; use crate::model::get_milliseconds_since_epoch; use crate::{ calc_result::CalcResult, constants::EXCEL_DATE_BASE, expressions::parser::Node, @@ -137,32 +138,18 @@ impl Model { let month = match self.get_number(&args[1], cell) { Ok(c) => { let t = c.floor(); - if t < 0.0 { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - }; - } - t as u32 + t as i32 } Err(s) => return s, }; let day = match self.get_number(&args[2], cell) { Ok(c) => { let t = c.floor(); - if t < 0.0 { - return CalcResult::Error { - error: Error::NUM, - origin: cell, - message: "Out of range parameters for date".to_string(), - }; - } - t as u32 + t as i32 } Err(s) => return s, }; - match date_to_serial_number(day, month, year) { + match permissive_date_to_serial_number(day, month, year) { Ok(serial_number) => CalcResult::Number(serial_number as f64), Err(message) => CalcResult::Error { error: Error::NUM, diff --git a/base/src/test/test_date_and_time.rs b/base/src/test/test_date_and_time.rs index 8420e2d..9fe8fc1 100644 --- a/base/src/test/test_date_and_time.rs +++ b/base/src/test/test_date_and_time.rs @@ -37,12 +37,12 @@ fn test_fn_date_arguments() { assert_eq!(model._get_text("A3"), *"#ERROR!"); assert_eq!(model._get_text("A4"), *"#ERROR!"); - assert_eq!(model._get_text("A5"), *"#NUM!"); - assert_eq!(model._get_text("A6"), *"#NUM!"); - assert_eq!(model._get_text("A7"), *"#NUM!"); - assert_eq!(model._get_text("A8"), *"#NUM!"); + assert_eq!(model._get_text("A5"), *"10/10/1974"); + assert_eq!(model._get_text("A6"), *"21/01/1975"); + assert_eq!(model._get_text("A7"), *"10/02/1976"); + assert_eq!(model._get_text("A8"), *"02/03/1975"); - assert_eq!(model._get_text("A9"), *"#NUM!"); + assert_eq!(model._get_text("A9"), *"01/03/1975"); assert_eq!(model._get_text("A10"), *"29/02/1976"); assert_eq!( model.get_cell_value_by_ref("Sheet1!A10"), @@ -64,15 +64,18 @@ fn test_date_out_of_range() { // year (actually years < 1900 don't really make sense) model._set("C1", "=DATE(-1, 5, 5)"); + // excel is not compatible with years past 9999 + model._set("C2", "=DATE(10000, 5, 5)"); model.evaluate(); - assert_eq!(model._get_text("A1"), *"#NUM!"); - assert_eq!(model._get_text("A2"), *"#NUM!"); - assert_eq!(model._get_text("B1"), *"#NUM!"); - assert_eq!(model._get_text("B2"), *"#NUM!"); + assert_eq!(model._get_text("A1"), *"10/12/2021"); + assert_eq!(model._get_text("A2"), *"10/01/2023"); + assert_eq!(model._get_text("B1"), *"30/04/2042"); + assert_eq!(model._get_text("B2"), *"01/06/2025"); assert_eq!(model._get_text("C1"), *"#NUM!"); + assert_eq!(model._get_text("C2"), *"#NUM!"); } #[test] @@ -204,7 +207,10 @@ fn test_date_early_dates() { model.get_cell_value_by_ref("Sheet1!A2"), Ok(CellValue::Number(60.0)) ); - assert_eq!(model._get_text("B2"), *"#NUM!"); + + // This does not agree with Excel, instead of mistakenly allowing + // for Feb 29, it will auto-wrap to the next day after Feb 28. + assert_eq!(model._get_text("B2"), *"01/03/1900"); // This agrees with Excel from he onward assert_eq!(model._get_text("A3"), *"01/03/1900");