use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FormatDirective {
Year4,
Year2,
MonthNumber,
DayOfMonth,
DayOfMonthSpaced,
Hour24,
Hour12,
Minute,
Second,
Fraction,
Meridiem,
DayOfYear,
MonthAbbrev,
MonthName,
WeekdayAbbrev,
WeekdayName,
Offset,
ZoneName,
}
impl FormatDirective {
#[must_use]
pub fn spelled(self) -> &'static str {
match self {
Self::Year4 => "%Y",
Self::Year2 => "%y",
Self::MonthNumber => "%m",
Self::DayOfMonth => "%d",
Self::DayOfMonthSpaced => "%e",
Self::Hour24 => "%H",
Self::Hour12 => "%I",
Self::Minute => "%M",
Self::Second => "%S",
Self::Fraction => "%f",
Self::Meridiem => "%p",
Self::DayOfYear => "%j",
Self::MonthAbbrev => "%b",
Self::MonthName => "%B",
Self::WeekdayAbbrev => "%a",
Self::WeekdayName => "%A",
Self::Offset => "%z",
Self::ZoneName => "%Z",
}
}
#[must_use]
pub fn means(self) -> &'static str {
match self {
Self::Year4 => "a four-digit year",
Self::Year2 => "a two-digit year",
Self::MonthNumber => "the month as a number",
Self::DayOfMonth | Self::DayOfMonthSpaced => "the day of the month",
Self::Hour24 => "the hour on a twenty-four hour clock",
Self::Hour12 => "the hour on a twelve hour clock",
Self::Minute => "the minute",
Self::Second => "the second",
Self::Fraction => "the fraction of a second",
Self::Meridiem => "AM or PM",
Self::DayOfYear => "the day of the year",
Self::MonthAbbrev => "the month's name, abbreviated",
Self::MonthName => "the month's name in full",
Self::WeekdayAbbrev => "the weekday's name, abbreviated",
Self::WeekdayName => "the weekday's name in full",
Self::Offset => "the offset from UTC",
Self::ZoneName => "the time zone's name",
}
}
#[must_use]
pub fn names_a_zone(self) -> bool {
matches!(self, Self::Offset | Self::ZoneName)
}
fn from_letter(letter: char) -> Option<Self> {
Some(match letter {
'Y' => Self::Year4,
'y' => Self::Year2,
'm' => Self::MonthNumber,
'd' => Self::DayOfMonth,
'e' => Self::DayOfMonthSpaced,
'H' => Self::Hour24,
'I' => Self::Hour12,
'M' => Self::Minute,
'S' => Self::Second,
'f' => Self::Fraction,
'p' => Self::Meridiem,
'j' => Self::DayOfYear,
'b' => Self::MonthAbbrev,
'B' => Self::MonthName,
'a' => Self::WeekdayAbbrev,
'A' => Self::WeekdayName,
'z' => Self::Offset,
'Z' => Self::ZoneName,
_ => return None,
})
}
fn shape(self) -> &'static str {
match self {
Self::Year4 => "([0-9]{4})",
Self::Year2 => "([0-9]{2})",
Self::MonthNumber | Self::Hour12 => "(0?[1-9]|1[0-2])",
Self::DayOfMonth => "(0?[1-9]|[12][0-9]|3[01])",
Self::DayOfMonthSpaced => "( ?[1-9]|[12][0-9]|3[01])",
Self::Hour24 => "([01]?[0-9]|2[0-3])",
Self::Minute | Self::Second => "([0-5]?[0-9])",
Self::Fraction => "([0-9]{1,9})",
Self::Meridiem => "([AaPp][Mm])",
Self::DayOfYear => "(0{0,2}[1-9]|0?[1-9][0-9]|[12][0-9]{2}|3[0-5][0-9]|36[0-6])",
Self::MonthAbbrev => {
"([Jj][Aa][Nn]|[Ff][Ee][Bb]|[Mm][Aa][Rr]|[Aa][Pp][Rr]|[Mm][Aa][Yy]|[Jj][Uu][Nn]|[Jj][Uu][Ll]|[Aa][Uu][Gg]|[Ss][Ee][Pp]|[Oo][Cc][Tt]|[Nn][Oo][Vv]|[Dd][Ee][Cc])"
}
Self::MonthName => {
"([Jj][Aa][Nn][Uu][Aa][Rr][Yy]|[Ff][Ee][Bb][Rr][Uu][Aa][Rr][Yy]|[Mm][Aa][Rr][Cc][Hh]|[Aa][Pp][Rr][Ii][Ll]|[Mm][Aa][Yy]|[Jj][Uu][Nn][Ee]|[Jj][Uu][Ll][Yy]|[Aa][Uu][Gg][Uu][Ss][Tt]|[Ss][Ee][Pp][Tt][Ee][Mm][Bb][Ee][Rr]|[Oo][Cc][Tt][Oo][Bb][Ee][Rr]|[Nn][Oo][Vv][Ee][Mm][Bb][Ee][Rr]|[Dd][Ee][Cc][Ee][Mm][Bb][Ee][Rr])"
}
Self::WeekdayAbbrev => {
"([Mm][Oo][Nn]|[Tt][Uu][Ee]|[Ww][Ee][Dd]|[Tt][Hh][Uu]|[Ff][Rr][Ii]|[Ss][Aa][Tt]|[Ss][Uu][Nn])"
}
Self::WeekdayName => {
"([Mm][Oo][Nn][Dd][Aa][Yy]|[Tt][Uu][Ee][Ss][Dd][Aa][Yy]|[Ww][Ee][Dd][Nn][Ee][Ss][Dd][Aa][Yy]|[Tt][Hh][Uu][Rr][Ss][Dd][Aa][Yy]|[Ff][Rr][Ii][Dd][Aa][Yy]|[Ss][Aa][Tt][Uu][Rr][Dd][Aa][Yy]|[Ss][Uu][Nn][Dd][Aa][Yy])"
}
Self::Offset => "([+-][0-9]{2}:?[0-9]{2}|[Zz])",
Self::ZoneName => "([A-Za-z_/+-]+)",
}
}
fn loose(self) -> &'static str {
match self {
Self::Year4 => "[0-9]{4}",
Self::Year2 => "[0-9]{2}",
Self::MonthNumber
| Self::DayOfMonth
| Self::Hour24
| Self::Hour12
| Self::Minute
| Self::Second => "[0-9]{1,2}",
Self::DayOfMonthSpaced => "[ 0-9]{1,2}",
Self::Fraction => "[0-9]{1,9}",
Self::Meridiem => "[AaPp][Mm]",
Self::DayOfYear => "[0-9]{1,3}",
Self::MonthAbbrev | Self::MonthName | Self::WeekdayAbbrev | Self::WeekdayName => {
"[A-Za-z]+"
}
Self::Offset => "[Zz+:0-9-]{1,6}",
Self::ZoneName => "[A-Za-z_/+-]+",
}
}
}
impl fmt::Display for FormatDirective {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.spelled())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FormatPart {
Literal(String),
Directive(FormatDirective),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DateFormatError {
UnknownDirective(char),
DanglingPercent,
Empty,
}
impl fmt::Display for DateFormatError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownDirective(letter) => write!(
f,
"`%{letter}` is not something a format can say. The directives are `%Y` `%y` \
`%m` `%d` `%e` `%H` `%I` `%M` `%S` `%f` `%p` `%j` `%b` `%B` `%a` `%A` `%z` `%Z` \
and `%%` for a literal percent sign; everything else in a format is written the \
way the value writes it"
),
Self::DanglingPercent => f.write_str(
"the format ends in a `%` with nothing after it. Name what comes there, or write \
`%%` for a literal percent sign",
),
Self::Empty => f.write_str(
"the format is empty. Write how the source spells the value: `%m/%d/%Y` for \
`1/1/2015`, `%Y-%m-%d` for `2015-01-01`",
),
}
}
}
impl std::error::Error for DateFormatError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DateFormat {
spelled: String,
parts: Vec<FormatPart>,
}
impl DateFormat {
pub fn parse(spelled: &str) -> Result<Self, DateFormatError> {
if spelled.is_empty() {
return Err(DateFormatError::Empty);
}
let mut parts: Vec<FormatPart> = Vec::new();
let mut literal = String::new();
let mut letters = spelled.chars();
while let Some(character) = letters.next() {
if character != '%' {
literal.push(character);
continue;
}
let Some(next) = letters.next() else {
return Err(DateFormatError::DanglingPercent);
};
if next == '%' {
literal.push('%');
continue;
}
let Some(directive) = FormatDirective::from_letter(next) else {
return Err(DateFormatError::UnknownDirective(next));
};
if !literal.is_empty() {
parts.push(FormatPart::Literal(std::mem::take(&mut literal)));
}
parts.push(FormatPart::Directive(directive));
}
if !literal.is_empty() {
parts.push(FormatPart::Literal(literal));
}
Ok(Self { spelled: spelled.to_owned(), parts })
}
#[must_use]
pub fn spelled(&self) -> &str {
&self.spelled
}
#[must_use]
pub fn parts(&self) -> &[FormatPart] {
&self.parts
}
pub fn directives(&self) -> impl Iterator<Item = FormatDirective> + '_ {
self.parts.iter().filter_map(|part| match part {
FormatPart::Directive(directive) => Some(*directive),
FormatPart::Literal(_) => None,
})
}
#[must_use]
pub fn names_a_zone(&self) -> bool {
self.directives().any(FormatDirective::names_a_zone)
}
#[must_use]
pub fn says_a_time_of_day(&self) -> bool {
self.directives().any(|directive| {
matches!(
directive,
FormatDirective::Hour24
| FormatDirective::Hour12
| FormatDirective::Minute
| FormatDirective::Second
| FormatDirective::Fraction
| FormatDirective::Meridiem
)
})
}
pub fn translate(
&self,
spell: &dyn Fn(FormatDirective) -> Option<&'static str>,
literal: &dyn Fn(&str) -> String,
) -> Result<String, FormatDirective> {
let mut pattern = String::new();
for part in &self.parts {
match part {
FormatPart::Literal(text) => pattern.push_str(&literal(text)),
FormatPart::Directive(directive) => {
pattern.push_str(spell(*directive).ok_or(*directive)?);
}
}
}
Ok(pattern)
}
#[must_use]
pub fn shape_pattern(&self) -> String {
let mut pattern = String::from("^");
for part in &self.parts {
match part {
FormatPart::Literal(text) => pattern.push_str(&escape_posix(text)),
FormatPart::Directive(directive) => pattern.push_str(directive.shape()),
}
}
pattern.push('$');
pattern
}
#[must_use]
pub fn capture_pattern(&self, wanted: FormatDirective) -> Option<String> {
let mut found = false;
let mut pattern = String::from("^");
for part in &self.parts {
match part {
FormatPart::Literal(text) => pattern.push_str(&escape_posix(text)),
FormatPart::Directive(directive) if *directive == wanted && !found => {
found = true;
pattern.push('(');
pattern.push_str(directive.loose());
pattern.push(')');
}
FormatPart::Directive(directive) => pattern.push_str(directive.loose()),
}
}
pattern.push('$');
found.then_some(pattern)
}
}
impl fmt::Display for DateFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.spelled)
}
}
fn escape_posix(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for character in text.chars() {
if "\\^$.[]|()*+?{}".contains(character) {
escaped.push('\\');
}
escaped.push(character);
}
escaped
}
#[cfg(test)]
mod tests {
use super::{DateFormat, DateFormatError, FormatDirective, FormatPart};
#[test]
fn a_day_spelled_with_slashes_reads_as_five_pieces() {
let format = DateFormat::parse("%m/%d/%Y").expect("a lawful format");
assert_eq!(
format.parts(),
[
FormatPart::Directive(FormatDirective::MonthNumber),
FormatPart::Literal("/".into()),
FormatPart::Directive(FormatDirective::DayOfMonth),
FormatPart::Literal("/".into()),
FormatPart::Directive(FormatDirective::Year4),
]
);
assert_eq!(format.spelled(), "%m/%d/%Y");
assert!(!format.says_a_time_of_day());
assert!(!format.names_a_zone());
}
#[test]
fn every_directive_in_the_family_is_read() {
let every = "%Y %y %m %d %e %H %I %M %S %f %p %j %b %B %a %A %z %Z";
let format = DateFormat::parse(every).expect("a lawful format");
let read: Vec<_> = format.directives().map(FormatDirective::spelled).collect();
assert_eq!(
read,
[
"%Y", "%y", "%m", "%d", "%e", "%H", "%I", "%M", "%S", "%f", "%p", "%j", "%b", "%B",
"%a", "%A", "%z", "%Z"
]
);
assert!(format.names_a_zone());
assert!(format.says_a_time_of_day());
}
#[test]
fn a_doubled_percent_is_a_literal_one() {
let format = DateFormat::parse("%d%%%m").expect("a lawful format");
assert_eq!(
format.parts(),
[
FormatPart::Directive(FormatDirective::DayOfMonth),
FormatPart::Literal("%".into()),
FormatPart::Directive(FormatDirective::MonthNumber),
]
);
}
#[test]
fn a_letter_the_family_does_not_use_is_refused_by_name() {
assert_eq!(DateFormat::parse("%Q-%m"), Err(DateFormatError::UnknownDirective('Q')));
assert!(DateFormat::parse("%Q").unwrap_err().to_string().contains("`%Q`"));
}
#[test]
fn a_trailing_percent_is_refused() {
assert_eq!(DateFormat::parse("%Y-%m-%d %"), Err(DateFormatError::DanglingPercent));
assert_eq!(DateFormat::parse(""), Err(DateFormatError::Empty));
}
#[test]
fn a_translation_names_the_directive_the_engine_cannot_say() {
let format = DateFormat::parse("%Y-%m-%dT%H:%M:%S%z").expect("a lawful format");
let spell = |directive: FormatDirective| match directive {
FormatDirective::Year4 => Some("yyyy"),
FormatDirective::MonthNumber => Some("MM"),
FormatDirective::DayOfMonth => Some("dd"),
FormatDirective::Hour24 => Some("HH"),
FormatDirective::Minute => Some("mm"),
FormatDirective::Second => Some("ss"),
_ => None,
};
assert_eq!(
format.translate(&spell, &|text| format!("'{text}'")),
Err(FormatDirective::Offset)
);
let without = DateFormat::parse("%Y-%m-%d").expect("a lawful format");
assert_eq!(
without.translate(&spell, &|text: &str| text.to_owned()),
Ok("yyyy-MM-dd".to_owned())
);
}
#[test]
fn the_guard_and_the_read_back_come_from_one_list() {
let format = DateFormat::parse("%m/%d/%Y").expect("a lawful format");
assert_eq!(
format.shape_pattern(),
"^(0?[1-9]|1[0-2])/(0?[1-9]|[12][0-9]|3[01])/([0-9]{4})$"
);
assert_eq!(
format.capture_pattern(FormatDirective::DayOfMonth).as_deref(),
Some("^[0-9]{1,2}/([0-9]{1,2})/[0-9]{4}$")
);
assert_eq!(format.capture_pattern(FormatDirective::Hour24), None);
}
#[test]
fn a_literal_dot_means_a_dot() {
let format = DateFormat::parse("%d.%m.%Y").expect("a lawful format");
assert!(format.shape_pattern().contains("\\."));
}
}