diff --git a/src/items/builder.rs b/src/items/builder.rs index bcffcd7..f0ec1b0 100644 --- a/src/items/builder.rs +++ b/src/items/builder.rs @@ -20,6 +20,10 @@ pub(crate) struct DateTimeBuilder { time: Option, weekday: Option, offset: Option, + /// Whether the military timezone letter `j` was given. It is a timezone + /// item, so it excludes any other one, but it means local time and so + /// contributes no offset of its own. + local_zone: bool, timezone: Option, relative: Vec, } @@ -59,6 +63,7 @@ impl DateTimeBuilder { || self.time.is_some() || self.weekday.is_some() || self.offset.is_some() + || self.local_zone || !self.relative.is_empty() { return Err("timestamp cannot be combined with other date/time items"); @@ -84,7 +89,7 @@ impl DateTimeBuilder { return Err("timestamp cannot be combined with other date/time items"); } else if self.time.is_some() { return Err("time cannot appear more than once"); - } else if self.offset.is_some() && time.offset.is_some() { + } else if (self.offset.is_some() || self.local_zone) && time.offset.is_some() { return Err("time offset and timezone are mutually exclusive"); } @@ -107,6 +112,7 @@ impl DateTimeBuilder { if self.timestamp.is_some() { return Err("timestamp cannot be combined with other date/time items"); } else if self.offset.is_some() + || self.local_zone || self.time.as_ref().and_then(|t| t.offset.as_ref()).is_some() { return Err("time offset cannot appear more than once"); @@ -116,6 +122,24 @@ impl DateTimeBuilder { Ok(self) } + /// Record the military timezone letter `j` (local time). + /// + /// It occupies the same slot as a numeric offset, so GNU rejects `8j utc`, + /// `8 utc j` and `8 j j` as a repeated timezone, and so do we. + fn set_local_zone(mut self) -> Result { + if self.timestamp.is_some() { + return Err("timestamp cannot be combined with other date/time items"); + } else if self.offset.is_some() + || self.local_zone + || self.time.as_ref().and_then(|t| t.offset.as_ref()).is_some() + { + return Err("time offset cannot appear more than once"); + } + + self.local_zone = true; + Ok(self) + } + fn push_relative(mut self, relative: relative::Relative) -> Result { if self.timestamp.is_some() { return Err("timestamp cannot be combined with other date/time items"); @@ -245,6 +269,7 @@ impl DateTimeBuilder { || self.time.is_some() || self.weekday.is_some() || self.offset.is_some() + || self.local_zone || has_timezone; let mut dt = if need_midnight { @@ -362,6 +387,7 @@ impl DateTimeBuilder { time, weekday, offset, + local_zone, timezone, relative, } = self; @@ -379,6 +405,7 @@ impl DateTimeBuilder { || time.is_some() || weekday.is_some() || offset.is_some() + || local_zone || has_timezone; let mut dt = ExtendedDateTime::new( DateParts { @@ -528,6 +555,7 @@ impl TryFrom> for DateTimeBuilder { Item::Time(t) => builder.set_time(t)?, Item::Weekday(weekday) => builder.set_weekday(weekday)?, Item::Offset(offset) => builder.set_offset(offset)?, + Item::LocalZone => builder.set_local_zone()?, Item::Relative(rel) => builder.push_relative(rel)?, Item::TimeZone(tz) => builder.set_timezone(tz)?, Item::Pure(pure) => builder.set_pure(pure)?, @@ -681,6 +709,7 @@ mod tests { DateTimeBuilder::new().set_time(time()).unwrap(), DateTimeBuilder::new().set_weekday(weekday()).unwrap(), DateTimeBuilder::new().set_offset(offset()).unwrap(), + DateTimeBuilder::new().set_local_zone().unwrap(), DateTimeBuilder::new() .push_relative(relative_day()) .unwrap(), @@ -715,6 +744,10 @@ mod tests { ts_builder().set_offset(offset()).unwrap_err(), "timestamp cannot be combined with other date/time items" ); + assert_eq!( + ts_builder().set_local_zone().unwrap_err(), + "timestamp cannot be combined with other date/time items" + ); assert_eq!( ts_builder().set_pure("2023".to_string()).unwrap_err(), "timestamp cannot be combined with other date/time items" diff --git a/src/items/mod.rs b/src/items/mod.rs index 6870a6b..92e1d9a 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -69,6 +69,10 @@ enum Item { Weekday(weekday::Weekday), Relative(relative::Relative), Offset(offset::Offset), + /// The military timezone letter `j`, which GNU `date` defines as local + /// time. It is a timezone item like [`Item::Offset`], but carries no + /// offset, so it cannot be represented as one. + LocalZone, TimeZone(jiff::tz::TimeZone), Pure(String), } @@ -260,6 +264,7 @@ fn parse_item(input: &mut &str) -> ModalResult { relative::parse.map(Item::Relative), weekday::parse.map(Item::Weekday), offset::parse.map(Item::Offset), + offset::parse_local.map(|()| Item::LocalZone), pure::parse.map(Item::Pure), )), ) @@ -785,4 +790,30 @@ mod tests { assert_eq!(parse_build(input), expected, "{input}"); } } + /// `j` is a time zone item, so it participates in the same duplicate check + /// as a numeric offset. GNU rejects all three of these as a repeated zone. + #[test] + fn military_j_is_a_timezone_item() { + // Parses on its own, attached or spaced, in either case. + for input in ["j", "8j", "8 j", "8J", "j 8"] { + assert!(parse(&mut &*input).is_ok(), "`{input}` should parse"); + } + + for input in ["8j utc", "8 utc j", "8 j j"] { + let result = parse(&mut &*input); + assert!(result.is_err(), "`{input}` should be rejected"); + assert!( + result + .unwrap_err() + .to_string() + .contains("time offset cannot appear more than once"), + "`{input}` should report a repeated time offset" + ); + } + + // A bare numeric offset is not an item on its own (the grammar requires + // a named zone), so this is rejected by the parser rather than by the + // duplicate check. GNU rejects it too. + assert!(parse(&mut "j +05:00").is_err()); + } } diff --git a/src/items/offset.rs b/src/items/offset.rs index d47d7f3..d432f2e 100644 --- a/src/items/offset.rs +++ b/src/items/offset.rs @@ -168,10 +168,30 @@ impl Display for Offset { } } +/// I'm assuming there are no timezone abbreviations with more +/// than 6 charactres +const MAX_TZ_SIZE: usize = 6; + pub(super) fn parse(input: &mut &str) -> ModalResult { timezone_name_offset.parse_next(input) } +/// Parse the military timezone letter `j`. +/// +/// Every other military letter denotes a fixed offset from UTC, but GNU `date` +/// defines `j` as local time, which cannot be expressed as an [`Offset`] at +/// all. It is therefore reported to the builder as its own item. +/// +/// The whole alphabetic word is consumed before comparing, so `jan` is left to +/// the date parser rather than being read as `j` followed by `an`, and `jj` is +/// rejected rather than matching a `j` prefix. +pub(super) fn parse_local(input: &mut &str) -> ModalResult<()> { + s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha)) + .verify(|word: &str| word.eq_ignore_ascii_case("j")) + .void() + .parse_next(input) +} + /// Parse a timezone starting with `+` or `-`. pub(super) fn timezone_offset(input: &mut &str) -> ModalResult { // Strings like "+8 years" are ambiguous, they can either be parsed as a @@ -188,9 +208,6 @@ pub(super) fn timezone_offset(input: &mut &str) -> ModalResult { /// Parse a timezone by name, with an optional numeric offset appended. fn timezone_name_offset(input: &mut &str) -> ModalResult { - /// I'm assuming there are no timezone abbreviations with more - /// than 6 charactres - const MAX_TZ_SIZE: usize = 6; let nextword = s(take_while(1..=MAX_TZ_SIZE, AsChar::is_alpha)).parse_next(input)?; let tz = timezone_name_to_offset(nextword)?; @@ -494,4 +511,31 @@ mod tests { assert_eq!(off(true, 5, 30).total_seconds(), -19_800); assert_eq!(off(false, 24, 0).total_seconds(), 86_400); } + /// `j` is the one military letter that is not an offset, so it is parsed + /// separately. The whole alphabetic word must be consumed before matching, + /// otherwise `jan` would be read as `j` followed by `an`. + #[test] + fn military_letter_j_local() { + for input in ["j", "J", " j"] { + let mut s = input; + assert!( + parse_local(&mut s).is_ok(), + "`{input}` should parse as local time" + ); + assert!(s.is_empty(), "`{input}` should be fully consumed"); + } + + for input in ["jj", "jan", "jst", "z", "a", ""] { + let mut s = input; + assert!( + parse_local(&mut s).is_err(), + "`{input}` should not parse as local time" + ); + } + + // `j` is absent from the offset table, so the offset parser must not + // claim it. + let mut s = "j"; + assert!(timezone_name_offset(&mut s).is_err()); + } } diff --git a/tests/date.rs b/tests/date.rs index af83322..c7d2978 100644 --- a/tests/date.rs +++ b/tests/date.rs @@ -325,3 +325,74 @@ fn test_utc_keyword_plus_relative_seconds_across_dst() { .expect_in_range(); assert_eq!(parsed.timestamp().as_second(), seconds); } + +// The military time zone letter `j`. +// +// Every other military letter (`a`-`i`, `k`-`y`, and `z` for UTC) denotes a +// fixed offset from UTC. GNU `date` defines `j` as *local* time instead, so it +// follows the base zone's DST rules rather than pinning an offset: +// +// $ TZ=America/New_York date -d '8j' # 2026-08-25 08:00 -0400 +// $ TZ=America/New_York date -d '2026-01-01 j' # 2026-01-01 00:00 -0500 +// $ TZ=America/New_York date -d '8z' # 2026-08-25 04:00 -0400 +// +// Verified against GNU coreutils 9.4. +#[rstest] +// Winter: New York is on EST (UTC-5). +#[case::est("2026-01-15 12:00:00", -5 * 3600)] +// Summer: the same zone is on EDT (UTC-4). A fixed offset could not do this. +#[case::edt("2026-07-15 12:00:00", -4 * 3600)] +fn test_military_j_is_local_time(#[case] base: &str, #[case] expected_offset: i32) { + let base = base + .parse::() + .unwrap() + .to_zoned(TimeZone::get("America/New_York").unwrap()) + .unwrap(); + + let parsed = parse_datetime::parse_datetime_at_date(base.clone(), "8j") + .unwrap() + .expect_in_range(); + + assert_eq!(parsed.hour(), 8, "`8j` should be 08:00 local"); + assert_eq!( + parsed.offset().seconds(), + expected_offset, + "`8j` should take the base zone's offset" + ); + + // `z` is UTC, so it must *not* follow the base zone. This is what + // distinguishes `j` from every other military letter. + let utc = parse_datetime::parse_datetime_at_date(base, "8z") + .unwrap() + .expect_in_range(); + assert_eq!(utc.offset().seconds(), 0, "`8z` should be UTC"); +} + +#[rstest] +#[case::bare("j")] +#[case::attached("8j")] +#[case::uppercase("8J")] +#[case::spaced("8 j")] +#[case::leading("j 8")] +fn test_military_j_accepted(#[case] input: &str) { + assert!( + parse_datetime::parse_datetime(input).is_ok(), + "`{input}` should parse" + ); +} + +#[rstest] +// `j` is a time zone item, so a second one is a repeated zone, as in GNU. +#[case::j_then_utc("8j utc")] +#[case::utc_then_j("8 utc j")] +#[case::j_twice("8 j j")] +#[case::j_then_numeric("j +05:00")] +// The whole alphabetic word is matched, so `j` never steals a prefix. +#[case::doubled_letter("jj")] +#[case::doubled_letter_after_number("8 jj")] +fn test_military_j_rejected(#[case] input: &str) { + assert!( + parse_datetime::parse_datetime(input).is_err(), + "`{input}` should be rejected, as GNU date does" + ); +}