// Ion timestamps.
//
// A timestamp is a moment in time with a precision (year, month, day, minute,
// or second), an optional fractional-second part, and — for values that carry
// a time — a local offset that may be known or explicitly unknown (`-00:00`).
//
// Timestamps are modelled field-by-field: an absent component is `None`, so
// the precision is derived from which components are present rather than kept
// as a separate sentinel.
///|
/// The precision to which an Ion timestamp is specified.
pub(all) enum TimestampPrecision {
Year
Month
Day
Minute
Second
} derive(Eq, Hash, Debug)
///|
/// The local offset of an Ion timestamp.
pub(all) enum TimestampOffset {
/// A known offset from UTC, in minutes east of UTC. `Z` and `+00:00` are
/// both `Known(0)`.
Known(Int32)
/// The "unknown local offset" written `-00:00`.
Unknown
} derive(Eq, Hash, Debug)
///|
/// An Ion `timestamp` value.
pub struct Timestamp {
year : Int32
month : Int32?
day : Int32?
hour : Int32?
minute : Int32?
second : Int32?
fraction : Decimal?
offset : TimestampOffset?
} derive(Debug)
///|
/// Build a timestamp from its components. Components left as `None` reduce the
/// precision; `fraction` requires `second`, and `offset` is required for any
/// value that carries a time.
#as_free_fn(timestamp)
#alias(new, deprecated="Use `@ion.timestamp(...)` instead")
pub fn Timestamp::Timestamp(
year~ : Int32,
month~ : Int32? = None,
day~ : Int32? = None,
hour~ : Int32? = None,
minute~ : Int32? = None,
second~ : Int32? = None,
fraction~ : Decimal? = None,
offset~ : TimestampOffset? = None,
) -> Timestamp {
{ year, month, day, hour, minute, second, fraction, offset, }
}
///|
/// Check that the components describe a real moment: the year is 1 to 9999,
/// each component is in range and the day exists in its month, an hour comes
/// with a minute, and a fractional second is at least zero and less than one.
pub fn Timestamp::validate(self : Timestamp) -> Unit raise IonError {
if self.year < 1 || self.year > 9999 {
raise DataModel("timestamp year is out of range")
}
if self.month is Some(month) && (month < 1 || month > 12) {
raise DataModel("timestamp month is out of range")
}
if self.day is Some(day) {
let limit = match self.month {
Some(month) => days_in_month(self.year, month)
None => 31
}
if day < 1 || day > limit {
raise DataModel("timestamp day is out of range for its month")
}
}
if self.hour is Some(hour) {
if hour < 0 || hour > 23 {
raise DataModel("timestamp hour is out of range")
}
if self.minute is None {
raise DataModel("a timestamp with an hour must have a minute")
}
}
if self.minute is Some(minute) && (minute < 0 || minute > 59) {
raise DataModel("timestamp minute is out of range")
}
if self.second is Some(second) && (second < 0 || second > 59) {
raise DataModel("timestamp second is out of range")
}
if self.fraction is Some(fraction) && !is_proper_fraction(fraction) {
raise DataModel(
"a timestamp's fractional second must be at least 0 and less than 1",
)
}
}
///|
/// Whether a decimal is at least zero and less than one. Zero of either sign
/// is; otherwise the coefficient must be positive and have no more digits than
/// the exponent moves the decimal point left. Comparing digit counts avoids
/// building a power of ten for a large exponent.
fn is_proper_fraction(fraction : Decimal) -> Bool {
if fraction.is_zero() {
return true
}
if fraction.is_negative() || fraction.exponent() >= 0 {
return false
}
fraction.coefficient().to_string().length() <= -fraction.exponent()
}
///|
/// The year component.
pub fn Timestamp::year(self : Timestamp) -> Int32 {
self.year
}
///|
/// The month component (`1`-`12`), when present.
pub fn Timestamp::month(self : Timestamp) -> Int32? {
self.month
}
///|
/// The day component (`1`-`31`), when present.
pub fn Timestamp::day(self : Timestamp) -> Int32? {
self.day
}
///|
/// The hour component (`0`-`23`), when present.
pub fn Timestamp::hour(self : Timestamp) -> Int32? {
self.hour
}
///|
/// The minute component (`0`-`59`), when present.
pub fn Timestamp::minute(self : Timestamp) -> Int32? {
self.minute
}
///|
/// The second component (`0`-`59`), when present.
pub fn Timestamp::second(self : Timestamp) -> Int32? {
self.second
}
///|
/// The fractional-second component, when present.
pub fn Timestamp::fraction(self : Timestamp) -> Decimal? {
self.fraction
}
///|
/// The local offset, when present.
pub fn Timestamp::offset(self : Timestamp) -> TimestampOffset? {
self.offset
}
///|
/// The precision implied by the components that are present.
pub fn Timestamp::precision(self : Timestamp) -> TimestampPrecision {
if self.second is Some(_) {
TimestampPrecision::Second
} else if self.minute is Some(_) {
TimestampPrecision::Minute
} else if self.day is Some(_) {
TimestampPrecision::Day
} else if self.month is Some(_) {
TimestampPrecision::Month
} else {
TimestampPrecision::Year
}
}
///|
/// Left-pad an integer with zeros to `width` digits.
fn pad_int(value : Int32, width : Int32) -> String {
let text = value.to_string()
if text.length() >= width {
return text
}
let sb = StringBuilder()
for _ in 0..<(width - text.length()) {
sb.write_string("0")
}
sb.write_string(text)
sb.to_string()
}
///|
/// Render a local offset as Ion text.
fn offset_to_text(offset : TimestampOffset) -> String {
match offset {
TimestampOffset::Known(0) => "Z"
TimestampOffset::Known(minutes) => {
let sign = if minutes < 0 { "-" } else { "+" }
let magnitude = if minutes < 0 { -minutes } else { minutes }
"\{sign}\{pad_int(magnitude / 60, 2)}:\{pad_int(magnitude % 60, 2)}"
}
TimestampOffset::Unknown => "-00:00"
}
}
///|
/// Render the fractional seconds (`< 1`) of a decimal as a `.ddd` suffix,
/// preserving the number of digits kept by the source.
fn fraction_to_text(fraction : Decimal) -> String {
let magnitude = if fraction.coefficient().compare_int(0) < 0 {
fraction.coefficient().neg()
} else {
fraction.coefficient()
}
let digits = magnitude.to_string()
let width = if fraction.exponent() < 0 { -fraction.exponent() } else { 0 }
let padded = if digits.length() >= width {
digits
} else {
let sb = StringBuilder()
for _ in 0..<(width - digits.length()) {
sb.write_string("0")
}
sb.write_string(digits)
sb.to_string()
}
"." + padded
}
///|
/// Render this timestamp as Ion text.
pub fn Timestamp::to_ion_string(self : Timestamp) -> String {
let sb = StringBuilder()
sb.write_string(pad_int(self.year, 4))
match self.precision() {
TimestampPrecision::Year => sb.write_string("T")
TimestampPrecision::Month => {
sb.write_string("-" + pad_int(self.month.unwrap(), 2))
sb.write_string("T")
}
TimestampPrecision::Day => {
sb.write_string("-" + pad_int(self.month.unwrap(), 2))
sb.write_string("-" + pad_int(self.day.unwrap(), 2))
}
TimestampPrecision::Minute => {
sb.write_string("-" + pad_int(self.month.unwrap(), 2))
sb.write_string("-" + pad_int(self.day.unwrap(), 2))
sb.write_string("T")
sb.write_string(pad_int(self.hour.unwrap(), 2))
sb.write_string(":" + pad_int(self.minute.unwrap(), 2))
sb.write_string(offset_to_text(self.offset.unwrap()))
}
TimestampPrecision::Second => {
sb.write_string("-" + pad_int(self.month.unwrap(), 2))
sb.write_string("-" + pad_int(self.day.unwrap(), 2))
sb.write_string("T")
sb.write_string(pad_int(self.hour.unwrap(), 2))
sb.write_string(":" + pad_int(self.minute.unwrap(), 2))
sb.write_string(":" + pad_int(self.second.unwrap(), 2))
match self.fraction {
Some(fraction) => sb.write_string(fraction_to_text(fraction))
None => ()
}
sb.write_string(offset_to_text(self.offset.unwrap()))
}
}
sb.to_string()
}
///|
/// Read exactly `count` ASCII digits at `start`, returning the value and the
/// index just past them.
fn read_fixed_uint(
text : StringView,
start : Int32,
count : Int32,
) -> (Int32, Int32) raise IonError {
let n = text.length()
if start + count > n {
raise IonError::Syntax("truncated timestamp")
}
let sb = StringBuilder()
for index in start..<(start + count) {
if !is_digit(text[index]) {
raise IonError::Syntax("expected a digit in timestamp")
}
sb.write_view(char_view(text, index))
}
let value : Int32 = @string.parse_int(sb.to_string()) catch {
_ => raise IonError::Syntax("invalid number in timestamp")
}
(value, start + count)
}
///|
/// Read the fractional-seconds digits after a `.`.
fn read_fraction(
text : StringView,
start : Int32,
) -> (Decimal, Int32) raise IonError {
let n = text.length()
let sb = StringBuilder()
let mut i = start
while i < n && is_digit(text[i]) {
sb.write_view(char_view(text, i))
i += 1
}
let digits = sb.to_string()
if digits.is_empty() {
raise IonError::Syntax(
"timestamp fractional seconds need at least one digit",
)
}
let coefficient = parse_digits_to_bigint(digits, false)
(Decimal::Decimal(coefficient, -digits.length()), i)
}
///|
/// Read a local offset: `Z`, `+HH:MM`, `-HH:MM`, or the unknown-offset form
/// `-00:00`.
fn read_offset(
text : StringView,
start : Int32,
) -> (TimestampOffset, Int32) raise IonError {
let n = text.length()
let mut i = start
if i < n && text[i] == 'Z' {
return (TimestampOffset::Known(0), i + 1)
}
if i >= n || (text[i] != '+' && text[i] != '-') {
raise IonError::Syntax("timestamp with a time must have a local offset")
}
let negative = text[i] == '-'
i += 1
let (hours, next) = read_fixed_uint(text, i, 2)
i = next
if i >= n || text[i] != ':' {
raise IonError::Syntax("expected ':' in timestamp offset")
}
i += 1
let (minutes, next) = read_fixed_uint(text, i, 2)
i = next
if hours > 23 || minutes > 59 {
raise IonError::DataModel("timestamp offset is out of range")
}
let total = hours * 60 + minutes
if negative && total == 0 {
(TimestampOffset::Unknown, i)
} else if negative {
(TimestampOffset::Known(-total), i)
} else {
(TimestampOffset::Known(total), i)
}
}
///|
/// Parse an Ion timestamp. For example `2007T`, `2007-02-23`,
/// `2007-02-23T12:14Z`, or `2007-02-23T12:14:33.079-08:00`.
pub fn Timestamp::parse(text : StringView) -> Timestamp raise IonError {
let n = text.length()
if n < 5 {
raise IonError::Syntax("timestamp is too short")
}
let (year, next) = read_fixed_uint(text, 0, 4)
if year < 1 {
raise IonError::DataModel("timestamp year must be at least 1")
}
let mut i = next
if i == n {
raise IonError::Syntax(
"timestamp must end with 'T' unless it has day precision",
)
}
if text[i] == 'T' {
if i + 1 != n {
raise IonError::Syntax(
"unexpected characters after a year-precision timestamp",
)
}
return Timestamp::Timestamp(year~)
}
if text[i] != '-' {
raise IonError::Syntax("expected '-' in timestamp")
}
i += 1
let (month, next) = read_fixed_uint(text, i, 2)
i = next
if month < 1 || month > 12 {
raise IonError::DataModel("timestamp month is out of range")
}
if i == n {
raise IonError::Syntax("month-precision timestamp must end with 'T'")
}
if text[i] == 'T' {
if i + 1 != n {
raise IonError::Syntax(
"unexpected characters after a month-precision timestamp",
)
}
return Timestamp::Timestamp(year~, month=Some(month))
}
if text[i] != '-' {
raise IonError::Syntax("expected '-' in timestamp")
}
i += 1
let (day, next) = read_fixed_uint(text, i, 2)
i = next
if day < 1 || day > days_in_month(year, month) {
raise IonError::DataModel("timestamp day is out of range for its month")
}
if i == n {
return Timestamp::Timestamp(year~, month=Some(month), day=Some(day))
}
if text[i] != 'T' {
raise IonError::Syntax("expected 'T' in timestamp")
}
i += 1
if i == n {
return Timestamp::Timestamp(year~, month=Some(month), day=Some(day))
}
let (hour, next) = read_fixed_uint(text, i, 2)
i = next
if hour > 23 {
raise IonError::DataModel("timestamp hour is out of range")
}
if i >= n || text[i] != ':' {
raise IonError::Syntax("expected ':' in timestamp time")
}
i += 1
let (minute, next) = read_fixed_uint(text, i, 2)
i = next
if minute > 59 {
raise IonError::DataModel("timestamp minute is out of range")
}
let mut second : Int32? = None
let mut fraction : Decimal? = None
if i < n && text[i] == ':' {
i += 1
let (value, next) = read_fixed_uint(text, i, 2)
i = next
if value > 59 {
raise IonError::DataModel("timestamp second is out of range")
}
second = Some(value)
if i < n && text[i] == '.' {
i += 1
let (value, next) = read_fraction(text, i)
i = next
fraction = Some(value)
}
}
let (offset, next) = read_offset(text, i)
i = next
if i != n {
raise IonError::Syntax("unexpected characters after timestamp")
}
Timestamp::Timestamp(
year~,
month=Some(month),
day=Some(day),
hour=Some(hour),
minute=Some(minute),
second~,
fraction~,
offset=Some(offset),
)
}