Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion src/items/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ pub(crate) struct DateTimeBuilder {
time: Option<time::Time>,
weekday: Option<weekday::Weekday>,
offset: Option<offset::Offset>,
/// 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<jiff::tz::TimeZone>,
relative: Vec<relative::Relative>,
}
Expand Down Expand Up @@ -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");
Expand All @@ -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");
}

Expand All @@ -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");
Expand All @@ -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<Self, &'static str> {
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<Self, &'static str> {
if self.timestamp.is_some() {
return Err("timestamp cannot be combined with other date/time items");
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -362,6 +387,7 @@ impl DateTimeBuilder {
time,
weekday,
offset,
local_zone,
timezone,
relative,
} = self;
Expand All @@ -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 {
Expand Down Expand Up @@ -528,6 +555,7 @@ impl TryFrom<Vec<Item>> 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)?,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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"
Expand Down
31 changes: 31 additions & 0 deletions src/items/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand Down Expand Up @@ -260,6 +264,7 @@ fn parse_item(input: &mut &str) -> ModalResult<Item> {
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),
)),
)
Expand Down Expand Up @@ -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());
}
}
50 changes: 47 additions & 3 deletions src/items/offset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Offset> {
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<Offset> {
// Strings like "+8 years" are ambiguous, they can either be parsed as a
Expand All @@ -188,9 +208,6 @@ pub(super) fn timezone_offset(input: &mut &str) -> ModalResult<Offset> {

/// Parse a timezone by name, with an optional numeric offset appended.
fn timezone_name_offset(input: &mut &str) -> ModalResult<Offset> {
/// 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)?;

Expand Down Expand Up @@ -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());
}
}
71 changes: 71 additions & 0 deletions tests/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<DateTime>()
.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"
);
}