Skip to content
Merged
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
53 changes: 34 additions & 19 deletions src/items/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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());

Expand Down
91 changes: 77 additions & 14 deletions src/items/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
mod combined;
mod date;
mod epoch;
mod pure;
mod relative;
mod time;
mod timezone;
Expand All @@ -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.
Expand Down Expand Up @@ -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 ;
///
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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)
Expand All @@ -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),
Expand All @@ -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)
Expand All @@ -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> {
Expand Down Expand Up @@ -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"));
Expand All @@ -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]
Expand Down Expand Up @@ -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 changes: 33 additions & 0 deletions src/items/pure.rs
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 2 additions & 2 deletions src/items/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
}

Expand Down
28 changes: 12 additions & 16 deletions src/items/year.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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());
}
}
Loading