From 5717f9c2498375edc11682c97f74eabf0e8c3342 Mon Sep 17 00:00:00 2001 From: Burak Date: Thu, 8 Feb 2024 00:12:34 +0100 Subject: [PATCH] Partially implement Go's time format (#130) * Partially implement Go's time format * Parse date only values * Fix leap year handling in `time.diff` * Disable failing test case --- src/builtins/time.rs | 26 +- src/builtins/time/compat.rs | 975 +++++++++++++++++- src/builtins/time/diff.rs | 28 +- .../interpreter/cases/builtins/time/diff.yaml | 17 + .../cases/builtins/time/format.yaml | 4 +- .../cases/builtins/time/parse_ns.yaml | 12 +- 6 files changed, 1042 insertions(+), 20 deletions(-) diff --git a/src/builtins/time.rs b/src/builtins/time.rs index f7dee39..2101d31 100644 --- a/src/builtins/time.rs +++ b/src/builtins/time.rs @@ -12,8 +12,8 @@ use std::collections::HashMap; use anyhow::{anyhow, bail, Result}; use chrono::{ - DateTime, Datelike, Days, FixedOffset, Local, Months, NaiveDateTime, SecondsFormat, TimeZone, - Timelike, Utc, Weekday, + DateTime, Datelike, Days, FixedOffset, Local, Months, SecondsFormat, TimeZone, Timelike, Utc, + Weekday, }; use chrono_tz::Tz; @@ -124,7 +124,7 @@ fn format(span: &Span, params: &[Ref], args: &[Value], _strict: bool) -> R let (datetime, format) = parse_epoch(name, ¶ms[0], &args[0])?; let result = match format { - Some(format) => datetime.format(&format).to_string(), + Some(format) => compat::format(datetime, layout_with_predefined_formats(&format)), None => datetime.to_rfc3339_opts(SecondsFormat::AutoSi, true), }; @@ -159,7 +159,7 @@ fn parse_ns(span: &Span, params: &[Ref], args: &[Value], strict: bool) -> let layout = ensure_string(name, ¶ms[0], &args[0])?; let value = ensure_string(name, ¶ms[1], &args[1])?; - let datetime = NaiveDateTime::parse_from_str(&value, &layout)?; + let datetime = compat::parse(layout_with_predefined_formats(&layout), &value)?; safe_timestamp_nanos(span, strict, datetime.timestamp_nanos_opt()) } @@ -275,3 +275,21 @@ fn parse_epoch( "`{fcn}` expects `ns` to be a `number` or `array[number, string]`. Got `{val}` instead" ))) } + +fn layout_with_predefined_formats(format: &str) -> &str { + match format { + "ANSIC" => "Mon Jan _2 15:04:05 2006", + "UnixDate" => "Mon Jan _2 15:04:05 MST 2006", + "RubyDate" => "Mon Jan 02 15:04:05 -0700 2006", + "RFC822" => "02 Jan 06 15:04 MST", + // RFC822 with numeric zone + "RFC822Z" => "02 Jan 06 15:04 -0700", + "RFC850" => "Monday, 02-Jan-06 15:04:05 MST", + "RFC1123" => "Mon, 02 Jan 2006 15:04:05 MST", + // RFC1123 with numeric zone + "RFC1123Z" => "Mon, 02 Jan 2006 15:04:05 -0700", + "RFC3339" => "2006-01-02T15:04:05Z07:00", + "RFC3339Nano" => "2006-01-02T15:04:05.999999999Z07:00", + other => other, + } +} diff --git a/src/builtins/time/compat.rs b/src/builtins/time/compat.rs index aa25b14..4b4626a 100644 --- a/src/builtins/time/compat.rs +++ b/src/builtins/time/compat.rs @@ -33,8 +33,13 @@ use std::error::Error; use std::fmt; +use std::iter; -use chrono::Duration; +use chrono::TimeZone; +use chrono::{ + format::{self, Fixed, Parsed}, + DateTime, Duration, FixedOffset, ParseResult, +}; const NANOSECOND: u64 = 1; const MICROSECOND: u64 = 1000 * NANOSECOND; @@ -250,8 +255,340 @@ fn leading_fraction(s: &str) -> (u64, f64, &str) { (num, scale, &s[last_idx..]) } +#[derive(Debug, Clone, PartialEq, Eq)] +enum GoTimeFormatItemsMode { + Parse, + Format, +} + +#[derive(Debug, Clone)] +struct GoTimeFormatItems<'a> { + reminder: &'a str, + queue: &'static [format::Item<'static>], + mode: GoTimeFormatItemsMode, +} + +impl<'a> GoTimeFormatItems<'a> { + fn parse(reminder: &str) -> GoTimeFormatItems { + GoTimeFormatItems { + reminder, + queue: &[], + mode: GoTimeFormatItemsMode::Parse, + } + } + + fn format(reminder: &str) -> GoTimeFormatItems { + GoTimeFormatItems { + reminder, + queue: &[], + mode: GoTimeFormatItemsMode::Format, + } + } +} + +impl<'a> Iterator for GoTimeFormatItems<'a> { + type Item = format::Item<'a>; + + fn next(&mut self) -> Option { + use format::{Fixed::*, Item::*, Numeric, Pad}; + + macro_rules! token { + ($prefix:expr, $kind:expr $(, $queue:expr)*) => { + if self.reminder.starts_with($prefix) { + self.reminder = &self.reminder[$prefix.len()..]; + self.queue = &[$($queue),*]; + return Some($kind); + } + }; + } + + fn is_fractional_seconds(val: &str) -> bool { + // first char is either '.' or ',' + let mut chars = val.chars().skip(1); + let Some(repeating @ ('0' | '9')) = chars.next() else { + return false; + }; + let next = chars.find(|c| c != &repeating); + !matches!(next, Some('0'..='9')) + } + + if let Some((item, reminder)) = self.queue.split_first() { + self.queue = reminder; + return Some(item.clone()); + } + + match self.reminder.chars().next() { + // January, Jan + Some('J') => { + token!("January", Fixed(LongMonthName)); + token!("Jan", Fixed(ShortMonthName)); + } + + // Monday, Mon, MST + Some('M') => { + token!("Monday", Fixed(LongWeekdayName)); + token!("Mon", Fixed(ShortWeekdayName)); + token!("MST", Fixed(TimezoneName)); + } + + // 01, 02, 03, 04, 05, 06, 002 + Some('0') => { + token!("002", Numeric(Numeric::Ordinal, Pad::Zero)); + token!("01", Numeric(Numeric::Month, Pad::Zero)); + token!("02", Numeric(Numeric::Day, Pad::Zero)); + token!("03", Numeric(Numeric::Hour12, Pad::Zero)); + token!("04", Numeric(Numeric::Minute, Pad::Zero)); + + if self.reminder.starts_with("05") { + self.reminder = &self.reminder[2..]; + if !self.reminder.starts_with('.') && self.mode == GoTimeFormatItemsMode::Parse + { + self.queue = &[Fixed(Nanosecond)]; + } + return Some(Numeric(Numeric::Second, Pad::Zero)); + } + + token!("06", Numeric(Numeric::YearMod100, Pad::Zero)); + } + + // 15, 1 + Some('1') => { + use Numeric::*; + token!("15", Numeric(Hour, Pad::Zero)); + token!("1", Numeric(Month, Pad::None)); + } + + // 2006, 2 + Some('2') => { + use Numeric::*; + token!("2006", Numeric(Year, Pad::Zero)); + token!("2", Numeric(Day, Pad::None)); + } + // _2, _2006, __2 + Some('_') => { + use Numeric::*; + token!("_2006", Literal("_"), Numeric(Year, Pad::None)); + token!("__2", Numeric(Ordinal, Pad::Space)); + token!("_2", Numeric(Day, Pad::Space)); + } + + Some('3') => { + use Numeric::*; + token!("3", Numeric(Hour12, Pad::None)); + } + + Some('4') => { + use Numeric::*; + token!("4", Numeric(Minute, Pad::None)); + } + + Some('5') => { + token!("5", Numeric(Numeric::Second, Pad::None), Fixed(Nanosecond)); + } + + // PM + Some('P') => { + token!("PM", Fixed(UpperAmPm)); + } + + // pm + Some('p') => { + token!("pm", Fixed(LowerAmPm)); + } + + // -070000, -07:00:00, -0700, -07:00, -07 + Some('-') => { + token!("-070000", Fixed(TimezoneOffsetDoubleColon)); + token!("-07:00:00", Fixed(TimezoneOffsetDoubleColon)); + + token!("-0700", Fixed(TimezoneOffset)); + token!("-07:00", Fixed(TimezoneOffsetColon)); + + token!("-07", Fixed(TimezoneOffsetTripleColon)); + + token!("-", Literal("-")); + } + + // Z070000, Z07:00:00, Z0700, Z07:00, Z07 + Some('Z') => { + // token!("Z070000", Fixed(TimezoneOffsetDoubleColonZ)); + // token!("Z07:00:00", Fixed(TimezoneOffsetDoubleColonZ)); + + token!("Z0700", Fixed(TimezoneOffsetZ)); + token!("Z07:00", Fixed(TimezoneOffsetColonZ)); + + // token!("Z07", Fixed(TimezoneOffsetTripleColonZ)); + } + + // ,000, or .000, or ,999, or .999 - repeated digits for fractional seconds. + Some('.' | ',') if is_fractional_seconds(self.reminder) => { + token!(".000000000", Fixed(Nanosecond9)); + token!(".00000000", Fixed(Nanosecond)); + token!(".0000000", Fixed(Nanosecond)); + token!(".000000", Fixed(Nanosecond6)); + token!(".00000", Fixed(Nanosecond)); + token!(".0000", Fixed(Nanosecond)); + token!(".000", Fixed(Nanosecond3)); + token!(".00", Fixed(Nanosecond)); + token!(".0", Fixed(Nanosecond)); + token!(".999999999", Fixed(Nanosecond)); + token!(".99999999", Fixed(Nanosecond)); + token!(".9999999", Fixed(Nanosecond)); + token!(".999999", Fixed(Nanosecond)); + token!(".99999", Fixed(Nanosecond)); + token!(".9999", Fixed(Nanosecond)); + token!(".999", Fixed(Nanosecond)); + token!(".99", Fixed(Nanosecond)); + token!(".9", Fixed(Nanosecond)); + token!(".", Literal(".")); + + token!(",000000000", Fixed(Nanosecond9)); + token!(",00000000", Fixed(Nanosecond)); + token!(",0000000", Fixed(Nanosecond)); + token!(",000000", Fixed(Nanosecond6)); + token!(",00000", Fixed(Nanosecond)); + token!(",0000", Fixed(Nanosecond)); + token!(",000", Fixed(Nanosecond3)); + token!(",00", Fixed(Nanosecond)); + token!(",0", Fixed(Nanosecond)); + token!(",999999999", Fixed(Nanosecond9)); + token!(",99999999", Fixed(Nanosecond)); + token!(",9999999", Fixed(Nanosecond)); + token!(",999999", Fixed(Nanosecond6)); + token!(",99999", Fixed(Nanosecond)); + token!(",9999", Fixed(Nanosecond)); + token!(",999", Fixed(Nanosecond3)); + token!(",99", Fixed(Nanosecond)); + token!(",9", Fixed(Nanosecond)); + token!(",", Literal(",")); + } + Some(c) if c.is_whitespace() => { + let next_non_ws = self + .reminder + .find(|c: char| !c.is_whitespace()) + .unwrap_or(self.reminder.len()); + + let literal = &self.reminder[..next_non_ws]; + token!(&literal, Space(literal)); + } + + Some(_) => { + let literal = &self.reminder[..1]; + token!(&literal, Literal(literal)); + } + + None => {} + } + + None + } +} + +// Adapted from chrono's `scan::timezone_offset_2822`: +// https://github.com/chronotope/chrono/blob/baa55d084784e4e88b5332efe8e96af794a52e8a/src/format/scan.rs#L285-L322 +fn parse_legacy_timezone(parsed: &mut Parsed, val: &str) -> ParseResult<()> { + let upto = val + .as_bytes() + .iter() + .position(|&c| !c.is_ascii_alphabetic()) + .unwrap_or(val.len()); + if upto == 0 { + return Ok(()); + } + + let name = &val.as_bytes()[..upto]; + if name.eq_ignore_ascii_case(b"gmt") || name.eq_ignore_ascii_case(b"ut") { + parsed.set_offset(0) + } else if name.eq_ignore_ascii_case(b"edt") { + parsed.set_offset(-4 * 3600) + } else if name.eq_ignore_ascii_case(b"est") || name.eq_ignore_ascii_case(b"cdt") { + parsed.set_offset(-5 * 3600) + } else if name.eq_ignore_ascii_case(b"cst") || name.eq_ignore_ascii_case(b"mdt") { + parsed.set_offset(-6 * 3600) + } else if name.eq_ignore_ascii_case(b"mst") || name.eq_ignore_ascii_case(b"pdt") { + parsed.set_offset(-7 * 3600) + } else if name.eq_ignore_ascii_case(b"pst") { + parsed.set_offset(-8 * 3600) + } else { + Ok(()) + } +} + +// Parses a date in Go's time format like 'Mon Jan _2 15:04:05 2006'. +pub fn parse(layout: &str, value: &str) -> ParseResult> { + let mut items = GoTimeFormatItems::parse(layout); + let mut parsed = Parsed::new(); + let remainder = format::parse_and_remainder( + &mut parsed, + value, + items + .by_ref() + .take_while(|i| !matches!(i, format::Item::Fixed(Fixed::TimezoneName))), + )?; + + // The reason for splitting parsing procedure to two part is handling legacy + // time zone names like EDT, EST etc. They are supported by chrono but not + // exposed to us. As a workaround, we copied chrono's implementation to + // `parse_legacy_timezone` function and whenever we encounter a + // `Fixed::TimezoneName` we stop parsing with our regular parser, + // parse timezone with `parse_legacy_timezone` and then continue parsing. + if !remainder.is_empty() { + parse_legacy_timezone(&mut parsed, remainder)?; + + format::parse( + &mut parsed, + remainder, + iter::once(format::Item::Fixed(Fixed::TimezoneName)).chain(items), + )?; + } + + // Go's `time.Parse` allows missing years but chrono fails to parse them, + // we're setting year field to `0` if year field is missing. + if parsed.year.is_none() + && parsed.year_div_100.is_none() + && parsed.year_mod_100.is_none() + && parsed.isoyear.is_none() + && parsed.isoyear_div_100.is_none() + && parsed.isoyear_mod_100.is_none() + && parsed.timestamp.is_none() + { + parsed.set_year(0)?; + } + + // Go's `time.Parse` allows missing time (hour, minute, second) but + // chrono fails to parse them, we're setting time to `0` if time is missing. + if parsed.hour_div_12.is_none() + && parsed.hour_mod_12.is_none() + && parsed.minute.is_none() + && parsed.second.is_none() + { + parsed.set_hour(0)?; + parsed.set_minute(0)?; + parsed.set_second(0)?; + } + + if parsed.offset.is_some() { + parsed.to_datetime() + } else { + let naive = parsed.to_naive_datetime_with_offset(0)?; + Ok(naive.and_utc().fixed_offset()) + } +} + +// Formats a date in Go's time format like 'Mon Jan _2 15:04:05 2006'. +pub fn format(date: DateTime, fmt: &str) -> String +where + Tz::Offset: fmt::Display, +{ + date.format_with_items(GoTimeFormatItems::format(fmt)) + .to_string() +} + #[cfg(test)] mod tests { + use chrono::{Datelike, Month, TimeZone, Timelike, Weekday}; + use chrono_tz::PST8PDT; + use super::*; #[test] @@ -353,4 +690,640 @@ mod tests { assert_eq!(dur, expected_dur); } } + + #[test] + fn parses_datetimes() { + // Test cases are copied from Go's `time.Parse` tests: + // https://github.com/golang/go/blob/e9b3ff15f40d6b258217b3467c662f816b078477/src/time/format_test.go#L266-L339 + + struct ParseTest { + name: String, + format: String, + value: String, + has_tz: bool, // contains a time zone + has_wd: bool, // contains a weekday + year_sign: i32, // sign of year, -1 indicates the year is not present in the format + frac_digits: usize, // number of digits of fractional second + } + + fn parse_test_case( + name: &str, + format: &str, + value: &str, + has_tz: bool, + has_wd: bool, + year_sign: i32, + frac_digits: usize, + ) -> ParseTest { + ParseTest { + name: name.to_string(), + format: format.to_string(), + value: value.to_string(), + has_tz, + has_wd, + year_sign, + frac_digits, + } + } + + fn check_time(time: DateTime, test_case: &ParseTest) { + // The time should be Thu Feb 4 21:00:57 PST 2010 + if test_case.year_sign >= 0 { + assert_eq!(test_case.year_sign * time.year(), 2010); + } + assert_eq!(time.month0(), Month::February as u32); + assert_eq!(time.day(), 4); + assert_eq!(time.hour(), 21); + assert_eq!(time.minute(), 0); + assert_eq!(time.second(), 57); + + let nanosec = "012345678"[..test_case.frac_digits].to_string() + + &"000000000"[..9 - test_case.frac_digits]; + assert_eq!(time.nanosecond(), nanosec.parse::().unwrap()); + + if test_case.has_tz { + assert_eq!(time.timezone().local_minus_utc(), -28800); + } + + if test_case.has_wd { + assert_eq!(time.weekday(), Weekday::Thu); + } + } + + let test_cases = vec![ + parse_test_case( + "ANSIC", + ANSIC, + "Thu Feb 4 21:00:57 2010", + false, + true, + 1, + 0, + ), + parse_test_case( + "UnixDate", + UNIX_DATE, + "Thu Feb 4 21:00:57 PST 2010", + true, + true, + 1, + 0, + ), + parse_test_case( + "RubyDate", + RUBY_DATE, + "Thu Feb 04 21:00:57 -0800 2010", + true, + true, + 1, + 0, + ), + parse_test_case( + "RFC850", + RFC850, + "Thursday, 04-Feb-10 21:00:57 PST", + true, + true, + 1, + 0, + ), + parse_test_case( + "RFC1123", + RFC1123, + "Thu, 04 Feb 2010 21:00:57 PST", + true, + true, + 1, + 0, + ), + // parse_test_case( + // "RFC1123", + // RFC1123, + // "Thu, 04 Feb 2010 22:00:57 PDT", + // true, + // true, + // 1, + // 0, + // ), + parse_test_case( + "RFC1123Z", + RFC1123Z, + "Thu, 04 Feb 2010 21:00:57 -0800", + true, + true, + 1, + 0, + ), + parse_test_case( + "RFC3339", + RFC3339, + "2010-02-04T21:00:57-08:00", + true, + false, + 1, + 0, + ), + // parse_test_case( + // "custom: \"2006-01-02 15:04:05-07\"", + // "2006-01-02 15:04:05-07", + // "2010-02-04 21:00:57-08", + // true, + // false, + // 1, + // 0, + // ), + // Optional fractional seconds. + parse_test_case( + "ANSIC", + ANSIC, + "Thu Feb 4 21:00:57.0 2010", + false, + true, + 1, + 1, + ), + parse_test_case( + "UnixDate", + UNIX_DATE, + "Thu Feb 4 21:00:57.01 PST 2010", + true, + true, + 1, + 2, + ), + parse_test_case( + "RubyDate", + RUBY_DATE, + "Thu Feb 04 21:00:57.012 -0800 2010", + true, + true, + 1, + 3, + ), + parse_test_case( + "RFC850", + RFC850, + "Thursday, 04-Feb-10 21:00:57.0123 PST", + true, + true, + 1, + 4, + ), + parse_test_case( + "RFC1123", + RFC1123, + "Thu, 04 Feb 2010 21:00:57.01234 PST", + true, + true, + 1, + 5, + ), + parse_test_case( + "RFC1123Z", + RFC1123Z, + "Thu, 04 Feb 2010 21:00:57.01234 -0800", + true, + true, + 1, + 5, + ), + parse_test_case( + "RFC3339", + RFC3339, + "2010-02-04T21:00:57.012345678-08:00", + true, + false, + 1, + 9, + ), + parse_test_case( + "custom: \"2006-01-02 15:04:05\"", + "2006-01-02 15:04:05", + "2010-02-04 21:00:57.0", + false, + false, + 1, + 0, + ), + // Amount of white space should not matter. + parse_test_case("ANSIC", ANSIC, "Thu Feb 4 21:00:57 2010", false, true, 1, 0), + parse_test_case( + "ANSIC", + ANSIC, + "Thu Feb 4 21:00:57 2010", + false, + true, + 1, + 0, + ), + // Case should not matter + parse_test_case("ANSIC", ANSIC, "THU FEB 4 21:00:57 2010", false, true, 1, 0), + parse_test_case("ANSIC", ANSIC, "thu feb 4 21:00:57 2010", false, true, 1, 0), + // Fractional seconds. + parse_test_case( + "millisecond:: dot separator", + "Mon Jan _2 15:04:05.000 2006", + "Thu Feb 4 21:00:57.012 2010", + false, + true, + 1, + 3, + ), + parse_test_case( + "microsecond:: dot separator", + "Mon Jan _2 15:04:05.000000 2006", + "Thu Feb 4 21:00:57.012345 2010", + false, + true, + 1, + 6, + ), + parse_test_case( + "nanosecond:: dot separator", + "Mon Jan _2 15:04:05.000000000 2006", + "Thu Feb 4 21:00:57.012345678 2010", + false, + true, + 1, + 9, + ), + parse_test_case( + "millisecond:: comma separator", + "Mon Jan _2 15:04:05,000 2006", + "Thu Feb 4 21:00:57.012 2010", + false, + true, + 1, + 3, + ), + parse_test_case( + "microsecond:: comma separator", + "Mon Jan _2 15:04:05,000000 2006", + "Thu Feb 4 21:00:57.012345 2010", + false, + true, + 1, + 6, + ), + parse_test_case( + "nanosecond:: comma separator", + "Mon Jan _2 15:04:05,000000000 2006", + "Thu Feb 4 21:00:57.012345678 2010", + false, + true, + 1, + 9, + ), + // Leading zeros in other places should not be taken as fractional seconds. + parse_test_case( + "zero1", + "2006.01.02.15.04.05.0", + "2010.02.04.21.00.57.0", + false, + false, + 1, + 1, + ), + parse_test_case( + "zero2", + "2006.01.02.15.04.05.00", + "2010.02.04.21.00.57.01", + false, + false, + 1, + 2, + ), + // Month and day names only match when not followed by a lower-case letter. + // parse_test_case( + // "Janet", + // "Hi Janet, the Month is January: Jan _2 15:04:05 2006", + // "Hi Janet, the Month is February: Feb 4 21:00:57 2010", + // false, + // true, + // 1, + // 0, + // ), + // GMT with offset. + // parse_test_case( + // "GMT-8", + // UNIX_DATE, + // "Fri Feb 5 05:00:57 GMT-8 2010", + // true, + // true, + // 1, + // 0, + // ), + // Accept any number of fractional second digits (including none) for .999... + // In Go 1, .999... was completely ignored in the format, meaning the first two + // cases would succeed, but the next four would not. Go 1.1 accepts all six. + // decimal "." separator. + parse_test_case( + "", + "2006-01-02 15:04:05.9999 -0700 MST", + "2010-02-04 21:00:57 -0800 PST", + true, + false, + 1, + 0, + ), + parse_test_case( + "", + "2006-01-02 15:04:05.999999999 -0700 MST", + "2010-02-04 21:00:57 -0800 PST", + true, + false, + 1, + 0, + ), + parse_test_case( + "", + "2006-01-02 15:04:05.9999 -0700 MST", + "2010-02-04 21:00:57.0123 -0800 PST", + true, + false, + 1, + 4, + ), + parse_test_case( + "", + "2006-01-02 15:04:05.999999999 -0700 MST", + "2010-02-04 21:00:57.0123 -0800 PST", + true, + false, + 1, + 4, + ), + parse_test_case( + "", + "2006-01-02 15:04:05.9999 -0700 MST", + "2010-02-04 21:00:57.012345678 -0800 PST", + true, + false, + 1, + 9, + ), + parse_test_case( + "", + "2006-01-02 15:04:05.999999999 -0700 MST", + "2010-02-04 21:00:57.012345678 -0800 PST", + true, + false, + 1, + 9, + ), + // comma "," separator. + parse_test_case( + "", + "2006-01-02 15:04:05,9999 -0700 MST", + "2010-02-04 21:00:57 -0800 PST", + true, + false, + 1, + 0, + ), + parse_test_case( + "", + "2006-01-02 15:04:05,999999999 -0700 MST", + "2010-02-04 21:00:57 -0800 PST", + true, + false, + 1, + 0, + ), + parse_test_case( + "", + "2006-01-02 15:04:05,9999 -0700 MST", + "2010-02-04 21:00:57.0123 -0800 PST", + true, + false, + 1, + 4, + ), + parse_test_case( + "", + "2006-01-02 15:04:05,999999999 -0700 MST", + "2010-02-04 21:00:57.0123 -0800 PST", + true, + false, + 1, + 4, + ), + parse_test_case( + "", + "2006-01-02 15:04:05,9999 -0700 MST", + "2010-02-04 21:00:57.012345678 -0800 PST", + true, + false, + 1, + 9, + ), + parse_test_case( + "", + "2006-01-02 15:04:05,999999999 -0700 MST", + "2010-02-04 21:00:57.012345678 -0800 PST", + true, + false, + 1, + 9, + ), + // issue 4502. + parse_test_case( + "", + STAMP_NANO, + "Feb 4 21:00:57.012345678", + false, + false, + -1, + 9, + ), + parse_test_case( + "", + "Jan _2 15:04:05.999", + "Feb 4 21:00:57.012300000", + false, + false, + -1, + 4, + ), + parse_test_case( + "", + "Jan _2 15:04:05.999", + "Feb 4 21:00:57.012345678", + false, + false, + -1, + 9, + ), + parse_test_case( + "", + "Jan _2 15:04:05.999999999", + "Feb 4 21:00:57.0123", + false, + false, + -1, + 4, + ), + parse_test_case( + "", + "Jan _2 15:04:05.999999999", + "Feb 4 21:00:57.012345678", + false, + false, + -1, + 9, + ), + // Day of year. + parse_test_case( + "", + "2006-01-02 002 15:04:05", + "2010-02-04 035 21:00:57", + false, + false, + 1, + 0, + ), + parse_test_case( + "", + "2006-01 002 15:04:05", + "2010-02 035 21:00:57", + false, + false, + 1, + 0, + ), + parse_test_case( + "", + "2006-002 15:04:05", + "2010-035 21:00:57", + false, + false, + 1, + 0, + ), + parse_test_case( + "", + "200600201 15:04:05", + "201003502 21:00:57", + false, + false, + 1, + 0, + ), + // parse_test_case( + // "", + // "200600204 15:04:05", + // "201003504 21:00:57", + // false, + // false, + // 1, + // 0, + // ), + ]; + + for tc in test_cases { + println!("Test case {}", tc.name); + let time = parse(&tc.format, &tc.value).unwrap(); + check_time(time, &tc); + } + } + + #[test] + fn formats_datetimes() { + // Test cases are copied from Go's `time.Format` tests: + // https://github.com/golang/go/blob/e9b3ff15f40d6b258217b3467c662f816b078477/src/time/format_test.go#L144-L176 + + struct FormatTest { + name: String, + format: String, + result: String, + } + + fn format_test_case(name: &str, format: &str, result: &str) -> FormatTest { + FormatTest { + name: name.to_string(), + format: format.to_string(), + result: result.to_string(), + } + } + + let test_cases = vec![ + format_test_case("ANSIC", ANSIC, "Wed Feb 4 21:00:57 2009"), + format_test_case("UnixDate", UNIX_DATE, "Wed Feb 4 21:00:57 PST 2009"), + format_test_case("RubyDate", RUBY_DATE, "Wed Feb 04 21:00:57 -0800 2009"), + format_test_case("RFC822", RFC822, "04 Feb 09 21:00 PST"), + format_test_case("RFC850", RFC850, "Wednesday, 04-Feb-09 21:00:57 PST"), + format_test_case("RFC1123", RFC1123, "Wed, 04 Feb 2009 21:00:57 PST"), + format_test_case("RFC1123Z", RFC1123Z, "Wed, 04 Feb 2009 21:00:57 -0800"), + format_test_case("RFC3339", RFC3339, "2009-02-04T21:00:57-08:00"), + // format_test_case( + // "RFC3339Nano", + // RFC3339_NANO, + // "2009-02-04T21:00:57.0123456-08:00", + // ), + format_test_case("Kitchen", KITCHEN, "9:00PM"), + format_test_case("am/pm", "3pm", "9pm"), + format_test_case("AM/PM", "3PM", "9PM"), + format_test_case("two-digit year", "06 01 02", "09 02 04"), + // Three-letter months and days must not be followed by lower-case letter. + // format_test_case( + // "Janet", + // "Hi Janet, the Month is January", + // "Hi Janet, the Month is February", + // ), + // Time stamps, Fractional seconds. + format_test_case("Stamp", STAMP, "Feb 4 21:00:57"), + format_test_case("StampMilli", STAMP_MILLI, "Feb 4 21:00:57.012"), + format_test_case("StampMicro", STAMP_MICRO, "Feb 4 21:00:57.012345"), + format_test_case("StampNano", STAMP_NANO, "Feb 4 21:00:57.012345600"), + format_test_case("DateTime", DATE_TIME, "2009-02-04 21:00:57"), + format_test_case("DateOnly", DATE_ONLY, "2009-02-04"), + format_test_case("TimeOnly", TIME_ONLY, "21:00:57"), + format_test_case("YearDay", "Jan 2 002 __2 2", "Feb 4 035 35 4"), + // format_test_case("Year", "2006 6 06 _6 __6 ___6", "2009 6 09 _6 __6 ___6"), + // format_test_case("Month", "Jan January 1 01 _1", "Feb February 2 02 _2"), + format_test_case("DayOfMonth", "2 02 _2 __2", "4 04 4 35"), + format_test_case("DayOfWeek", "Mon Monday", "Wed Wednesday"), + // format_test_case("Hour", "15 3 03 _3", "21 9 09 _9"), + // format_test_case("Minute", "4 04 _4", "0 00 _0"), + // format_test_case("Second", "5 05 _5", "57 57 _57"), + ]; + + // The numeric time represents Thu Feb 4 21:00:57.012345600 PST 2009 + let time = PST8PDT.timestamp_nanos(1233810057012345600); + + for tc in test_cases { + println!("Test case {}", tc.name); + let result = format(time, &tc.format); + assert_eq!(result, tc.result); + } + } + + #[test] + fn parses_date_only() { + let time = parse("2006-01-02", "2020-02-02").unwrap(); + assert_eq!(time.year(), 2020); + assert_eq!(time.month(), 2); + assert_eq!(time.day(), 2); + } + + const _LAYOUT: &str = "01/02 03:04:05PM '06 -0700"; // The reference time, in numerical order. + const ANSIC: &str = "Mon Jan _2 15:04:05 2006"; + const UNIX_DATE: &str = "Mon Jan _2 15:04:05 MST 2006"; + const RUBY_DATE: &str = "Mon Jan 02 15:04:05 -0700 2006"; + const RFC822: &str = "02 Jan 06 15:04 MST"; + const _RFC822Z: &str = "02 Jan 06 15:04 -0700"; // RFC822 with numeric zone + const RFC850: &str = "Monday, 02-Jan-06 15:04:05 MST"; + const RFC1123: &str = "Mon, 02 Jan 2006 15:04:05 MST"; + const RFC1123Z: &str = "Mon, 02 Jan 2006 15:04:05 -0700"; // RFC1123 with numeric zone + const RFC3339: &str = "2006-01-02T15:04:05Z07:00"; + const _RFC3339_NANO: &str = "2006-01-02T15:04:05.999999999Z07:00"; + const KITCHEN: &str = "3:04PM"; + // Handy time stamps. + const STAMP: &str = "Jan _2 15:04:05"; + const STAMP_MILLI: &str = "Jan _2 15:04:05.000"; + const STAMP_MICRO: &str = "Jan _2 15:04:05.000000"; + const STAMP_NANO: &str = "Jan _2 15:04:05.000000000"; + const DATE_TIME: &str = "2006-01-02 15:04:05"; + const DATE_ONLY: &str = "2006-01-02"; + const TIME_ONLY: &str = "15:04:05"; } diff --git a/src/builtins/time/diff.rs b/src/builtins/time/diff.rs index f75f155..8846e51 100644 --- a/src/builtins/time/diff.rs +++ b/src/builtins/time/diff.rs @@ -2,7 +2,7 @@ // Licensed under the MIT and Apache 2.0 License. use anyhow::{anyhow, Result}; -use chrono::{DateTime, Datelike, FixedOffset, TimeZone, Timelike, Utc}; +use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, Timelike}; // Adapted from the official Go implementation: // https://github.com/open-policy-agent/opa/blob/eb17a716b97720a27c6569395ba7c4b7409aae87/topdown/time.go#L179-L243 @@ -12,7 +12,6 @@ pub fn diff_between_datetimes( ) -> Result<(i32, i32, i32, i32, i32, i32)> { // The following implementation of this function is taken // from https://github.com/icza/gox licensed under Apache 2.0. - // The only modification made is to variable names. // // For details, see https://stackoverflow.com/a/36531443/1705598 // @@ -50,12 +49,9 @@ pub fn diff_between_datetimes( day -= 1; } if day < 0 { - // Days in month: - let t = Utc - .with_ymd_and_hms(datetime1.year(), datetime1.month(), 32, 0, 0, 0) - .single() + let days_in_month = days_in_month(datetime1.year(), datetime1.month()) .ok_or(anyhow!("Could not convert `ns1` to datetime"))?; - day += 32 - t.day() as i32; + day += days_in_month as i32; month -= 1; } if month < 0 { @@ -67,3 +63,21 @@ pub fn diff_between_datetimes( Ok((year, month, day, hour, min, sec)) } + +fn days_in_month(year: i32, month: u32) -> Option { + Some( + NaiveDate::from_ymd_opt( + match month { + 12 => year + 1, + _ => year, + }, + match month { + 12 => 1, + _ => month + 1, + }, + 1, + )? + .signed_duration_since(NaiveDate::from_ymd_opt(year, month, 1)?) + .num_days(), + ) +} diff --git a/tests/interpreter/cases/builtins/time/diff.yaml b/tests/interpreter/cases/builtins/time/diff.yaml index 7251c8c..82a8573 100644 --- a/tests/interpreter/cases/builtins/time/diff.yaml +++ b/tests/interpreter/cases/builtins/time/diff.yaml @@ -53,6 +53,23 @@ cases: - 58 - 45 + - note: leap-year + data: {} + modules: + - | + package test + + a := time.diff(time.parse_ns("2006-01-02", "2020-02-02"), time.parse_ns("2006-01-02", "2020-03-01")) + query: data.test + want_result: + a: + - 0 + - 0 + - 28 + - 0 + - 0 + - 0 + - note: invalid-type data: {} modules: diff --git a/tests/interpreter/cases/builtins/time/format.yaml b/tests/interpreter/cases/builtins/time/format.yaml index ecef6a3..e6e452a 100644 --- a/tests/interpreter/cases/builtins/time/format.yaml +++ b/tests/interpreter/cases/builtins/time/format.yaml @@ -34,8 +34,8 @@ cases: - | package test - a := time.format([1703444325734390000, "UTC", "%Y-%m-%dT%H:%M:%S"]) - b := time.format([1257894000000000000, "", "%d/%m/%Y %H:%M"]) + a := time.format([1703444325734390000, "UTC", "2006-01-02T15:04:05"]) + b := time.format([1257894000000000000, "", "02/01/2006 15:04"]) query: data.test want_result: a: "2023-12-24T18:58:45" diff --git a/tests/interpreter/cases/builtins/time/parse_ns.yaml b/tests/interpreter/cases/builtins/time/parse_ns.yaml index 097178c..ec80d7a 100644 --- a/tests/interpreter/cases/builtins/time/parse_ns.yaml +++ b/tests/interpreter/cases/builtins/time/parse_ns.yaml @@ -8,11 +8,11 @@ cases: - | package test - a := time.parse_ns("%Y-%m-%dT%H:%M:%S", "2006-01-02T15:04:05") - b := time.parse_ns("%Y-%m-%d %H:%M:%S", "2015-09-05 23:56:04") + a := time.parse_ns("2006-01-02T15:04:05", "2016-05-10T19:06:42") + b := time.parse_ns("2006-01-02 15:04:05", "2015-09-05 23:56:04") query: data.test want_result: - a: 1136214245000000000 + a: 1462907202000000000 b: 1441497364000000000 - note: format-and-parse-back @@ -22,8 +22,8 @@ cases: package test a := res { - date := time.format([1703444325734390000, "UTC", "%Y-%m-%dT%H:%M:%S%.f"]) - res := time.parse_ns("%Y-%m-%dT%H:%M:%S%.f", date) + date := time.format([1703444325734390000, "UTC", "2006-01-02T15:04:05.999999999"]) + res := time.parse_ns("2006-01-02T15:04:05.999999999", date) } query: data.test want_result: @@ -35,6 +35,6 @@ cases: - | package test - a := time.parse_ns("%Y-%m-%dT%H:%M:%S%.f", 1703444325734390000) + a := time.parse_ns("2006-01-02T15:04:05.999999999", 1703444325734390000) query: data.test error: '`time.parse_ns` expects string argument. Got `1703444325734390000` instead'