refactor: replace floating-point seconds with precise integer representation

Replace f64-based timestamp and second parsing with structured
types using separate second and nanosecond fields to eliminate
floating-point precision issues.
This commit is contained in:
yuankunzhang
2025-08-12 20:18:15 +08:00
parent c86b137e6f
commit 5c686bcfdf
6 changed files with 226 additions and 58 deletions
+13 -12
View File
@@ -3,7 +3,7 @@
use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, TimeZone, Timelike};
use super::{date, relative, time, timezone, weekday, year};
use super::{date, epoch, relative, time, timezone, weekday, year};
/// The builder is used to construct a DateTime object from various components.
/// The parser creates a `DateTimeBuilder` object with the parsed components,
@@ -13,7 +13,7 @@ use super::{date, relative, time, timezone, weekday, year};
#[derive(Debug, Default)]
pub(crate) struct DateTimeBuilder {
base: Option<DateTime<FixedOffset>>,
timestamp: Option<f64>,
timestamp: Option<epoch::Timestamp>,
date: Option<date::Date>,
time: Option<time::Time>,
weekday: Option<weekday::Weekday>,
@@ -35,7 +35,7 @@ impl DateTimeBuilder {
/// Sets a timestamp value. Timestamp values are exclusive to other date/time
/// items (date, time, weekday, timezone, relative adjustments).
pub(super) fn set_timestamp(mut self, ts: f64) -> Result<Self, &'static str> {
pub(super) fn set_timestamp(mut self, ts: epoch::Timestamp) -> Result<Self, &'static str> {
if self.timestamp.is_some() {
return Err("timestamp cannot appear more than once");
} else if self.date.is_some()
@@ -148,15 +148,15 @@ impl DateTimeBuilder {
self.set_time(time)
}
fn build_from_timestamp(ts: f64, tz: &FixedOffset) -> Option<DateTime<FixedOffset>> {
// TODO: How to make the fract -> nanosecond conversion more precise?
// Maybe considering using the
// [rust_decimal](https://crates.io/crates/rust_decimal) crate?
match chrono::Utc.timestamp_opt(ts as i64, (ts.fract() * 10f64.powi(9)).round() as u32) {
fn build_from_timestamp(
ts: epoch::Timestamp,
tz: &FixedOffset,
) -> Option<DateTime<FixedOffset>> {
match chrono::Utc.timestamp_opt(ts.second, ts.nanosecond) {
chrono::MappedLocalTime::Single(t) => Some(t.with_timezone(tz)),
chrono::MappedLocalTime::Ambiguous(earliest, _latest) => {
// TODO: When there is a fold in the local time, which value
// do we choose? For now, we use the earliest one.
// When there is a fold in the local time, we use the earliest
// one.
Some(earliest.with_timezone(tz))
}
chrono::MappedLocalTime::None => None, // Invalid timestamp
@@ -210,6 +210,7 @@ impl DateTimeBuilder {
hour,
minute,
second,
nanosecond,
ref offset,
}) = self.time
{
@@ -224,8 +225,8 @@ impl DateTimeBuilder {
dt.day(),
hour,
minute,
second as u32,
(second.fract() * 10f64.powi(9)).round() as u32,
second,
nanosecond,
offset,
)?;
}
+2 -1
View File
@@ -55,7 +55,8 @@ mod tests {
time: Time {
hour: 10,
minute: 10,
second: 55.0,
second: 55,
nanosecond: 0,
offset: None,
},
});
+125 -13
View File
@@ -15,35 +15,147 @@
//! > @1483228800 represents 2017-01-01 00:00:00 UTC, and there is no way to
//! > represent the intervening leap second 2016-12-31 23:59:60 UTC.
use winnow::{combinator::preceded, ModalResult, Parser};
use winnow::{
ascii::digit1,
combinator::{opt, preceded},
token::one_of,
ModalResult, Parser,
};
use super::primitive::{float, s};
use super::primitive::{dec_uint, s};
/// Parse a timestamp in the form of `@1234567890`.
pub fn parse(input: &mut &str) -> ModalResult<f64> {
s(preceded("@", float)).parse_next(input)
/// Represents a timestamp with nanosecond accuracy.
///
/// # Invariants
///
/// - `nanosecond` is always in the range of `0..1_000_000_000`.
/// - Negative timestamps are represented by a negative `second` value and a
/// positive `nanosecond` value.
#[derive(Debug, PartialEq)]
pub(crate) struct Timestamp {
pub(crate) second: i64,
pub(crate) nanosecond: u32,
}
/// Parse a timestamp in the form of `1234567890` or `-1234567890.12345` or
/// `1234567890,12345`.
pub(crate) fn parse(input: &mut &str) -> ModalResult<Timestamp> {
(s("@"), opt(s(one_of(['-', '+']))), sec_and_nsec)
.verify_map(|(_, sign, (sec, nsec))| {
let sec = i64::try_from(sec).ok()?;
let (second, nanosecond) = match (sign, nsec) {
(Some('-'), 0) => (-sec, 0),
// Truncate towards minus infinity.
(Some('-'), _) => ((-sec).checked_sub(1)?, 1_000_000_000 - nsec),
_ => (sec, nsec),
};
Some(Timestamp { second, nanosecond })
})
.parse_next(input)
}
/// Parse a second value in the form of `1234567890` or `1234567890.12345` or
/// `1234567890,12345`.
///
/// The first part represents whole seconds. The optional second part represents
/// fractional seconds, parsed as a nanosecond value from up to 9 digits
/// (padded with zeros on the right if fewer digits are present). If the second
/// part is omitted, it defaults to 0 nanoseconds.
pub(super) fn sec_and_nsec(input: &mut &str) -> ModalResult<(u64, u32)> {
(s(dec_uint), opt(preceded(one_of(['.', ',']), digit1)))
.verify_map(|(sec, opt_nsec_str)| match opt_nsec_str {
Some(nsec_str) if nsec_str.len() >= 9 => Some((sec, nsec_str[..9].parse().ok()?)),
Some(nsec_str) => {
let multiplier = 10_u32.pow(9 - nsec_str.len() as u32);
Some((sec, nsec_str.parse::<u32>().ok()?.checked_mul(multiplier)?))
}
None => Some((sec, 0)),
})
.parse_next(input)
}
#[cfg(test)]
mod tests {
use super::parse;
use super::*;
fn float_eq(a: f64, b: f64) -> bool {
(a - b).abs() < f64::EPSILON
#[test]
fn sec_and_nsec_test() {
let mut input = "1234567890";
assert_eq!(sec_and_nsec(&mut input).unwrap(), (1234567890, 0));
let mut input = "1234567890.12345";
assert_eq!(sec_and_nsec(&mut input).unwrap(), (1234567890, 123450000));
let mut input = "1234567890,12345";
assert_eq!(sec_and_nsec(&mut input).unwrap(), (1234567890, 123450000));
let mut input = "1234567890.1234567890123";
assert_eq!(sec_and_nsec(&mut input).unwrap(), (1234567890, 123456789));
}
#[test]
fn float() {
fn timestamp() {
let mut input = "@1234567890";
assert!(float_eq(parse(&mut input).unwrap(), 1234567890.0));
assert_eq!(
parse(&mut input).unwrap(),
Timestamp {
second: 1234567890,
nanosecond: 0,
}
);
let mut input = "@ 1234567890";
assert_eq!(
parse(&mut input).unwrap(),
Timestamp {
second: 1234567890,
nanosecond: 0,
}
);
let mut input = "@ -1234567890";
assert_eq!(
parse(&mut input).unwrap(),
Timestamp {
second: -1234567890,
nanosecond: 0,
}
);
let mut input = "@ - 1234567890";
assert_eq!(
parse(&mut input).unwrap(),
Timestamp {
second: -1234567890,
nanosecond: 0,
}
);
let mut input = "@1234567890.12345";
assert!(float_eq(parse(&mut input).unwrap(), 1234567890.12345));
assert_eq!(
parse(&mut input).unwrap(),
Timestamp {
second: 1234567890,
nanosecond: 123450000,
}
);
let mut input = "@1234567890,12345";
assert!(float_eq(parse(&mut input).unwrap(), 1234567890.12345));
assert_eq!(
parse(&mut input).unwrap(),
Timestamp {
second: 1234567890,
nanosecond: 123450000,
}
);
let mut input = "@-1234567890.12345";
assert_eq!(parse(&mut input).unwrap(), -1234567890.12345);
assert_eq!(
parse(&mut input).unwrap(),
Timestamp {
second: -1234567891,
nanosecond: 876550000,
}
);
}
}
+1 -1
View File
@@ -58,7 +58,7 @@ use crate::ParseDateTimeError;
#[derive(PartialEq, Debug)]
pub(crate) enum Item {
Timestamp(f64),
Timestamp(epoch::Timestamp),
DateTime(combined::DateTime),
Date(date::Date),
Time(time::Time),
+5 -2
View File
@@ -3,8 +3,10 @@
//! Primitive combinators.
use std::str::FromStr;
use winnow::{
ascii::{digit1, multispace0},
ascii::{digit1, multispace0, Uint},
combinator::{alt, delimited, not, opt, peek, preceded, repeat, separated},
error::{ContextError, ParserError, StrContext, StrContextValue},
stream::AsChar,
@@ -100,8 +102,9 @@ where
///
/// See the rationale for `dec_int` for why we don't use
/// `winnow::ascii::dec_uint`.
pub(super) fn dec_uint<'a, E>(input: &mut &'a str) -> winnow::Result<u32, E>
pub(super) fn dec_uint<'a, O, E>(input: &mut &'a str) -> winnow::Result<O, E>
where
O: Uint + FromStr,
E: ParserError<&'a str>,
{
digit1
+80 -29
View File
@@ -40,11 +40,12 @@
use winnow::{
combinator::{alt, opt, preceded},
error::ErrMode,
seq, ModalResult, Parser,
ModalResult, Parser,
};
use super::{
primitive::{colon, ctx_err, dec_uint, float, s},
epoch::sec_and_nsec,
primitive::{colon, ctx_err, dec_uint, s},
timezone::{timezone_num, Offset},
};
@@ -52,7 +53,8 @@ use super::{
pub(crate) struct Time {
pub hour: u32,
pub minute: u32,
pub second: f64,
pub second: u32,
pub nanosecond: u32,
pub offset: Option<Offset>,
}
@@ -74,16 +76,24 @@ pub(super) fn iso(input: &mut &str) -> ModalResult<Time> {
(hour24, timezone_num).map(|(hour, offset)| Time {
hour,
minute: 0,
second: 0.0,
second: 0,
nanosecond: 0,
offset: Some(offset),
}),
seq!(Time {
hour: hour24,
_: colon,
minute: minute,
second: opt(preceded(colon, second)).map(|s| s.unwrap_or(0.0)),
offset: opt(timezone_num)
}),
(
hour24,
colon,
minute,
opt(preceded(colon, second)),
opt(timezone_num),
)
.map(|(hour, _, minute, sec_nsec, offset)| Time {
hour,
minute,
second: sec_nsec.map_or(0, |(s, _)| s),
nanosecond: sec_nsec.map_or(0, |(_, ns)| ns),
offset,
}),
))
.parse_next(input)
}
@@ -92,7 +102,7 @@ pub(super) fn iso(input: &mut &str) -> ModalResult<Time> {
///
/// The hours are restricted to 12 or lower in this format
fn am_pm_time(input: &mut &str) -> ModalResult<Time> {
let (h, m, s, meridiem) = (
let (h, m, sec_nsec, meridiem) = (
hour12,
opt(preceded(colon, minute)),
opt(preceded(colon, second)),
@@ -118,7 +128,8 @@ fn am_pm_time(input: &mut &str) -> ModalResult<Time> {
Ok(Time {
hour: h,
minute: m.unwrap_or(0),
second: s.unwrap_or(0.0),
second: sec_nsec.map_or(0, |(s, _)| s),
nanosecond: sec_nsec.map_or(0, |(_, ns)| ns),
offset: None,
})
}
@@ -139,14 +150,9 @@ pub(super) fn minute(input: &mut &str) -> ModalResult<u32> {
}
/// Parse a number of seconds (preceded by whitespace)
fn second(input: &mut &str) -> ModalResult<f64> {
s(float)
.verify(|x| *x < 60.0)
.map(|x| {
// Truncates the fractional part of seconds to 9 digits.
let factor = 10f64.powi(9);
(x * factor).trunc() / factor
})
fn second(input: &mut &str) -> ModalResult<(u32, u32)> {
sec_and_nsec
.verify_map(|(s, ns)| if s < 60 { Some((s as u32, ns)) } else { None })
.parse_next(input)
}
@@ -159,7 +165,8 @@ mod tests {
let reference = Time {
hour: 20,
minute: 2,
second: 0.0,
second: 0,
nanosecond: 0,
offset: None,
};
@@ -201,7 +208,8 @@ mod tests {
let reference = Time {
hour: 11,
minute: 0,
second: 0.0,
second: 0,
nanosecond: 0,
offset: None,
};
@@ -223,12 +231,50 @@ mod tests {
}
}
#[test]
fn nanoseconds() {
let reference = Time {
hour: 11,
minute: 0,
second: 0,
nanosecond: 123450000,
offset: None,
};
for mut s in ["11:00:00.12345", "11:00:00.12345am"] {
let old_s = s.to_owned();
assert_eq!(
parse(&mut s).ok(),
Some(reference.clone()),
"Format string: {old_s}"
);
}
let reference = Time {
hour: 11,
minute: 0,
second: 0,
nanosecond: 123456789,
offset: None,
};
for mut s in ["11:00:00.123456789", "11:00:00.1234567890123"] {
let old_s = s.to_owned();
assert_eq!(
parse(&mut s).ok(),
Some(reference.clone()),
"Format string: {old_s}"
);
}
}
#[test]
fn noon() {
let reference = Time {
hour: 12,
minute: 0,
second: 0.0,
second: 0,
nanosecond: 0,
offset: None,
};
@@ -254,7 +300,8 @@ mod tests {
let reference = Time {
hour: 0,
minute: 0,
second: 0.0,
second: 0,
nanosecond: 0,
offset: None,
};
@@ -273,7 +320,8 @@ mod tests {
let reference = Time {
hour: 1,
minute: 23,
second: 0.0,
second: 0,
nanosecond: 0,
offset: Some(Offset {
negative: false,
hours: 5,
@@ -303,7 +351,8 @@ mod tests {
let reference = Time {
hour: 3,
minute: 45,
second: 0.0,
second: 0,
nanosecond: 0,
offset: Some(Offset {
negative: false,
hours: 5,
@@ -335,7 +384,8 @@ mod tests {
let reference = Time {
hour: 3,
minute: 45,
second: 0.0,
second: 0,
nanosecond: 0,
offset: Some(Offset {
negative: false,
hours: 0,
@@ -366,7 +416,8 @@ mod tests {
let reference = Time {
hour: 3,
minute: 45,
second: 0.0,
second: 0,
nanosecond: 0,
offset: Some(Offset {
negative: true,
hours: 5,