diff --git a/CHANGELOG.md b/CHANGELOG.md index 037f4c3..4bc7663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,14 @@ result in an error. This change was made to improve compatibility with other time zone identifiers in `jiff 0.1`, but using them for parsing or formatting will result in a WARN-level deprecation message. +Enhancements: + +* [#147](https://github.com/BurntSushi/jiff/issues/147): +Adds a number of new conversion specifiers to Jiff's `strftime` and +`strptime` APIs. Specifically, `%C`, `%G`, `%g`, `%j`, `%k`, `%l`, `%n`, `%R`, +`%s`, `%t`, `%U`, `%u`, `%W`, `%w`. Their behavior should match the +corresponding specifiers in GNU libc. + 0.1.24 (2025-01-16) =================== diff --git a/src/civil/date.rs b/src/civil/date.rs index 3c9fe90..a73e6f0 100644 --- a/src/civil/date.rs +++ b/src/civil/date.rs @@ -563,10 +563,8 @@ impl Date { /// ``` #[inline] pub fn day_of_year(self) -> i16 { - type DayOfYear = ri16<1, 366>; - let days = C(1) + self.since_days_ranged(self.first_of_year()); - DayOfYear::rfrom(days).get() + t::DayOfYear::rfrom(days).get() } /// Returns the ordinal day of the year that this date resides in, but @@ -3669,9 +3667,7 @@ fn month_add_overflowing( } fn day_of_year(year: Year, day: i16) -> Result { - type DayOfYear = ri16<1, 366>; - - let day = DayOfYear::try_new("day-of-year", day)?; + let day = t::DayOfYear::try_new("day-of-year", day)?; let span = Span::new().days_ranged(day - C(1)); let start = Date::new_ranged(year, C(1), C(1))?; let end = start.checked_add(span)?; diff --git a/src/fmt/strtime/format.rs b/src/fmt/strtime/format.rs index 8d28a64..cc22ef0 100644 --- a/src/fmt/strtime/format.rs +++ b/src/fmt/strtime/format.rs @@ -46,24 +46,38 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> { b'a' => self.fmt_weekday_abbrev(ext).context("%a failed")?, b'B' => self.fmt_month_full(ext).context("%B failed")?, b'b' => self.fmt_month_abbrev(ext).context("%b failed")?, + b'C' => self.fmt_century(ext).context("%C failed")?, b'D' => self.fmt_american_date(ext).context("%D failed")?, b'd' => self.fmt_day_zero(ext).context("%d failed")?, b'e' => self.fmt_day_space(ext).context("%e failed")?, b'F' => self.fmt_iso_date(ext).context("%F failed")?, b'f' => self.fmt_fractional(ext).context("%f failed")?, - b'H' => self.fmt_hour24(ext).context("%H failed")?, + b'G' => self.fmt_iso_week_year(ext).context("%G failed")?, + b'g' => self.fmt_iso_week_year2(ext).context("%g failed")?, + b'H' => self.fmt_hour24_zero(ext).context("%H failed")?, b'h' => self.fmt_month_abbrev(ext).context("%b failed")?, - b'I' => self.fmt_hour12(ext).context("%H failed")?, + b'I' => self.fmt_hour12_zero(ext).context("%H failed")?, + b'j' => self.fmt_day_of_year(ext).context("%j failed")?, + b'k' => self.fmt_hour24_space(ext).context("%k failed")?, + b'l' => self.fmt_hour12_space(ext).context("%l failed")?, b'M' => self.fmt_minute(ext).context("%M failed")?, b'm' => self.fmt_month(ext).context("%m failed")?, + b'n' => self.fmt_literal("\n").context("%n failed")?, b'P' => self.fmt_ampm_lower(ext).context("%P failed")?, b'p' => self.fmt_ampm_upper(ext).context("%p failed")?, b'Q' => self.fmt_iana_nocolon(b'Q').context("%Q failed")?, + b'R' => self.fmt_clock_nosecs(ext).context("%R failed")?, b'S' => self.fmt_second(ext).context("%S failed")?, - b'T' => self.fmt_clock(ext).context("%T failed")?, + b's' => self.fmt_timestamp(ext).context("%s failed")?, + b'T' => self.fmt_clock_secs(ext).context("%T failed")?, + b't' => self.fmt_literal("\t").context("%t failed")?, + b'U' => self.fmt_week_sun(ext).context("%U failed")?, + b'u' => self.fmt_weekday_mon(ext).context("%u failed")?, b'V' => self.fmt_iana_nocolon(b'V').context("%V failed")?, + b'W' => self.fmt_week_mon(ext).context("%W failed")?, + b'w' => self.fmt_weekday_sun(ext).context("%w failed")?, b'Y' => self.fmt_year(ext).context("%Y failed")?, - b'y' => self.fmt_year_2digit(ext).context("%y failed")?, + b'y' => self.fmt_year2(ext).context("%y failed")?, b'Z' => self.fmt_tzabbrev(ext).context("%Z failed")?, b'z' => self.fmt_offset_nocolon().context("%z failed")?, b':' => { @@ -245,13 +259,21 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> { self.wtr.write_char('/')?; self.fmt_day_zero(ext)?; self.wtr.write_char('/')?; - self.fmt_year_2digit(ext)?; + self.fmt_year2(ext)?; + Ok(()) + } + + /// %R + fn fmt_clock_nosecs(&mut self, ext: Extension) -> Result<(), Error> { + self.fmt_hour24_zero(ext)?; + self.wtr.write_char(':')?; + self.fmt_minute(ext)?; Ok(()) } /// %T - fn fmt_clock(&mut self, ext: Extension) -> Result<(), Error> { - self.fmt_hour24(ext)?; + fn fmt_clock_secs(&mut self, ext: Extension) -> Result<(), Error> { + self.fmt_hour24_zero(ext)?; self.wtr.write_char(':')?; self.fmt_minute(ext)?; self.wtr.write_char(':')?; @@ -280,7 +302,7 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> { } /// %I - fn fmt_hour12(&mut self, ext: Extension) -> Result<(), Error> { + fn fmt_hour12_zero(&mut self, ext: Extension) -> Result<(), Error> { let mut hour = self .tm .hour @@ -295,7 +317,7 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> { } /// %H - fn fmt_hour24(&mut self, ext: Extension) -> Result<(), Error> { + fn fmt_hour24_zero(&mut self, ext: Extension) -> Result<(), Error> { let hour = self .tm .hour @@ -304,6 +326,31 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> { ext.write_int(b'0', Some(2), hour, self.wtr) } + /// %l + fn fmt_hour12_space(&mut self, ext: Extension) -> Result<(), Error> { + let mut hour = self + .tm + .hour + .ok_or_else(|| err!("requires time to format hour"))? + .get(); + if hour == 0 { + hour = 12; + } else if hour > 12 { + hour -= 12; + } + ext.write_int(b' ', Some(2), hour, self.wtr) + } + + /// %k + fn fmt_hour24_space(&mut self, ext: Extension) -> Result<(), Error> { + let hour = self + .tm + .hour + .ok_or_else(|| err!("requires time to format hour"))? + .get(); + ext.write_int(b' ', Some(2), hour, self.wtr) + } + /// %F fn fmt_iso_date(&mut self, ext: Extension) -> Result<(), Error> { self.fmt_year(ext)?; @@ -420,6 +467,17 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> { ext.write_int(b'0', Some(2), second, self.wtr) } + /// %s + fn fmt_timestamp(&mut self, ext: Extension) -> Result<(), Error> { + let timestamp = self.tm.to_timestamp().map_err(|_| { + err!( + "requires instant (a date, time and offset) \ + to format Unix timestamp", + ) + })?; + ext.write_int(b' ', None, timestamp.as_second(), self.wtr) + } + /// %f fn fmt_fractional(&mut self, ext: Extension) -> Result<(), Error> { let subsec = self.tm.subsec.ok_or_else(|| { @@ -466,6 +524,7 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> { let weekday = self .tm .weekday + .or_else(|| self.tm.to_date().ok().map(|d| d.weekday())) .ok_or_else(|| err!("requires date to format weekday"))?; ext.write_str(Case::AsIs, weekday_name_full(weekday), self.wtr) } @@ -475,10 +534,93 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> { let weekday = self .tm .weekday + .or_else(|| self.tm.to_date().ok().map(|d| d.weekday())) .ok_or_else(|| err!("requires date to format weekday"))?; ext.write_str(Case::AsIs, weekday_name_abbrev(weekday), self.wtr) } + /// %u + fn fmt_weekday_mon(&mut self, ext: Extension) -> Result<(), Error> { + let weekday = self + .tm + .weekday + .or_else(|| self.tm.to_date().ok().map(|d| d.weekday())) + .ok_or_else(|| err!("requires date to format weekday number"))?; + ext.write_int(b' ', None, weekday.to_monday_one_offset(), self.wtr) + } + + /// %w + fn fmt_weekday_sun(&mut self, ext: Extension) -> Result<(), Error> { + let weekday = self + .tm + .weekday + .or_else(|| self.tm.to_date().ok().map(|d| d.weekday())) + .ok_or_else(|| err!("requires date to format weekday number"))?; + ext.write_int(b' ', None, weekday.to_sunday_zero_offset(), self.wtr) + } + + /// %U + fn fmt_week_sun(&mut self, ext: Extension) -> Result<(), Error> { + // Short circuit if the week number was explicitly set. + if let Some(weeknum) = self.tm.week_sun { + return ext.write_int(b'0', Some(2), weeknum, self.wtr); + } + let day = self + .tm + .day_of_year + .map(|day| day.get()) + .or_else(|| self.tm.to_date().ok().map(|d| d.day_of_year())) + .ok_or_else(|| { + err!("requires date to format Sunday-based week number") + })?; + let weekday = self + .tm + .weekday + .or_else(|| self.tm.to_date().ok().map(|d| d.weekday())) + .ok_or_else(|| { + err!("requires date to format Sunday-based week number") + })? + .to_sunday_zero_offset(); + // Example: 2025-01-05 is the first Sunday in 2025, and thus the start + // of week 1. This means that 2025-01-04 (Saturday) is in week 0. + // + // So for 2025-01-05, day=5 and weekday=0. Thus we get 11/7 = 1. + // For 2025-01-04, day=4 and weekday=6. Thus we get 4/7 = 0. + let weeknum = (day + 6 - i16::from(weekday)) / 7; + ext.write_int(b'0', Some(2), weeknum, self.wtr) + } + + /// %W + fn fmt_week_mon(&mut self, ext: Extension) -> Result<(), Error> { + // Short circuit if the week number was explicitly set. + if let Some(weeknum) = self.tm.week_mon { + return ext.write_int(b'0', Some(2), weeknum, self.wtr); + } + let day = self + .tm + .day_of_year + .map(|day| day.get()) + .or_else(|| self.tm.to_date().ok().map(|d| d.day_of_year())) + .ok_or_else(|| { + err!("requires date to format Monday-based week number") + })?; + let weekday = self + .tm + .weekday + .or_else(|| self.tm.to_date().ok().map(|d| d.weekday())) + .ok_or_else(|| { + err!("requires date to format Monday-based week number") + })? + .to_sunday_zero_offset(); + // Example: 2025-01-06 is the first Monday in 2025, and thus the start + // of week 1. This means that 2025-01-05 (Sunday) is in week 0. + // + // So for 2025-01-06, day=6 and weekday=1. Thus we get 12/7 = 1. + // For 2025-01-05, day=5 and weekday=7. Thus we get 5/7 = 0. + let weeknum = (day + 6 - ((i16::from(weekday) + 6) % 7)) / 7; + ext.write_int(b'0', Some(2), weeknum, self.wtr) + } + /// %Y fn fmt_year(&mut self, ext: Extension) -> Result<(), Error> { let year = self @@ -490,7 +632,7 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> { } /// %y - fn fmt_year_2digit(&mut self, ext: Extension) -> Result<(), Error> { + fn fmt_year2(&mut self, ext: Extension) -> Result<(), Error> { let year = self .tm .year @@ -505,6 +647,74 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> { let year = year % 100; ext.write_int(b'0', Some(2), year, self.wtr) } + + /// %C + fn fmt_century(&mut self, ext: Extension) -> Result<(), Error> { + let year = self + .tm + .year + .ok_or_else(|| err!("requires date to format century (2-digit)"))? + .get(); + let century = year / 100; + ext.write_int(b' ', None, century, self.wtr) + } + + /// %G + fn fmt_iso_week_year(&mut self, ext: Extension) -> Result<(), Error> { + let year = self + .tm + .iso_week_year + .or_else(|| { + self.tm.to_date().ok().map(|d| d.iso_week_date().year_ranged()) + }) + .ok_or_else(|| { + err!("requires date to format ISO 8601 week-based year") + })? + .get(); + ext.write_int(b'0', Some(4), year, self.wtr) + } + + /// %g + fn fmt_iso_week_year2(&mut self, ext: Extension) -> Result<(), Error> { + let year = self + .tm + .iso_week_year + .or_else(|| { + self.tm.to_date().ok().map(|d| d.iso_week_date().year_ranged()) + }) + .ok_or_else(|| { + err!( + "requires date to format \ + ISO 8601 week-based year (2-digit)" + ) + })? + .get(); + if !(1969 <= year && year <= 2068) { + return Err(err!( + "formatting a 2-digit ISO 8601 week-based year \ + requires that it be in \ + the inclusive range 1969 to 2068, but got {year}", + )); + } + let year = year % 100; + ext.write_int(b'0', Some(2), year, self.wtr) + } + + /// %j + fn fmt_day_of_year(&mut self, ext: Extension) -> Result<(), Error> { + let day = self + .tm + .day_of_year + .map(|day| day.get()) + .or_else(|| self.tm.to_date().ok().map(|d| d.day_of_year())) + .ok_or_else(|| err!("requires date to format day of year"))?; + ext.write_int(b'0', Some(3), day, self.wtr) + } + + /// %n, %t + fn fmt_literal(&mut self, literal: &str) -> Result<(), Error> { + self.wtr.write_str(literal) + } } /// Writes the given time zone offset to the writer. @@ -639,9 +849,9 @@ impl Case { #[cfg(test)] mod tests { use crate::{ - civil::{date, time, Date, Time}, + civil::{date, time, Date, DateTime, Time}, fmt::strtime::format, - Zoned, + Timestamp, Zoned, }; #[test] @@ -675,6 +885,7 @@ mod tests { fn ok_format_clock() { let f = |fmt: &str, time: Time| format(fmt, time).unwrap(); + insta::assert_snapshot!(f("%R", time(23, 59, 8, 0)), @"23:59"); insta::assert_snapshot!(f("%T", time(23, 59, 8, 0)), @"23:59:08"); } @@ -716,6 +927,16 @@ mod tests { insta::assert_snapshot!(f("%I", time(11, 0, 0, 0)), @"11"); insta::assert_snapshot!(f("%I", time(23, 0, 0, 0)), @"11"); insta::assert_snapshot!(f("%I", time(0, 0, 0, 0)), @"12"); + + insta::assert_snapshot!(f("%k", time(9, 0, 0, 0)), @" 9"); + insta::assert_snapshot!(f("%k", time(11, 0, 0, 0)), @"11"); + insta::assert_snapshot!(f("%k", time(23, 0, 0, 0)), @"23"); + insta::assert_snapshot!(f("%k", time(0, 0, 0, 0)), @" 0"); + + insta::assert_snapshot!(f("%l", time(9, 0, 0, 0)), @" 9"); + insta::assert_snapshot!(f("%l", time(11, 0, 0, 0)), @"11"); + insta::assert_snapshot!(f("%l", time(23, 0, 0, 0)), @"11"); + insta::assert_snapshot!(f("%l", time(0, 0, 0, 0)), @"12"); } #[test] @@ -875,6 +1096,9 @@ mod tests { insta::assert_snapshot!(f("%#A", date(2024, 7, 14)), @"Sunday"); insta::assert_snapshot!(f("%^A", date(2024, 7, 14)), @"SUNDAY"); + + insta::assert_snapshot!(f("%u", date(2024, 7, 14)), @"7"); + insta::assert_snapshot!(f("%w", date(2024, 7, 14)), @"0"); } #[test] @@ -884,6 +1108,16 @@ mod tests { insta::assert_snapshot!(f("%Y", date(2024, 7, 14)), @"2024"); insta::assert_snapshot!(f("%Y", date(24, 7, 14)), @"0024"); insta::assert_snapshot!(f("%Y", date(-24, 7, 14)), @"-0024"); + + insta::assert_snapshot!(f("%C", date(2024, 7, 14)), @"20"); + insta::assert_snapshot!(f("%C", date(1815, 7, 14)), @"18"); + insta::assert_snapshot!(f("%C", date(915, 7, 14)), @"9"); + insta::assert_snapshot!(f("%C", date(1, 7, 14)), @"0"); + insta::assert_snapshot!(f("%C", date(0, 7, 14)), @"0"); + insta::assert_snapshot!(f("%C", date(-1, 7, 14)), @"0"); + insta::assert_snapshot!(f("%C", date(-2024, 7, 14)), @"-20"); + insta::assert_snapshot!(f("%C", date(-1815, 7, 14)), @"-18"); + insta::assert_snapshot!(f("%C", date(-915, 7, 14)), @"-9"); } #[test] @@ -900,6 +1134,42 @@ mod tests { insta::assert_snapshot!(f("%_5y", date(2001, 7, 14)), @" 1"); } + #[test] + fn ok_format_iso_week_year() { + let f = |fmt: &str, date: Date| format(fmt, date).unwrap(); + + insta::assert_snapshot!(f("%G", date(2019, 11, 30)), @"2019"); + insta::assert_snapshot!(f("%G", date(19, 11, 30)), @"0019"); + insta::assert_snapshot!(f("%G", date(-19, 11, 30)), @"-0019"); + + // tricksy + insta::assert_snapshot!(f("%G", date(2019, 12, 30)), @"2020"); + } + + #[test] + fn ok_format_week_num() { + let f = |fmt: &str, date: Date| format(fmt, date).unwrap(); + + insta::assert_snapshot!(f("%U", date(2025, 1, 4)), @"00"); + insta::assert_snapshot!(f("%U", date(2025, 1, 5)), @"01"); + + insta::assert_snapshot!(f("%W", date(2025, 1, 5)), @"00"); + insta::assert_snapshot!(f("%W", date(2025, 1, 6)), @"01"); + } + + #[test] + fn ok_format_timestamp() { + let f = |fmt: &str, ts: Timestamp| format(fmt, ts).unwrap(); + + let ts = "1970-01-01T00:00Z".parse().unwrap(); + insta::assert_snapshot!(f("%s", ts), @"0"); + insta::assert_snapshot!(f("%3s", ts), @" 0"); + insta::assert_snapshot!(f("%03s", ts), @"000"); + + let ts = "2025-01-20T13:09-05[US/Eastern]".parse().unwrap(); + insta::assert_snapshot!(f("%s", ts), @"1737396540"); + } + #[test] fn err_format_subsec_nanosecond() { let f = |fmt: &str, time: Time| format(fmt, time).unwrap_err(); @@ -910,4 +1180,15 @@ mod tests { @"strftime formatting failed: %f failed: zero precision with %f is not allowed", ); } + + #[test] + fn err_format_timestamp() { + let f = |fmt: &str, dt: DateTime| format(fmt, dt).unwrap_err(); + + let dt = date(2025, 1, 20).at(13, 9, 0, 0); + insta::assert_snapshot!( + f("%s", dt), + @"strftime formatting failed: %s failed: requires instant (a date, time and offset) to format Unix timestamp", + ); + } } diff --git a/src/fmt/strtime/mod.rs b/src/fmt/strtime/mod.rs index 70c480f..afb78fd 100644 --- a/src/fmt/strtime/mod.rs +++ b/src/fmt/strtime/mod.rs @@ -161,31 +161,45 @@ strings, the strings are matched without regard to ASCII case. | `%%` | `%%` | A literal `%`. | | `%A`, `%a` | `Sunday`, `Sun` | The full and abbreviated weekday, respectively. | | `%B`, `%b`, `%h` | `June`, `Jun`, `Jun` | The full and abbreviated month name, respectively. | +| `%C` | `20` | The century of the year. No padding. | | `%D` | `7/14/24` | Equivalent to `%m/%d/%y`. | | `%d`, `%e` | `25`, ` 5` | The day of the month. `%d` is zero-padded, `%e` is space padded. | | `%F` | `2024-07-14` | Equivalent to `%Y-%m-%d`. | | `%f` | `000456` | Fractional seconds, up to nanosecond precision. | | `%.f` | `.000456` | Optional fractional seconds, with dot, up to nanosecond precision. | +| `%G` | `2024` | An [ISO 8601 week-based] year. Zero padded to 4 digits. | +| `%g` | `24` | A two-digit [ISO 8601 week-based] year. Represents only 1969-2068. Zero padded. | | `%H` | `23` | The hour in a 24 hour clock. Zero padded. | | `%I` | `11` | The hour in a 12 hour clock. Zero padded. | +| `%j` | `060` | The day of the year. Range is `1..=366`. Zero padded to 3 digits. | +| `%k` | `15` | The hour in a 24 hour clock. Space padded. | +| `%l` | ` 3` | The hour in a 12 hour clock. Space padded. | | `%M` | `04` | The minute. Zero padded. | | `%m` | `01` | The month. Zero padded. | +| `%n` | `\n` | Formats as a newline character. Parses arbitrary whitespace. | | `%P` | `am` | Whether the time is in the AM or PM, lowercase. | | `%p` | `PM` | Whether the time is in the AM or PM, uppercase. | | `%Q` | `America/New_York`, `+0530` | An IANA time zone identifier, or `%z` if one doesn't exist. | | `%:Q` | `America/New_York`, `+05:30` | An IANA time zone identifier, or `%:z` if one doesn't exist. | +| `%R` | `23:30` | Equivalent to `%H:%M`. | | `%S` | `59` | The second. Zero padded. | +| `%s` | `1737396540` | A Unix timestamp, in seconds. | | `%T` | `23:30:59` | Equivalent to `%H:%M:%S`. | +| `%t` | `\t` | Formats as a tab character. Parses arbitrary whitespace. | +| `%U` | `03` | Week number. Week 1 is the first week starting with a Sunday. Zero padded. | +| `%u` | `7` | The day of the week beginning with Monday at `1`. | +| `%W` | `03` | Week number. Week 1 is the first week starting with a Monday. Zero padded. | +| `%w` | `0` | The day of the week beginning with Sunday at `0`. | | `%Y` | `2024` | A full year, including century. Zero padded to 4 digits. | | `%y` | `24` | A two-digit year. Represents only 1969-2068. Zero padded. | | `%Z` | `EDT` | A time zone abbreviation. Supported when formatting only. | | `%z` | `+0530` | A time zone offset in the format `[+-]HHMM[SS]`. | | `%:z` | `+05:30` | A time zone offset in the format `[+-]HH:MM[:SS]`. | -These specifiers are deprecated. Specifically, in `jiff 0.2`, `%V` will parse -or print the ISO 8601 week number and `%:V` will no longer be recognized. To -emit an IANA time zone identifier (which is what `%V` does in `jiff 0.1`) in -a forward compatible way, please use the `%Q` or `%:Q` specifier (as listed +The following specifiers are deprecated. Specifically, in `jiff 0.2`, `%V` will +parse or print the ISO 8601 week number and `%:V` will no longer be recognized. +To emit an IANA time zone identifier (which is what `%V` does in `jiff 0.1`) +in a forward compatible way, please use the `%Q` or `%:Q` specifier (as listed above). | Specifier | Example | Description | @@ -215,7 +229,7 @@ than 256. The number formed by these digits will correspond to the minimum amount of padding (to the left). The flags and padding amount above may be used when parsing as well. Most -settings are ignoring during parsing except for padding. For example, if one +settings are ignored during parsing except for padding. For example, if one wanted to parse `003` as the day `3`, then one should use `%03d`. Otherwise, by default, `%d` will only try to consume at most 2 digits. @@ -243,12 +257,16 @@ is variable width data. If you have a use case for this, please The following things are currently unsupported: * Parsing or formatting fractional seconds in the time time zone offset. -* Conversion specifiers related to week numbers. -* Conversion specifiers related to day-of-year numbers, like the Julian day. -* The `%s` conversion specifier, for Unix timestamps in seconds. +* A conversion specifier for an ISO 8601 week number. It is planned to support + this, via `%V`, in `jiff 0.2`. +* Locale oriented conversion specifiers, such as `%c`, `%r` and `%+`, are not + supported by Jiff. For locale oriented datetime formatting, please use the + [`icu`] crate. [`strftime`]: https://pubs.opengroup.org/onlinepubs/009695399/functions/strftime.html [`strptime`]: https://pubs.opengroup.org/onlinepubs/009695399/functions/strptime.html +[ISO 8601 week-based]: https://en.wikipedia.org/wiki/ISO_week_date +[`icu`]: https://docs.rs/icu */ use crate::{ @@ -455,7 +473,7 @@ pub fn format( // the rest of Jiff's API. e.g., If a `DateTime` is requested but the format // string has no directives for time, we'll happy default to midnight. The // only catch is that you can't omit time units bigger than any present time -// unit. For example, `%M` doesn't fly. If you want to parse minutes, you +// unit. For example, only `%M` doesn't fly. If you want to parse minutes, you // also have to parse hours. // // This design does also let us possibly do "incomplete" parsing by asking @@ -468,14 +486,19 @@ pub struct BrokenDownTime { year: Option, month: Option, day: Option, + day_of_year: Option, + iso_week_year: Option, + week_sun: Option, + week_mon: Option, hour: Option, minute: Option, second: Option, subsec: Option, offset: Option, - // Only used to confirm that it is consistent - // with the date given. But otherwise cannot - // pick a date on its own. + // Used to confirm that it is consistent + // with the date given. It usually isn't + // used to pick a date on its own, but can + // be for week dates. weekday: Option, // Only generally useful with %I. But can still // be used with, say, %H. In that case, AM will @@ -880,10 +903,15 @@ impl BrokenDownTime { /// Extracts a civil date from this broken down time. /// + /// This requires that the year is set along with a way to identify the day + /// in the year. This can be done by either setting the month and the day + /// of the month (`%m` and `%d`), or by setting the day of the year (`%j`). + /// /// # Errors /// - /// This returns an error if there weren't enough components to construct a - /// civil date. This means there must be at least a year, month and day. + /// This returns an error if there weren't enough components to construct + /// a civil date. This means there must be at least a year and either the + /// month and day or the day of the year. /// /// It's okay if there are more units than are needed to construct a civil /// datetime. For example, if this broken down time contain a civil time, @@ -904,25 +932,24 @@ impl BrokenDownTime { #[inline] pub fn to_date(&self) -> Result { let Some(year) = self.year else { - return Err(err!( - "parsing format did not include year directive, \ - without it, a date cannot be created", - )); - }; - let Some(month) = self.month else { - return Err(err!( - "parsing format did not include month directive, \ - without it, a date cannot be created", - )); + return Err(err!("missing year, date cannot be created")); }; - let Some(day) = self.day else { + let mut date = self.to_date_from_gregorian(year)?; + if date.is_none() { + date = self.to_date_from_day_of_year(year)?; + } + if date.is_none() { + date = self.to_date_from_week_sun(year)?; + } + if date.is_none() { + date = self.to_date_from_week_mon(year)?; + } + let Some(date) = date else { return Err(err!( - "parsing format did not include day directive, \ - without it, a date cannot be created", + "a month/day, day-of-year or week date must be \ + present to create a date, but none were found", )); }; - let date = - Date::new_ranged(year, month, day).context("invalid date")?; if let Some(weekday) = self.weekday { if weekday != date.weekday() { return Err(err!( @@ -936,6 +963,129 @@ impl BrokenDownTime { Ok(date) } + #[inline] + fn to_date_from_gregorian( + &self, + year: t::Year, + ) -> Result, Error> { + let (Some(month), Some(day)) = (self.month, self.day) else { + return Ok(None); + }; + Ok(Some(Date::new_ranged(year, month, day).context("invalid date")?)) + } + + #[inline] + fn to_date_from_day_of_year( + &self, + year: t::Year, + ) -> Result, Error> { + let Some(doy) = self.day_of_year else { return Ok(None) }; + Ok(Some({ + let first = Date::new_ranged(year, C(1), C(1)).unwrap(); + first + .with() + .day_of_year(doy.get()) + .build() + .context("invalid date")? + })) + } + + #[inline] + fn to_date_from_week_sun( + &self, + year: t::Year, + ) -> Result, Error> { + let (Some(week), Some(weekday)) = (self.week_sun, self.weekday) else { + return Ok(None); + }; + let week = i16::from(week); + let wday = i16::from(weekday.to_sunday_zero_offset()); + let first_of_year = + Date::new_ranged(year, C(1), C(1)).context("invalid date")?; + let first_sunday = first_of_year + .nth_weekday_of_month(1, Weekday::Sunday) + .map(|d| d.day_of_year()) + .context("invalid date")?; + let doy = if week == 0 { + let days_before_first_sunday = 7 - wday; + let doy = first_sunday + .checked_sub(days_before_first_sunday) + .ok_or_else(|| { + err!( + "weekday `{weekday:?}` is not valid for \ + Sunday based week number `{week}` \ + in year `{year}`", + ) + })?; + if doy == 0 { + return Err(err!( + "weekday `{weekday:?}` is not valid for \ + Sunday based week number `{week}` \ + in year `{year}`", + )); + } + doy + } else { + let days_since_first_sunday = (week - 1) * 7 + wday; + let doy = first_sunday + days_since_first_sunday; + doy + }; + let date = first_of_year + .with() + .day_of_year(doy) + .build() + .context("invalid date")?; + Ok(Some(date)) + } + + #[inline] + fn to_date_from_week_mon( + &self, + year: t::Year, + ) -> Result, Error> { + let (Some(week), Some(weekday)) = (self.week_mon, self.weekday) else { + return Ok(None); + }; + let week = i16::from(week); + let wday = i16::from(weekday.to_monday_zero_offset()); + let first_of_year = + Date::new_ranged(year, C(1), C(1)).context("invalid date")?; + let first_monday = first_of_year + .nth_weekday_of_month(1, Weekday::Monday) + .map(|d| d.day_of_year()) + .context("invalid date")?; + let doy = if week == 0 { + let days_before_first_monday = 7 - wday; + let doy = first_monday + .checked_sub(days_before_first_monday) + .ok_or_else(|| { + err!( + "weekday `{weekday:?}` is not valid for \ + Monday based week number `{week}` \ + in year `{year}`", + ) + })?; + if doy == 0 { + return Err(err!( + "weekday `{weekday:?}` is not valid for \ + Monday based week number `{week}` \ + in year `{year}`", + )); + } + doy + } else { + let days_since_first_monday = (week - 1) * 7 + wday; + let doy = first_monday + days_since_first_monday; + doy + }; + let date = first_of_year + .with() + .day_of_year(doy) + .build() + .context("invalid date")?; + Ok(Some(date)) + } + /// Extracts a civil time from this broken down time. /// /// # Errors @@ -1175,6 +1325,166 @@ impl BrokenDownTime { self.day.map(|x| x.get()) } + /// Returns the parsed day of the year (1-366), if available. + /// + /// # Example + /// + /// This shows how to parse the day of the year: + /// + /// ``` + /// use jiff::fmt::strtime::BrokenDownTime; + /// + /// let tm = BrokenDownTime::parse("%j", "5")?; + /// assert_eq!(tm.day_of_year(), Some(5)); + /// assert_eq!(tm.to_string("%j")?, "005"); + /// assert_eq!(tm.to_string("%-j")?, "5"); + /// + /// // Parsing the day of the year works for all possible legal + /// // values, even if, e.g., 366 isn't valid for all possible + /// // year/month combinations. + /// let tm = BrokenDownTime::parse("%j", "366")?; + /// assert_eq!(tm.day_of_year(), Some(366)); + /// // This is true even if you're parsing a year: + /// let tm = BrokenDownTime::parse("%Y/%j", "2023/366")?; + /// assert_eq!(tm.day_of_year(), Some(366)); + /// // An error only occurs when you try to extract a date: + /// assert_eq!( + /// tm.to_date().unwrap_err().to_string(), + /// "invalid date: parameter 'day-of-year' with value 366 \ + /// is not in the required range of 1..=365", + /// ); + /// // But parsing a value that is always illegal will + /// // result in an error: + /// assert!(BrokenDownTime::parse("%j", "0").is_err()); + /// assert!(BrokenDownTime::parse("%j", "367").is_err()); + /// + /// # Ok::<(), Box>(()) + /// ``` + /// + /// # Example: extract a [`Date`] + /// + /// This example shows how parsing a year and a day of the year enables + /// the extraction of a date: + /// + /// ``` + /// use jiff::{civil::date, fmt::strtime::BrokenDownTime}; + /// + /// let tm = BrokenDownTime::parse("%Y-%j", "2024-60")?; + /// assert_eq!(tm.to_date()?, date(2024, 2, 29)); + /// + /// # Ok::<(), Box>(()) + /// ``` + /// + /// When all of `%m`, `%d` and `%j` are used, then `%m` and `%d` take + /// priority over `%j` when extracting a `Date` from a `BrokenDownTime`. + /// However, `%j` is still parsed and accessible: + /// + /// ``` + /// use jiff::{civil::date, fmt::strtime::BrokenDownTime}; + /// + /// let tm = BrokenDownTime::parse( + /// "%Y-%m-%d (day of year: %j)", + /// "2024-02-29 (day of year: 1)", + /// )?; + /// assert_eq!(tm.to_date()?, date(2024, 2, 29)); + /// assert_eq!(tm.day_of_year(), Some(1)); + /// + /// # Ok::<(), Box>(()) + /// ``` + #[inline] + pub fn day_of_year(&self) -> Option { + self.day_of_year.map(|x| x.get()) + } + + /// Returns the parsed ISO 8601 week-based year, if available. + /// + /// This is also set when a 2 digit ISO 8601 week-based year is parsed. + /// (But that's limited to the years 1969 to 2068, inclusive.) + /// + /// # Example + /// + /// This shows how to parse just an ISO 8601 week-based year: + /// + /// ``` + /// use jiff::fmt::strtime::BrokenDownTime; + /// + /// let tm = BrokenDownTime::parse("%G", "2024")?; + /// assert_eq!(tm.iso_week_year(), Some(2024)); + /// + /// # Ok::<(), Box>(()) + /// ``` + /// + /// And 2-digit years are supported too: + /// + /// ``` + /// use jiff::fmt::strtime::BrokenDownTime; + /// + /// let tm = BrokenDownTime::parse("%g", "24")?; + /// assert_eq!(tm.iso_week_year(), Some(2024)); + /// let tm = BrokenDownTime::parse("%g", "00")?; + /// assert_eq!(tm.iso_week_year(), Some(2000)); + /// let tm = BrokenDownTime::parse("%g", "69")?; + /// assert_eq!(tm.iso_week_year(), Some(1969)); + /// + /// // 2-digit years have limited range. They must + /// // be in the range 0-99. + /// assert!(BrokenDownTime::parse("%g", "2024").is_err()); + /// + /// # Ok::<(), Box>(()) + /// ``` + #[inline] + pub fn iso_week_year(&self) -> Option { + self.iso_week_year.map(|x| x.get()) + } + + /// Returns the Sunday based week number. + /// + /// The week number returned is always in the range `0..=53`. Week `1` + /// begins on the first Sunday of the year. Any days in the year prior to + /// week `1` are in week `0`. + /// + /// # Example + /// + /// ``` + /// use jiff::{civil::{Weekday, date}, fmt::strtime::BrokenDownTime}; + /// + /// let tm = BrokenDownTime::parse("%Y-%U-%w", "2025-01-0")?; + /// assert_eq!(tm.year(), Some(2025)); + /// assert_eq!(tm.sunday_based_week(), Some(1)); + /// assert_eq!(tm.weekday(), Some(Weekday::Sunday)); + /// assert_eq!(tm.to_date()?, date(2025, 1, 5)); + /// + /// # Ok::<(), Box>(()) + /// ``` + #[inline] + pub fn sunday_based_week(&self) -> Option { + self.week_sun.map(|x| x.get()) + } + + /// Returns the Monday based week number. + /// + /// The week number returned is always in the range `0..=53`. Week `1` + /// begins on the first Monday of the year. Any days in the year prior to + /// week `1` are in week `0`. + /// + /// # Example + /// + /// ``` + /// use jiff::{civil::{Weekday, date}, fmt::strtime::BrokenDownTime}; + /// + /// let tm = BrokenDownTime::parse("%Y-%U-%w", "2025-01-1")?; + /// assert_eq!(tm.year(), Some(2025)); + /// assert_eq!(tm.sunday_based_week(), Some(1)); + /// assert_eq!(tm.weekday(), Some(Weekday::Monday)); + /// assert_eq!(tm.to_date()?, date(2025, 1, 6)); + /// + /// # Ok::<(), Box>(()) + /// ``` + #[inline] + pub fn monday_based_week(&self) -> Option { + self.week_mon.map(|x| x.get()) + } + /// Returns the parsed hour, if available. /// /// The hour returned incorporates [`BrokenDownTime::meridiem`] if it's @@ -1534,6 +1844,137 @@ impl BrokenDownTime { Ok(()) } + /// Set the day of year on this broken down time. + /// + /// # Errors + /// + /// This returns an error if the given day is out of range. + /// + /// Note that setting a day to a value that is legal in any context + /// is always valid, even if it isn't valid for the year, month and + /// day-of-month components already set. + /// + /// # Example + /// + /// ``` + /// use jiff::fmt::strtime::BrokenDownTime; + /// + /// let mut tm = BrokenDownTime::default(); + /// // out of range + /// assert!(tm.set_day_of_year(Some(367)).is_err()); + /// tm.set_day_of_year(Some(31))?; + /// assert_eq!(tm.to_string("%j")?, "031"); + /// + /// // Works even if the resulting date is invalid. + /// let mut tm = BrokenDownTime::default(); + /// tm.set_year(Some(2023))?; + /// tm.set_day_of_year(Some(366))?; // 2023 wasn't a leap year + /// assert_eq!(tm.to_string("%Y/%j")?, "2023/366"); + /// + /// # Ok::<(), Box>(()) + /// ``` + #[inline] + pub fn set_day_of_year(&mut self, day: Option) -> Result<(), Error> { + self.day_of_year = match day { + None => None, + Some(day) => Some(t::DayOfYear::try_new("day-of-year", day)?), + }; + Ok(()) + } + + /// Set the ISO 8601 week-based year on this broken down time. + /// + /// # Errors + /// + /// This returns an error if the given year is out of range. + /// + /// # Example + /// + /// ``` + /// use jiff::fmt::strtime::BrokenDownTime; + /// + /// let mut tm = BrokenDownTime::default(); + /// // out of range + /// assert!(tm.set_iso_week_year(Some(10_000)).is_err()); + /// tm.set_iso_week_year(Some(2024))?; + /// assert_eq!(tm.to_string("%G")?, "2024"); + /// + /// # Ok::<(), Box>(()) + /// ``` + #[inline] + pub fn set_iso_week_year( + &mut self, + year: Option, + ) -> Result<(), Error> { + self.iso_week_year = match year { + None => None, + Some(year) => Some(t::ISOYear::try_new("year", year)?), + }; + Ok(()) + } + + /// Set the Sunday based week number. + /// + /// The week number returned is always in the range `0..=53`. Week `1` + /// begins on the first Sunday of the year. Any days in the year prior to + /// week `1` are in week `0`. + /// + /// # Example + /// + /// ``` + /// use jiff::fmt::strtime::BrokenDownTime; + /// + /// let mut tm = BrokenDownTime::default(); + /// // out of range + /// assert!(tm.set_sunday_based_week(Some(56)).is_err()); + /// tm.set_sunday_based_week(Some(9))?; + /// assert_eq!(tm.to_string("%U")?, "09"); + /// + /// # Ok::<(), Box>(()) + /// ``` + #[inline] + pub fn set_sunday_based_week( + &mut self, + week_number: Option, + ) -> Result<(), Error> { + self.week_sun = match week_number { + None => None, + Some(wk) => Some(t::WeekNum::try_new("week-number", wk)?), + }; + Ok(()) + } + + /// Set the Monday based week number. + /// + /// The week number returned is always in the range `0..=53`. Week `1` + /// begins on the first Monday of the year. Any days in the year prior to + /// week `1` are in week `0`. + /// + /// # Example + /// + /// ``` + /// use jiff::fmt::strtime::BrokenDownTime; + /// + /// let mut tm = BrokenDownTime::default(); + /// // out of range + /// assert!(tm.set_monday_based_week(Some(56)).is_err()); + /// tm.set_monday_based_week(Some(9))?; + /// assert_eq!(tm.to_string("%W")?, "09"); + /// + /// # Ok::<(), Box>(()) + /// ``` + #[inline] + pub fn set_monday_based_week( + &mut self, + week_number: Option, + ) -> Result<(), Error> { + self.week_mon = match week_number { + None => None, + Some(wk) => Some(t::WeekNum::try_new("week-number", wk)?), + }; + Ok(()) + } + /// Set the hour on this broken down time. /// /// # Errors @@ -1856,7 +2297,7 @@ impl<'a> From<&'a Zoned> for BrokenDownTime { impl From for BrokenDownTime { fn from(ts: Timestamp) -> BrokenDownTime { - let dt = TimeZone::UTC.to_datetime(ts); + let dt = Offset::UTC.to_datetime(ts); BrokenDownTime { offset: Some(Offset::UTC), ..BrokenDownTime::from(dt) @@ -1875,7 +2316,6 @@ impl From for BrokenDownTime { minute: Some(t.minute_ranged()), second: Some(t.second_ranged()), subsec: Some(t.subsec_nanosecond_ranged()), - weekday: Some(d.weekday()), meridiem: Some(Meridiem::from(t)), ..BrokenDownTime::default() } @@ -1888,7 +2328,6 @@ impl From for BrokenDownTime { year: Some(d.year_ranged()), month: Some(d.month_ranged()), day: Some(d.day_ranged()), - weekday: Some(d.weekday()), ..BrokenDownTime::default() } } diff --git a/src/fmt/strtime/parse.rs b/src/fmt/strtime/parse.rs index ea45548..6463db1 100644 --- a/src/fmt/strtime/parse.rs +++ b/src/fmt/strtime/parse.rs @@ -10,7 +10,7 @@ use crate::{ rangeint::{ri8, RFrom}, t::{self, C}, }, - Error, + Error, Timestamp, }; // Custom offset value ranges. They're the same as what we use for `Offset`, @@ -56,24 +56,38 @@ impl<'f, 'i, 't> Parser<'f, 'i, 't> { b'a' => self.parse_weekday_abbrev().context("%a failed")?, b'B' => self.parse_month_name_full().context("%B failed")?, b'b' => self.parse_month_name_abbrev().context("%b failed")?, + b'C' => self.parse_century(ext).context("%C failed")?, b'D' => self.parse_american_date().context("%D failed")?, b'd' => self.parse_day(ext).context("%d failed")?, b'e' => self.parse_day(ext).context("%e failed")?, b'F' => self.parse_iso_date().context("%F failed")?, b'f' => self.parse_fractional(ext).context("%f failed")?, - b'H' => self.parse_hour(ext).context("%H failed")?, + b'G' => self.parse_iso_week_year(ext).context("%G failed")?, + b'g' => self.parse_iso_week_year2(ext).context("%g failed")?, + b'H' => self.parse_hour24(ext).context("%H failed")?, b'h' => self.parse_month_name_abbrev().context("%h failed")?, b'I' => self.parse_hour12(ext).context("%I failed")?, + b'j' => self.parse_day_of_year(ext).context("%j failed")?, + b'k' => self.parse_hour24(ext).context("%k failed")?, + b'l' => self.parse_hour12(ext).context("%l failed")?, b'M' => self.parse_minute(ext).context("%M failed")?, b'm' => self.parse_month(ext).context("%m failed")?, + b'n' => self.parse_whitespace().context("%n failed")?, b'P' => self.parse_ampm().context("%P failed")?, b'p' => self.parse_ampm().context("%p failed")?, b'Q' => self.parse_iana_nocolon(b'Q').context("%Q failed")?, + b'R' => self.parse_clock_nosecs().context("%R failed")?, b'S' => self.parse_second(ext).context("%S failed")?, - b'T' => self.parse_clock().context("%T failed")?, + b's' => self.parse_timestamp(ext).context("%s failed")?, + b'T' => self.parse_clock_secs().context("%T failed")?, + b't' => self.parse_whitespace().context("%t failed")?, + b'U' => self.parse_week_sun(ext).context("%U failed")?, + b'u' => self.parse_weekday_mon(ext).context("%u failed")?, b'V' => self.parse_iana_nocolon(b'V').context("%V failed")?, + b'W' => self.parse_week_mon(ext).context("%W failed")?, + b'w' => self.parse_weekday_sun(ext).context("%w failed")?, b'Y' => self.parse_year(ext).context("%Y failed")?, - b'y' => self.parse_year_2digit(ext).context("%y failed")?, + b'y' => self.parse_year2(ext).context("%y failed")?, b'z' => self.parse_offset_nocolon().context("%z failed")?, b':' => { if !self.bump_fmt() { @@ -222,6 +236,17 @@ impl<'f, 'i, 't> Parser<'f, 'i, 't> { Ok(()) } + /// Parses an arbitrary (zero or more) amount ASCII whitespace. + /// + /// This is for `%n` and `%t`. + fn parse_whitespace(&mut self) -> Result<(), Error> { + if !self.inp.is_empty() { + while self.i().is_ascii_whitespace() && self.bump_input() {} + } + self.bump_fmt(); + Ok(()) + } + /// Parses a literal '%' from the input. fn parse_percent(&mut self) -> Result<(), Error> { if self.i() != b'%' { @@ -265,7 +290,7 @@ impl<'f, 'i, 't> Parser<'f, 'i, 't> { } /// Parses `%T`, which is equivalent to `%H:%M:%S`. - fn parse_clock(&mut self) -> Result<(), Error> { + fn parse_clock_secs(&mut self) -> Result<(), Error> { let mut p = Parser { fmt: b"%H:%M:%S", inp: self.inp, tm: self.tm }; p.parse()?; self.inp = p.inp; @@ -273,10 +298,18 @@ impl<'f, 'i, 't> Parser<'f, 'i, 't> { Ok(()) } + /// Parses `%R`, which is equivalent to `%H:%M`. + fn parse_clock_nosecs(&mut self) -> Result<(), Error> { + let mut p = Parser { fmt: b"%H:%M", inp: self.inp, tm: self.tm }; + p.parse()?; + self.inp = p.inp; + self.bump_fmt(); + Ok(()) + } + /// Parses `%d` and `%e`, which is equivalent to the day of the month. /// - /// We merely require that it is in the range 1-31 here. It isn't - /// validated as an actual date until `Pieces` is used. + /// We merely require that it is in the range 1-31 here. fn parse_day(&mut self, ext: Extension) -> Result<(), Error> { let (day, inp) = ext .parse_number(2, Flag::PadZero, self.inp) @@ -290,8 +323,24 @@ impl<'f, 'i, 't> Parser<'f, 'i, 't> { Ok(()) } + /// Parses `%j`, which is equivalent to the day of the year. + /// + /// We merely require that it is in the range 1-366 here. + fn parse_day_of_year(&mut self, ext: Extension) -> Result<(), Error> { + let (day, inp) = ext + .parse_number(3, Flag::PadZero, self.inp) + .context("failed to parse day of year")?; + self.inp = inp; + + let day = t::DayOfYear::try_new("day-of-year", day) + .context("day of year number is invalid")?; + self.tm.day_of_year = Some(day); + self.bump_fmt(); + Ok(()) + } + /// Parses `%H`, which is equivalent to the hour. - fn parse_hour(&mut self, ext: Extension) -> Result<(), Error> { + fn parse_hour24(&mut self, ext: Extension) -> Result<(), Error> { let (hour, inp) = ext .parse_number(2, Flag::PadZero, self.inp) .context("failed to parse hour")?; @@ -567,6 +616,50 @@ impl<'f, 'i, 't> Parser<'f, 'i, 't> { Ok(()) } + /// Parses `%s`, which is equivalent to a Unix timestamp. + fn parse_timestamp(&mut self, ext: Extension) -> Result<(), Error> { + let (sign, inp) = parse_optional_sign(self.inp); + let (timestamp, inp) = ext + // 19 comes from `i64::MAX.to_string().len()`. + .parse_number(19, Flag::PadSpace, inp) + .context("failed to parse Unix timestamp (in seconds)")?; + // I believe this error case is actually impossible. Since `timestamp` + // is guaranteed to be positive, and negating any positive `i64` will + // always result in a valid `i64`. + let timestamp = timestamp.checked_mul(sign).ok_or_else(|| { + err!( + "parsed Unix timestamp `{timestamp}` with a \ + leading `-` sign, which causes overflow", + ) + })?; + let timestamp = + Timestamp::from_second(timestamp).with_context(|| { + err!( + "parsed Unix timestamp `{timestamp}`, \ + but out of range of valid Jiff `Timestamp`", + ) + })?; + self.inp = inp; + + // This is basically just repeating the + // `From for BrokenDownTime` + // trait implementation. + let dt = Offset::UTC.to_datetime(timestamp); + let (d, t) = (dt.date(), dt.time()); + self.tm.offset = Some(Offset::UTC); + self.tm.year = Some(d.year_ranged()); + self.tm.month = Some(d.month_ranged()); + self.tm.day = Some(d.day_ranged()); + self.tm.hour = Some(t.hour_ranged()); + self.tm.minute = Some(t.minute_ranged()); + self.tm.second = Some(t.second_ranged()); + self.tm.subsec = Some(t.subsec_nanosecond_ranged()); + self.tm.meridiem = Some(Meridiem::from(t)); + + self.bump_fmt(); + Ok(()) + } + /// Parses `%f`, which is equivalent to a fractional second up to /// nanosecond precision. This must always parse at least one decimal digit /// and does not parse any leading dot. @@ -714,6 +807,71 @@ impl<'f, 'i, 't> Parser<'f, 'i, 't> { Ok(()) } + /// Parse `%u`, which is a weekday number with Monday being `1` and + /// Sunday being `7`. + fn parse_weekday_mon(&mut self, ext: Extension) -> Result<(), Error> { + let (weekday, inp) = ext + .parse_number(1, Flag::NoPad, self.inp) + .context("failed to parse weekday number")?; + self.inp = inp; + + let weekday = i8::try_from(weekday).map_err(|_| { + err!("parsed weekday number `{weekday}` is invalid") + })?; + let weekday = Weekday::from_monday_one_offset(weekday) + .context("weekday number is invalid")?; + self.tm.weekday = Some(weekday); + self.bump_fmt(); + Ok(()) + } + + /// Parse `%w`, which is a weekday number with Sunday being `0`. + fn parse_weekday_sun(&mut self, ext: Extension) -> Result<(), Error> { + let (weekday, inp) = ext + .parse_number(1, Flag::NoPad, self.inp) + .context("failed to parse weekday number")?; + self.inp = inp; + + let weekday = i8::try_from(weekday).map_err(|_| { + err!("parsed weekday number `{weekday}` is invalid") + })?; + let weekday = Weekday::from_sunday_zero_offset(weekday) + .context("weekday number is invalid")?; + self.tm.weekday = Some(weekday); + self.bump_fmt(); + Ok(()) + } + + /// Parse `%U`, which is a week number with Sunday being the first day + /// in the first week numbered `01`. + fn parse_week_sun(&mut self, ext: Extension) -> Result<(), Error> { + let (week, inp) = ext + .parse_number(2, Flag::PadZero, self.inp) + .context("failed to parse Sunday-based week number")?; + self.inp = inp; + + let week = t::WeekNum::try_new("week", week) + .context("Sunday-based week number is invalid")?; + self.tm.week_sun = Some(week); + self.bump_fmt(); + Ok(()) + } + + /// Parse `%W`, which is a week number with Monday being the first day + /// in the first week numbered `01`. + fn parse_week_mon(&mut self, ext: Extension) -> Result<(), Error> { + let (week, inp) = ext + .parse_number(2, Flag::PadZero, self.inp) + .context("failed to parse Monday-based week number")?; + self.inp = inp; + + let week = t::WeekNum::try_new("week", week) + .context("Monday-based week number is invalid")?; + self.tm.week_mon = Some(week); + self.bump_fmt(); + Ok(()) + } + /// Parses `%Y`, which we permit to be any year, including a negative year. fn parse_year(&mut self, ext: Extension) -> Result<(), Error> { let (sign, inp) = parse_optional_sign(self.inp); @@ -735,7 +893,7 @@ impl<'f, 'i, 't> Parser<'f, 'i, 't> { /// Parses `%y`, which is equivalent to a 2-digit year. /// /// The numbers 69-99 refer to 1969-1999, while 00-68 refer to 2000-2068. - fn parse_year_2digit(&mut self, ext: Extension) -> Result<(), Error> { + fn parse_year2(&mut self, ext: Extension) -> Result<(), Error> { type Year2Digit = ri8<0, 99>; let (year, inp) = ext @@ -755,6 +913,71 @@ impl<'f, 'i, 't> Parser<'f, 'i, 't> { self.bump_fmt(); Ok(()) } + + /// Parses `%C`, which we permit to just be a century, including a negative + /// century. + fn parse_century(&mut self, ext: Extension) -> Result<(), Error> { + let (sign, inp) = parse_optional_sign(self.inp); + let (century, inp) = ext + .parse_number(2, Flag::NoPad, inp) + .context("failed to parse century")?; + self.inp = inp; + + // OK because sign=={1,-1} and century can't be bigger than 2 digits + // so overflow isn't possible. + let century = sign.checked_mul(century).unwrap(); + // Similarly, we have 64-bit integers here. Two digits multiplied by + // 100 will never overflow. + let year = century.checked_mul(100).unwrap(); + // I believe the error condition here is impossible. + let year = t::Year::try_new("year", year) + .context("year number (from century) is invalid")?; + self.tm.year = Some(year); + self.bump_fmt(); + Ok(()) + } + + /// Parses `%G`, which we permit to be any year, including a negative year. + fn parse_iso_week_year(&mut self, ext: Extension) -> Result<(), Error> { + let (sign, inp) = parse_optional_sign(self.inp); + let (year, inp) = ext + .parse_number(4, Flag::PadZero, inp) + .context("failed to parse ISO 8601 week-based year")?; + self.inp = inp; + + // OK because sign=={1,-1} and year can't be bigger than 4 digits + // so overflow isn't possible. + let year = sign.checked_mul(year).unwrap(); + let year = t::ISOYear::try_new("year", year) + .context("ISO 8601 week-based year number is invalid")?; + self.tm.iso_week_year = Some(year); + self.bump_fmt(); + Ok(()) + } + + /// Parses `%g`, which is equivalent to a 2-digit ISO 8601 week-based year. + /// + /// The numbers 69-99 refer to 1969-1999, while 00-68 refer to 2000-2068. + fn parse_iso_week_year2(&mut self, ext: Extension) -> Result<(), Error> { + type Year2Digit = ri8<0, 99>; + + let (year, inp) = ext + .parse_number(2, Flag::PadZero, self.inp) + .context("failed to parse 2-digit ISO 8601 week-based year")?; + self.inp = inp; + + let year = Year2Digit::try_new("year (2 digits)", year) + .context("ISO 8601 week-based year number is invalid")?; + let mut year = t::ISOYear::rfrom(year); + if year <= 68 { + year += C(2000); + } else { + year += C(1900); + } + self.tm.iso_week_year = Some(year); + self.bump_fmt(); + Ok(()) + } } impl Extension { @@ -778,7 +1001,7 @@ impl Extension { self, default_pad_width: usize, default_flag: Flag, - inp: &'i [u8], + mut inp: &'i [u8], ) -> Result<(i64, &'i [u8]), Error> { let flag = self.flag.unwrap_or(default_flag); let zero_pad_width = match flag { @@ -787,6 +1010,10 @@ impl Extension { }; let max_digits = default_pad_width.max(zero_pad_width); + // Strip and ignore any whitespace we might see here. + while inp.get(0).map_or(false, |b| b.is_ascii_whitespace()) { + inp = &inp[1..]; + } let mut digits = 0; while digits < inp.len() && digits < zero_pad_width @@ -1147,6 +1374,39 @@ mod tests { p("%h %d, %Y %H:%M:%S %:z", "Apr 1, 2022 20:46:15 -04:00:59"), @"2022-04-02T00:47:14Z", ); + + insta::assert_debug_snapshot!( + p("%s", "0"), + @"1970-01-01T00:00:00Z", + ); + insta::assert_debug_snapshot!( + p("%s", "-0"), + @"1970-01-01T00:00:00Z", + ); + insta::assert_debug_snapshot!( + p("%s", "-1"), + @"1969-12-31T23:59:59Z", + ); + insta::assert_debug_snapshot!( + p("%s", "1"), + @"1970-01-01T00:00:01Z", + ); + insta::assert_debug_snapshot!( + p("%s", "+1"), + @"1970-01-01T00:00:01Z", + ); + insta::assert_debug_snapshot!( + p("%s", "1737396540"), + @"2025-01-20T18:09:00Z", + ); + insta::assert_debug_snapshot!( + p("%s", "-377705023201"), + @"-009999-01-02T01:59:59Z", + ); + insta::assert_debug_snapshot!( + p("%s", "253402207200"), + @"9999-12-30T22:00:00Z", + ); } #[test] @@ -1250,6 +1510,78 @@ mod tests { p("%5Y%m%d", "009990111"), @"0999-01-11", ); + + insta::assert_debug_snapshot!( + p("%C-%m-%d", "20-07-01"), + @"2000-07-01", + ); + insta::assert_debug_snapshot!( + p("%C-%m-%d", "-20-07-01"), + @"-002000-07-01", + ); + insta::assert_debug_snapshot!( + p("%C-%m-%d", "9-07-01"), + @"0900-07-01", + ); + insta::assert_debug_snapshot!( + p("%C-%m-%d", "-9-07-01"), + @"-000900-07-01", + ); + insta::assert_debug_snapshot!( + p("%C-%m-%d", "09-07-01"), + @"0900-07-01", + ); + insta::assert_debug_snapshot!( + p("%C-%m-%d", "-09-07-01"), + @"-000900-07-01", + ); + insta::assert_debug_snapshot!( + p("%C-%m-%d", "0-07-01"), + @"0000-07-01", + ); + insta::assert_debug_snapshot!( + p("%C-%m-%d", "-0-07-01"), + @"0000-07-01", + ); + + insta::assert_snapshot!( + p("%u %m/%d/%Y", "7 7/14/2024"), + @"2024-07-14", + ); + insta::assert_snapshot!( + p("%w %m/%d/%Y", "0 7/14/2024"), + @"2024-07-14", + ); + + insta::assert_snapshot!( + p("%Y-%U-%u", "2025-00-6"), + @"2025-01-04", + ); + insta::assert_snapshot!( + p("%Y-%U-%u", "2025-01-7"), + @"2025-01-05", + ); + insta::assert_snapshot!( + p("%Y-%U-%u", "2025-01-1"), + @"2025-01-06", + ); + + insta::assert_snapshot!( + p("%Y-%W-%u", "2025-00-6"), + @"2025-01-04", + ); + insta::assert_snapshot!( + p("%Y-%W-%u", "2025-00-7"), + @"2025-01-05", + ); + insta::assert_snapshot!( + p("%Y-%W-%u", "2025-01-1"), + @"2025-01-06", + ); + insta::assert_snapshot!( + p("%Y-%W-%u", "2025-01-2"), + @"2025-01-07", + ); } #[test] @@ -1261,10 +1593,6 @@ mod tests { .unwrap() }; - insta::assert_debug_snapshot!( - p("%H", "15"), - @"15:00:00", - ); insta::assert_debug_snapshot!( p("%H:%M", "15:48"), @"15:48:00", @@ -1281,6 +1609,10 @@ mod tests { p("%T", "15:48:59"), @"15:48:59", ); + insta::assert_debug_snapshot!( + p("%R", "15:48"), + @"15:48:00", + ); insta::assert_debug_snapshot!( p("%H %p", "5 am"), @@ -1352,6 +1684,95 @@ mod tests { p("%H:%M:%S.%3f", "15:48:01.123456"), @"15:48:01.123456", ); + + insta::assert_debug_snapshot!( + p("%H", "09"), + @"09:00:00", + ); + insta::assert_debug_snapshot!( + p("%H", " 9"), + @"09:00:00", + ); + insta::assert_debug_snapshot!( + p("%H", "15"), + @"15:00:00", + ); + insta::assert_debug_snapshot!( + p("%k", "09"), + @"09:00:00", + ); + insta::assert_debug_snapshot!( + p("%k", " 9"), + @"09:00:00", + ); + insta::assert_debug_snapshot!( + p("%k", "15"), + @"15:00:00", + ); + + insta::assert_debug_snapshot!( + p("%I", "09"), + @"09:00:00", + ); + insta::assert_debug_snapshot!( + p("%I", " 9"), + @"09:00:00", + ); + insta::assert_debug_snapshot!( + p("%l", "09"), + @"09:00:00", + ); + insta::assert_debug_snapshot!( + p("%l", " 9"), + @"09:00:00", + ); + } + + #[test] + fn ok_parse_whitespace() { + let p = |fmt: &str, input: &str| { + BrokenDownTime::parse_mono(fmt.as_bytes(), input.as_bytes()) + .unwrap() + .to_time() + .unwrap() + }; + + insta::assert_debug_snapshot!( + p("%H%M", "1548"), + @"15:48:00", + ); + insta::assert_debug_snapshot!( + p("%H%M", "15\n48"), + @"15:48:00", + ); + insta::assert_debug_snapshot!( + p("%H%M", "15\t48"), + @"15:48:00", + ); + insta::assert_debug_snapshot!( + p("%H%n%M", "1548"), + @"15:48:00", + ); + insta::assert_debug_snapshot!( + p("%H%n%M", "15\n48"), + @"15:48:00", + ); + insta::assert_debug_snapshot!( + p("%H%n%M", "15\t48"), + @"15:48:00", + ); + insta::assert_debug_snapshot!( + p("%H%t%M", "1548"), + @"15:48:00", + ); + insta::assert_debug_snapshot!( + p("%H%t%M", "15\n48"), + @"15:48:00", + ); + insta::assert_debug_snapshot!( + p("%H%t%M", "15\t48"), + @"15:48:00", + ); } #[test] @@ -1510,6 +1931,40 @@ mod tests { p("%Q", "America/+"), @r###"strptime parsing failed: %Q failed: expected the start of an IANA time zone identifier name or component, but found "+" instead"###, ); + + insta::assert_snapshot!( + p("%s", "-377705023202"), + @"strptime parsing failed: %s failed: parsed Unix timestamp `-377705023202`, but out of range of valid Jiff `Timestamp`: parameter 'second' with value -377705023202 is not in the required range of -377705023201..=253402207200", + ); + insta::assert_snapshot!( + p("%s", "253402207201"), + @"strptime parsing failed: %s failed: parsed Unix timestamp `253402207201`, but out of range of valid Jiff `Timestamp`: parameter 'second' with value 253402207201 is not in the required range of -377705023201..=253402207200", + ); + insta::assert_snapshot!( + p("%s", "-9999999999999999999"), + @"strptime parsing failed: %s failed: failed to parse Unix timestamp (in seconds): number '9999999999999999999' too big to parse into 64-bit integer", + ); + insta::assert_snapshot!( + p("%s", "9999999999999999999"), + @"strptime parsing failed: %s failed: failed to parse Unix timestamp (in seconds): number '9999999999999999999' too big to parse into 64-bit integer", + ); + + insta::assert_snapshot!( + p("%u", "0"), + @"strptime parsing failed: %u failed: weekday number is invalid: parameter 'weekday' with value 0 is not in the required range of 1..=7", + ); + insta::assert_snapshot!( + p("%w", "7"), + @"strptime parsing failed: %w failed: weekday number is invalid: parameter 'weekday' with value 7 is not in the required range of 0..=6", + ); + insta::assert_snapshot!( + p("%u", "128"), + @r###"strptime expects to consume the entire input, but "28" remains unparsed"###, + ); + insta::assert_snapshot!( + p("%w", "128"), + @r###"strptime expects to consume the entire input, but "28" remains unparsed"###, + ); } #[test] @@ -1524,27 +1979,27 @@ mod tests { insta::assert_snapshot!( p("%Y", "2024"), - @"parsing format did not include month directive, without it, a date cannot be created", + @"a month/day, day-of-year or week date must be present to create a date, but none were found", ); insta::assert_snapshot!( p("%m", "7"), - @"parsing format did not include year directive, without it, a date cannot be created", + @"missing year, date cannot be created", ); insta::assert_snapshot!( p("%d", "25"), - @"parsing format did not include year directive, without it, a date cannot be created", + @"missing year, date cannot be created", ); insta::assert_snapshot!( p("%Y-%m", "2024-7"), - @"parsing format did not include day directive, without it, a date cannot be created", + @"a month/day, day-of-year or week date must be present to create a date, but none were found", ); insta::assert_snapshot!( p("%Y-%d", "2024-25"), - @"parsing format did not include month directive, without it, a date cannot be created", + @"a month/day, day-of-year or week date must be present to create a date, but none were found", ); insta::assert_snapshot!( p("%m-%d", "7-25"), - @"parsing format did not include year directive, without it, a date cannot be created", + @"missing year, date cannot be created", ); insta::assert_snapshot!( @@ -1563,6 +2018,15 @@ mod tests { p("%A %m/%d/%y", "Monday 7/14/24"), @"parsed weekday Monday does not match weekday Sunday from parsed date 2024-07-14", ); + + insta::assert_snapshot!( + p("%Y-%U-%u", "2025-00-2"), + @"weekday `Tuesday` is not valid for Sunday based week number `0` in year `2025`", + ); + insta::assert_snapshot!( + p("%Y-%W-%u", "2025-00-2"), + @"weekday `Tuesday` is not valid for Monday based week number `0` in year `2025`", + ); } #[test] diff --git a/src/util/t.rs b/src/util/t.rs index 3c01d7d..763ff7d 100644 --- a/src/util/t.rs +++ b/src/util/t.rs @@ -146,10 +146,14 @@ pub(crate) type WeekdayOne = ri8<1, 7>; /// is being used. pub(crate) type Day = ri8<1, 31>; +pub(crate) type DayOfYear = ri16<1, 366>; + pub(crate) type ISOYear = ri16<-9999, 9999>; pub(crate) type ISOWeek = ri8<1, 53>; +pub(crate) type WeekNum = ri8<0, 53>; + /// The range of possible hour values. pub(crate) type Hour = ri8<0, 23>;