feat: implement pure number parsing

Implement GNU-compatible pure number interpretation that treats
pure number as year or time based on the current parsing context.
This commit is contained in:
yuankunzhang
2025-08-04 23:04:31 +08:00
parent 8fa8928a6a
commit 551985ef91
5 changed files with 158 additions and 51 deletions
+34 -19
View File
@@ -3,7 +3,7 @@
use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, TimeZone, Timelike};
use super::{date, relative, time, weekday};
use super::{date, relative, time, weekday, year};
/// The builder is used to construct a DateTime object from various components.
/// The parser creates a `DateTimeBuilder` object with the parsed components,
@@ -40,24 +40,6 @@ impl DateTimeBuilder {
Ok(self)
}
pub(super) fn set_year(mut self, year: u32) -> Result<Self, &'static str> {
if let Some(date) = self.date.as_mut() {
if date.year.is_some() {
Err("year cannot appear more than once")
} else {
date.year = Some(year);
Ok(self)
}
} else {
self.date = Some(date::Date {
day: 1,
month: 1,
year: Some(year),
});
Ok(self)
}
}
pub(super) fn set_date(mut self, date: date::Date) -> Result<Self, &'static str> {
if self.date.is_some() || self.timestamp.is_some() {
Err("date cannot appear more than once")
@@ -103,6 +85,39 @@ impl DateTimeBuilder {
self
}
/// Sets a pure number that can be interpreted as either a year or time
/// depending on the current state of the builder.
///
/// If a date is already set but lacks a year, the number is interpreted as
/// a year. Otherwise, it's interpreted as a time in HHMM, HMM, HH, or H
/// format.
pub(super) fn set_pure(mut self, pure: String) -> Result<Self, &'static str> {
if let Some(date) = self.date.as_mut() {
if date.year.is_none() {
date.year = Some(year::year_from_str(&pure)?);
return Ok(self);
}
}
let (mut hour_str, mut minute_str) = match pure.len() {
1..=2 => (pure.as_str(), "0"),
3..=4 => pure.split_at(pure.len() - 2),
_ => {
return Err("pure number must be 1-4 digits when interpreted as time");
}
};
let hour = time::hour24(&mut hour_str).map_err(|_| "invalid hour in pure number")?;
let minute = time::minute(&mut minute_str).map_err(|_| "invalid minute in pure number")?;
let time = time::Time {
hour,
minute,
..Default::default()
};
self.set_time(time)
}
pub(super) fn build(self) -> Option<DateTime<FixedOffset>> {
let base = self.base.unwrap_or_else(|| chrono::Local::now().into());
+77 -14
View File
@@ -33,6 +33,7 @@
mod combined;
mod date;
mod epoch;
mod pure;
mod relative;
mod time;
mod timezone;
@@ -59,13 +60,13 @@ use crate::ParseDateTimeError;
#[derive(PartialEq, Debug)]
pub(crate) enum Item {
Timestamp(f64),
Year(u32),
DateTime(combined::DateTime),
Date(date::Date),
Time(time::Time),
Weekday(weekday::Weekday),
Relative(relative::Relative),
TimeZone(time::Offset),
Pure(String),
}
/// Build a `DateTime<FixedOffset>` from a `DateTimeBuilder` and a base date.
@@ -97,7 +98,7 @@ pub(crate) fn at_local(
/// timestamp = "@" , float ;
///
/// items = item , { item } ;
/// item = datetime | date | time | relative | weekday | timezone | year ;
/// item = datetime | date | time | relative | weekday | timezone | pure ;
///
/// datetime = date , [ "t" | whitespace ] , iso_time ;
///
@@ -178,6 +179,8 @@ pub(crate) fn at_local(
///
/// timezone = named_zone , [ time_offset ] ;
///
/// pure = { digit }
///
/// optional_whitespace = { whitespace } ;
/// ```
pub(crate) fn parse(input: &mut &str) -> ModalResult<DateTimeBuilder> {
@@ -217,9 +220,6 @@ fn parse_items(input: &mut &str) -> ModalResult<DateTimeBuilder> {
.set_timestamp(ts)
.map_err(|e| expect_error(input, e))?;
}
Item::Year(year) => {
builder = builder.set_year(year).map_err(|e| expect_error(input, e))?;
}
Item::DateTime(dt) => {
builder = builder
.set_date(dt.date)
@@ -246,6 +246,9 @@ fn parse_items(input: &mut &str) -> ModalResult<DateTimeBuilder> {
Item::Relative(rel) => {
builder = builder.push_relative(rel);
}
Item::Pure(pure) => {
builder = builder.set_pure(pure).map_err(|e| expect_error(input, e))?;
}
},
Err(ErrMode::Backtrack(_)) => break,
Err(e) => return Err(e),
@@ -271,7 +274,7 @@ fn parse_item(input: &mut &str) -> ModalResult<Item> {
relative::parse.map(Item::Relative),
weekday::parse.map(Item::Weekday),
timezone::parse.map(Item::TimeZone),
year::parse.map(Item::Year),
pure::parse.map(Item::Pure),
)),
)
.parse_next(input)
@@ -290,7 +293,8 @@ fn expect_error(input: &mut &str, reason: &'static str) -> ErrMode<ContextError>
mod tests {
use super::{at_date, parse, DateTimeBuilder};
use chrono::{
DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc,
DateTime, Datelike, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike,
Utc,
};
fn at_utc(builder: DateTimeBuilder) -> DateTime<FixedOffset> {
@@ -386,13 +390,6 @@ mod tests {
.to_string()
.contains("time cannot appear more than once"));
let result = parse(&mut "2025-05-19 2024");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("year cannot appear more than once"));
let result = parse(&mut "2025-05-19 +00:00 +01:00");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unexpected input"));
@@ -415,6 +412,46 @@ mod tests {
let result = parse(&mut "2025-05-19 @1690466034");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unexpected input"));
// Pure number as year (too large).
let result = parse(&mut "jul 18 12:30 10000");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("year must be no greater than 9999"));
// Pure number as time (too long).
let result = parse(&mut "01:02 12345");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("pure number must be 1-4 digits when interpreted as time"));
// Pure number as time (repeated time).
let result = parse(&mut "01:02 1234");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("time cannot appear more than once"));
// Pure number as time (invalid hour).
let result = parse(&mut "jul 18 2025 2400");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("invalid hour in pure number"));
// Pure number as time (invalid minute).
let result = parse(&mut "jul 18 2025 2360");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("invalid minute in pure number"));
}
#[test]
@@ -498,4 +535,30 @@ mod tests {
assert_eq!(result.minute(), 0);
assert_eq!(result.second(), 0);
}
#[test]
fn pure() {
let now = Utc::now().fixed_offset();
// Pure number as year.
let result = at_date(parse(&mut "jul 18 12:30 2025").unwrap(), now).unwrap();
assert_eq!(result.year(), 2025);
// Pure number as time.
let result = at_date(parse(&mut "1230").unwrap(), now).unwrap();
assert_eq!(result.hour(), 12);
assert_eq!(result.minute(), 30);
let result = at_date(parse(&mut "123").unwrap(), now).unwrap();
assert_eq!(result.hour(), 1);
assert_eq!(result.minute(), 23);
let result = at_date(parse(&mut "12").unwrap(), now).unwrap();
assert_eq!(result.hour(), 12);
assert_eq!(result.minute(), 0);
let result = at_date(parse(&mut "1").unwrap(), now).unwrap();
assert_eq!(result.hour(), 1);
assert_eq!(result.minute(), 0);
}
}
+33
View File
@@ -0,0 +1,33 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
//! Parse a pure number string.
//!
//! The GNU docs say:
//!
//! > The precise interpretation of a pure decimal number depends on the
//! > context in the date string.
//! >
//! > If the decimal number is of the form YYYYMMDD and no other calendar
//! > date item (*note Calendar date items::) appears before it in the date
//! > string, then YYYY is read as the year, MM as the month number and DD as
//! > the day of the month, for the specified calendar date.
//! >
//! > If the decimal number is of the form HHMM and no other time of day
//! > item appears before it in the date string, then HH is read as the hour
//! > of the day and MM as the minute of the hour, for the specified time of
//! > day. MM can also be omitted.
//! >
//! > If both a calendar date and a time of day appear to the left of a
//! > number in the date string, but no relative item, then the number
//! > overrides the year.
use winnow::{stream::AsChar, token::take_while, ModalResult, Parser};
use super::primitive::s;
pub(super) fn parse(input: &mut &str) -> ModalResult<String> {
s(take_while(1.., AsChar::is_dec_digit))
.map(|s: &str| s.to_owned())
.parse_next(input)
}
+2 -2
View File
@@ -214,7 +214,7 @@ fn colon(input: &mut &str) -> ModalResult<()> {
}
/// Parse a number of hours in `0..24`(preceded by whitespace)
fn hour24(input: &mut &str) -> ModalResult<u32> {
pub(super) fn hour24(input: &mut &str) -> ModalResult<u32> {
s(dec_uint).verify(|x| *x < 24).parse_next(input)
}
@@ -224,7 +224,7 @@ fn hour12(input: &mut &str) -> ModalResult<u32> {
}
/// Parse a number of minutes (preceded by whitespace)
fn minute(input: &mut &str) -> ModalResult<u32> {
pub(super) fn minute(input: &mut &str) -> ModalResult<u32> {
s(dec_uint).verify(|x| *x < 60).parse_next(input)
}
+12 -16
View File
@@ -10,13 +10,9 @@
//! strings. For example, `"00"` is interpreted as `2000`, whereas `"0"`,
//! `"000"`, or `"0000"` are interpreted as `0`.
use winnow::{error::ErrMode, stream::AsChar, token::take_while, ModalResult, Parser};
use winnow::{stream::AsChar, token::take_while, ModalResult, Parser};
use super::primitive::{ctx_err, s};
pub(super) fn parse(input: &mut &str) -> ModalResult<u32> {
year_from_str(year_str(input)?).map_err(|e| ErrMode::Cut(ctx_err(e)))
}
use super::primitive::s;
// TODO: Leverage `TryFrom` trait.
pub(super) fn year_from_str(year_str: &str) -> Result<u32, &'static str> {
@@ -56,23 +52,23 @@ pub(super) fn year_str<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
#[cfg(test)]
mod tests {
use super::parse;
use super::year_from_str;
#[test]
fn test_year() {
// 2-characters are converted to 19XX/20XX
assert_eq!(parse(&mut "10").unwrap(), 2010u32);
assert_eq!(parse(&mut "68").unwrap(), 2068u32);
assert_eq!(parse(&mut "69").unwrap(), 1969u32);
assert_eq!(parse(&mut "99").unwrap(), 1999u32);
assert_eq!(year_from_str("10").unwrap(), 2010u32);
assert_eq!(year_from_str("68").unwrap(), 2068u32);
assert_eq!(year_from_str("69").unwrap(), 1969u32);
assert_eq!(year_from_str("99").unwrap(), 1999u32);
// 3,4-characters are converted verbatim
assert_eq!(parse(&mut "468").unwrap(), 468u32);
assert_eq!(parse(&mut "469").unwrap(), 469u32);
assert_eq!(parse(&mut "1568").unwrap(), 1568u32);
assert_eq!(parse(&mut "1569").unwrap(), 1569u32);
assert_eq!(year_from_str("468").unwrap(), 468u32);
assert_eq!(year_from_str("469").unwrap(), 469u32);
assert_eq!(year_from_str("1568").unwrap(), 1568u32);
assert_eq!(year_from_str("1569").unwrap(), 1569u32);
// years greater than 9999 are not accepted
assert!(parse(&mut "10000").is_err());
assert!(year_from_str("10000").is_err());
}
}