diff --git a/xee-interpreter/src/atomic/cast_datetime.rs b/xee-interpreter/src/atomic/cast_datetime.rs index 362b378a6..b9e957c63 100644 --- a/xee-interpreter/src/atomic/cast_datetime.rs +++ b/xee-interpreter/src/atomic/cast_datetime.rs @@ -62,7 +62,8 @@ impl atomic::Atomic { let mut s = String::new(); let offset = date_time.offset; let date_time = date_time.date_time; - s.push_str(&date_time.format("%Y-%m-%dT%H:%M:%S").to_string()); + Self::push_canonical_year(&mut s, date_time.year()); + s.push_str(&date_time.format("-%m-%dT%H:%M:%S").to_string()); let millis = date_time.and_utc().timestamp_subsec_millis(); Self::push_millis(&mut s, millis); if let Some(offset) = offset { @@ -75,7 +76,8 @@ impl atomic::Atomic { date_time: &chrono::DateTime, ) -> String { let mut s = String::new(); - s.push_str(&date_time.format("%Y-%m-%dT%H:%M:%S").to_string()); + Self::push_canonical_year(&mut s, date_time.year()); + s.push_str(&date_time.format("-%m-%dT%H:%M:%S").to_string()); let millis = date_time.timestamp_subsec_millis(); Self::push_millis(&mut s, millis); let offset = date_time.offset(); @@ -100,7 +102,8 @@ impl atomic::Atomic { let mut s = String::new(); let offset = date.offset; let date = date.date; - s.push_str(&date.format("%Y-%m-%d").to_string()); + Self::push_canonical_year(&mut s, date.year()); + s.push_str(&date.format("-%m-%d").to_string()); if let Some(offset) = offset { Self::push_canonical_time_zone_offset(&mut s, &offset); } @@ -206,6 +209,16 @@ impl atomic::Atomic { } } + fn push_canonical_year(s: &mut String, year: i32) { + // chrono's %Y writes years above 9999 with a leading '+', which is + // not part of the XSD lexical space, so write the year ourselves + if year >= 0 { + s.push_str(&format!("{:04}", year)); + } else { + s.push_str(&format!("-{:04}", year.abs())); + } + } + fn push_canonical_time_zone_offset(s: &mut String, offset: &chrono::FixedOffset) { let seconds = offset.local_minus_utc(); if seconds == 0 { diff --git a/xee-xpath/tests/canonical_years.rs b/xee-xpath/tests/canonical_years.rs new file mode 100644 index 000000000..480416468 --- /dev/null +++ b/xee-xpath/tests/canonical_years.rs @@ -0,0 +1,58 @@ +// Canonical serialization of years outside 1..=9999. +// +// chrono's %Y formats years above 9999 with a leading '+', which is not +// valid in XSD lexical space, so the engine's own output failed to cast +// back: xs:dateTime(string(xs:dateTime("10000-01-01T00:00:00Z"))) raised +// FORG0001. + +use xee_xpath::{error, Documents, Queries, Query}; + +fn eval(xpath: &str) -> error::Result { + let mut documents = Documents::new(); + let queries = Queries::default(); + let q = queries.one(xpath, |_, item| Ok(item.try_into_value::()?))?; + q.execute_build_context(&mut documents, |_| {}) +} + +#[test] +fn test_five_digit_years_serialize_without_plus() { + assert_eq!( + eval(r#"string(xs:dateTime("10000-01-01T00:00:00Z"))"#).unwrap(), + "10000-01-01T00:00:00Z" + ); + assert_eq!( + eval(r#"string(xs:dateTimeStamp("10000-01-01T00:00:00Z"))"#).unwrap(), + "10000-01-01T00:00:00Z" + ); + assert_eq!( + eval(r#"string(xs:date("10000-01-01"))"#).unwrap(), + "10000-01-01" + ); +} + +#[test] +fn test_five_digit_year_round_trips_through_the_engine() { + assert_eq!( + eval(r#"string(xs:dateTime(string(xs:dateTime("10000-01-01T00:00:00Z"))))"#).unwrap(), + "10000-01-01T00:00:00Z" + ); + assert_eq!( + eval(r#"string(xs:date(string(xs:date("10000-01-01"))))"#).unwrap(), + "10000-01-01" + ); +} + +#[test] +fn test_other_year_forms_stay_canonical() { + // negative years and year zero were already canonical + assert_eq!( + eval(r#"string(xs:dateTime("-0005-03-01T12:00:00"))"#).unwrap(), + "-0005-03-01T12:00:00" + ); + assert_eq!( + eval(r#"string(xs:date("0000-01-01"))"#).unwrap(), + "0000-01-01" + ); + // the gregorian types formatted years manually all along + assert_eq!(eval(r#"string(xs:gYear("10000"))"#).unwrap(), "10000"); +}