Feedback on human relative date time parsing grammar

I’ve been working on this tool for parsing dates and times in US English with the goal of interpreting a broad variety of expressions like last 2 months, Aug 6 at 2, tomorrow at 3:30, 3:45pm, January 1st, 2020 relative to the current time and other parameters.

I’ve made it fairly on dates far but it’s starting to feel difficult to add times and other things to the grammar, and this may just be the kind of thing where custom handwritten parsing is a better strategy. But I wanted to share what I have so far here in case anyone sees any better ways to do things.

There are a lot of challenges to this problem; for example Aug 6 at 1 is valid expression where 1 means 1:00, but 1 by itself isn’t a valid time. Conversely Jun 2020 is a valid expression, but Jun 2020 at 1:00 isn’t (a time only makes sense for a specific day). I found some other packages out there using other parser generators but they seemed to run into similar problems and aren’t able to handle a lot of common English date/time cases.

The latest thing I’m strugging with is expressions like 1:23am on aug 6. I think it’s failing because the 23 probably gets tokenized as a TwoDayYear…I tried to solve that by moving FullYear and TwoDayYear out of the tokenizer, but then I had to add a lot of ambiguity markers, and it failed to parse most of my testcases.

Here’s my grammar:

@top DateTimeExpression {
  Date
  | DayDate (space ('at' ~maybeTime space)? AtTime)?
  | Time (space ('on' ~maybeDate space)? DayDate)?
  | AtTime (space ('on' ~maybeDate space) DayDate)
}

Date {
  FullYear ~n (
    (MonthNameNoDot DayOfMonth?)
    | ('.' MonthNoDot ('.' DayOfMonth)?)
    | ('-' MonthNoDot ('-' DayOfMonth)?)
    | ('_' MonthNoDot ('_' DayOfMonth)?)
    | ('/' MonthNoDot ('/' DayOfMonth)?)
    | (space Month ~maybeTime (space DayOfMonth)?)
  )? ~DateOrDayDate
  | MonthNameNoDot ~m (
    ('.' DayOfMonth ('.' Year)?)
    | (NthDayOfMonth ~n (Year)?)
    | DayOfMonth ~n
    | ('-' DayOfMonth ('-' Year)?)
    | ('_' DayOfMonth ('_' Year)?)
    | ('/' DayOfMonth ('/' Year)?)
  )? ~DateOrDayDate
  | MonthName ~m (space DayOfMonth ~maybeTime ((space | (space? ',' space?)) Year)?)? ~DateOrDayDate
  | MonthNum ~n (
    (NthDayOfMonth (Year)?)
    | ('.' DayOfMonth ('.' Year)?)
    | ('-' DayOfMonth ('-' Year)?)
    | ('_' DayOfMonth ('_' Year)?)
    | ('/' DayOfMonth ('/' Year)?)
    | (space DayOfMonth ~maybeTime (space Year)?)     
  )? ~DateOrDayDate
  | ('the' space)? NthDayOfMonth ~n (
    (MonthNameNoDot Year?)
    | ('.' MonthNoDot ('.' Year)?)
    | ('-' MonthNoDot ('-' Year)?)
    | ('_' MonthNoDot ('_' Year)?)
    | ('/' MonthNoDot ('/' Year)?)
    | (space (('day' space)? 'of' space)? MonthName ~maybeTime ((space | (space? ',' space?)) Year)?)
  )? ~DateOrDayDate
  | DayOfMonthNum ~n (
    (MonthNameNoDot Year?)
    | ('.' MonthNoDot ('.' Year)?)
    | ('-' MonthNoDot ('-' Year)?)
    | ('_' MonthNoDot ('_' Year)?)
    | ('/' MonthNoDot ('/' Year)?)
    | (space Month ~maybeTime (space Year)?)
  ) ~DateOrDayDate
}

DayDate {
  FullYear ~n (
    (MonthNameNoDot DayOfMonth)
    | ('.' MonthNoDot ('.' DayOfMonth))
    | ('-' MonthNoDot ('-' DayOfMonth))
    | ('_' MonthNoDot ('_' DayOfMonth))
    | ('/' MonthNoDot ('/' DayOfMonth))
    | (space Month ~maybeTime (space DayOfMonth))
  )? ~DateOrDayDate
  | MonthNameNoDot ~m (
    ('.' DayOfMonth ('.' Year)?)
    | (NthDayOfMonth ~n (Year)?)
    | DayOfMonth ~n
    | ('-' DayOfMonth ('-' Year)?)
    | ('_' DayOfMonth ('_' Year)?)
    | ('/' DayOfMonth ('/' Year)?)
  ) ~DateOrDayDate
  | MonthName ~m (space DayOfMonth ~maybeTime ((space | (space? ',' space?)) Year)?) ~DateOrDayDate
  | MonthNum ~n (
    (NthDayOfMonth (Year)?)
    | ('.' DayOfMonth ('.' Year)?)
    | ('-' DayOfMonth ('-' Year)?)
    | ('_' DayOfMonth ('_' Year)?)
    | ('/' DayOfMonth ('/' Year)?)
    | (space DayOfMonth ~maybeTime (space Year)?)     
  ) ~DateOrDayDate
  | ('the' space)? NthDayOfMonth ~n (
    (MonthNameNoDot Year?)
    | ('.' MonthNoDot ('.' Year)?)
    | ('-' MonthNoDot ('-' Year)?)
    | ('_' MonthNoDot ('_' Year)?)
    | ('/' MonthNoDot ('/' Year)?)
    | (space (('day' space)? 'of' space)? MonthName ~maybeTime ((space | (space? ',' space?)) Year)?)
  )? ~DateOrDayDate
  | DayOfMonthNum ~n (
    (MonthNameNoDot Year?)
    | ('.' MonthNoDot ('.' Year)?)
    | ('-' MonthNoDot ('-' Year)?)
    | ('_' MonthNoDot ('_' Year)?)
    | ('/' MonthNoDot ('/' Year)?)
    | (space Month ~maybeTime (space Year)?)
  ) ~DateOrDayDate
}

AtTime {
  Hours ~n (':' Minutes (':' Seconds ('.' Milliseconds)?)?)? ~TimeOrAtTime (space? AmPm)? ~TimeOrAtTime
}

Time {
  Hours ~n (':' Minutes (':' Seconds ('.' Milliseconds)?)?) ~maybeDate (space? AmPm)? ~TimeOrAtTime
  | Hours ~n (':' Minutes (':' Seconds ('.' Milliseconds)?)?)? (space? AmPm) ~TimeOrAtTime
}

DayOfMonth { NthDayOfMonth | DayOfMonthNum }
Month { MonthName | MonthNum }
MonthNoDot { MonthNameNoDot | MonthNum }
Year { FullYear | TwoDigitYear }

MonthNameNoDot {
  MonthNameFull
  | MonthNameAbbrev
}

MonthName {
  (MonthNameAbbrev ~m '.'?)
  | MonthNameFull
}

MonthNum { '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '01' | '02' | '03' | '04' | '05' | '06' | '07' | '08' | '09' | '10' | '11' | '12' }
DayOfMonthNum { ( '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '01' | '02' | '03' | '04' | '05' | '06' | '07' | '08' | '09' | '10' | '11' | '12' | '13' | '14' | '15' | '16' | '17' | '18' | '19' | '20' | '21' | '22' | '23' | '24' | '25' | '26' | '27' | '28' | '29' | '30' | '31') ~n }

MonthNameFull { 'january' | 'february' | 'march' | 'april' | 'june' | 'july' | 'august' | 'september' | 'october' | 'november' | 'december' }

MonthNameAbbrev { 'jan' | 'feb' | 'mar' | 'apr' | 'may' | 'jun' | 'jul' | 'aug' | ('sep' 't'?) | 'oct' | 'nov' | 'dec' }

Digit { '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' ~n }
Hours { ('0' | '1')? ~n Digit ~n | '2' ~n ('0' | '1' | '3' | '4') ~n }
Minutes { ('0' | '1' | '2' | '3' | '4' | '5') ~n Digit }
Seconds { ('0' | '1' | '2' | '3' | '4' | '5') ~n Digit }
Milliseconds { Digit Digit? Digit? }
AmPm { ('a' 'm'? | 'p' 'm'?) }

@tokens {
  space { @whitespace+ }

  FullYear { @digit @digit @digit @digit }
  TwoDigitYear { "'"? @digit @digit }
  NthDayOfMonth {
    '1st' | 'first'
    | '2nd' | 'second'
    | '3rd' | 'third'
    | '4th' | 'fourth'
    | '5th' | 'fifth'
    | '6th' | 'sixth'
    | '7th' | 'seventh'
    | '8th' | 'eighth'
    | '9th' | 'ninth'
    | '10th' | 'tenth'
    | '11th' | 'eleventh'
    | '12th' | 'twelfth'
    | '13th' | 'thirteenth'
    | '14th' | 'fourteenth'
    | '15th' | 'fifteenth'
    | '16th' | 'sixteenth'
    | '17th' | 'seventeenth'
    | '18th' | 'eighteenth'
    | '19th' | 'ninteenth'  
    | '20th' | 'twentieth'
    | '21st' | 'twenty-first'
    | '22nd' | 'twenty-second'
    | '23rd' | 'twenty-third'
    | '24th' | 'twenty-fourth'
    | '25th' | 'twenty-fifth'
    | '26th' | 'twenty-sixth'
    | '27th' | 'twenty-seventh'
    | '28th' | 'twenty-eighth'
    | '29th' | 'twenty-ninthy'
    | '30th' | 'thirtieth'
    | '31st' | 'thirty-first'
  }
}

And my current passing and failing testcases:

    ✔ 2021
    ✔ 2021aug
    ✔ 2021aug06
    ✔ 2021aug6
    ✔ 2021august
    ✔ 2021august06
    ✔ 2021august6
    ✔ 2021.aug
    ✔ 2021.aug.06
    ✔ 2021.aug.6
    ✔ 2021.august
    ✔ 2021.august.06
    ✔ 2021.august.6
    ✔ 2021-aug
    ✔ 2021-aug-06
    ✔ 2021-aug-6
    ✔ 2021-august
    ✔ 2021-august-06
    ✔ 2021-august-6
    ✔ 2021_aug
    ✔ 2021_aug_06
    ✔ 2021_aug_6
    ✔ 2021_august
    ✔ 2021_august_06
    ✔ 2021_august_6
    ✔ 2021/aug
    ✔ 2021/aug/06
    ✔ 2021/aug/6
    ✔ 2021/august
    ✔ 2021/august/06
    ✔ 2021/august/6
    ✔ 2021 aug
    ✔ 2021 aug 06
    ✔ 2021 aug 6
    ✔ 2021 august
    ✔ 2021 august 06
    ✔ 2021 august 6
    ✔ 2021 aug.
    ✔ 2021 aug. 06
    ✔ 2021 aug. 6
    ✔ 2021.8
    ✔ 2021.08
    ✔ 2021.08.06
    ✔ 2021.8.6
    ✔ 2021-8
    ✔ 2021-08
    ✔ 2021-08-06
    ✔ 2021-8-6
    ✔ 2021_8
    ✔ 2021_08
    ✔ 2021_08_06
    ✔ 2021_8_6
    ✔ 2021/8
    ✔ 2021/08
    ✔ 2021/08/06
    ✔ 2021/8/6
    ✔ 2021 8
    ✔ 2021 08
    ✔ 2021 08 06
    ✔ 2021 8 6
    ✔ aug
    ✔ aug 01
    ✔ 01 aug
    ✔ 01 aug 2021
    ✔ 01 aug 21
    ✔ aug 01, 2021
    ✔ aug 01, '21
    ✔ aug 1
    ✔ 1 aug
    ✔ 1 aug 2021
    ✔ 1 aug 21
    ✔ aug 1, 2021
    ✔ aug 1, '21
    ✔ aug 1st
    ✔ 1st of aug
    ✔ 1st of aug, 2021
    ✔ 1st aug
    ✔ 1st aug 2021
    ✔ 1st aug 21
    ✔ aug 1st, 2021
    ✔ aug 1st, '21
    ✔ aug 2
    ✔ 2 aug
    ✔ 2 aug 2021
    ✔ 2 aug 21
    ✔ aug 2, 2021
    ✔ aug 2, '21
    ✔ aug 2nd
    ✔ 2nd of aug
    ✔ 2nd of aug, 2021
    ✔ 2nd aug
    ✔ 2nd aug 2021
    ✔ 2nd aug 21
    ✔ aug 2nd, 2021
    ✔ aug 2nd, '21
    ✔ aug 3
    ✔ 3 aug
    ✔ 3 aug 2021
    ✔ 3 aug 21
    ✔ aug 3, 2021
    ✔ aug 3, '21
    ✔ aug 3rd
    ✔ 3rd of aug
    ✔ 3rd of aug, 2021
    ✔ 3rd aug
    ✔ 3rd aug 2021
    ✔ 3rd aug 21
    ✔ aug 3rd, 2021
    ✔ aug 3rd, '21
    ✔ aug 4
    ✔ 4 aug
    ✔ 4 aug 2021
    ✔ 4 aug 21
    ✔ aug 4, 2021
    ✔ aug 4, '21
    ✔ aug 4th
    ✔ 4th of aug
    ✔ 4th of aug, 2021
    ✔ 4th aug
    ✔ 4th aug 2021
    ✔ 4th aug 21
    ✔ aug 4th, 2021
    ✔ aug 4th, '21
    ✔ aug 5
    ✔ 5 aug
    ✔ 5 aug 2021
    ✔ 5 aug 21
    ✔ aug 5, 2021
    ✔ aug 5, '21
    ✔ aug 5th
    ✔ 5th of aug
    ✔ 5th of aug, 2021
    ✔ 5th aug
    ✔ 5th aug 2021
    ✔ 5th aug 21
    ✔ aug 5th, 2021
    ✔ aug 5th, '21
    ✔ aug6
    ✔ aug.6
    ✔ aug.6.2021
    ✔ aug-6
    ✔ aug-6-2021
    ✔ aug_6
    ✔ aug_6_2021
    ✔ aug/6
    ✔ aug/6/2021
    ✔ aug 6
    ✔ aug 6 2021
    ✔ aug.
    ✔ aug. 01
    ✔ 01 aug.
    ✔ 01 aug. 2021
    ✔ 01 aug. 21
    ✔ aug. 01, 2021
    ✔ aug. 01, '21
    ✔ aug. 1
    ✔ 1 aug.
    ✔ 1 aug. 2021
    ✔ 1 aug. 21
    ✔ aug. 1, 2021
    ✔ aug. 1, '21
    ✔ aug. 1st
    ✔ 1st of aug.
    ✔ 1st of aug., 2021
    ✔ 1st aug.
    ✔ 1st aug. 2021
    ✔ 1st aug. 21
    ✔ aug. 1st, 2021
    ✔ aug. 1st, '21
    ✔ aug. 2
    ✔ 2 aug.
    ✔ 2 aug. 2021
    ✔ 2 aug. 21
    ✔ aug. 2, 2021
    ✔ aug. 2, '21
    ✔ aug. 2nd
    ✔ 2nd of aug.
    ✔ 2nd of aug., 2021
    ✔ 2nd aug.
    ✔ 2nd aug. 2021
    ✔ 2nd aug. 21
    ✔ aug. 2nd, 2021
    ✔ aug. 2nd, '21
    ✔ aug. 3
    ✔ 3 aug.
    ✔ 3 aug. 2021
    ✔ 3 aug. 21
    ✔ aug. 3, 2021
    ✔ aug. 3, '21
    ✔ aug. 3rd
    ✔ 3rd of aug.
    ✔ 3rd of aug., 2021
    ✔ 3rd aug.
    ✔ 3rd aug. 2021
    ✔ 3rd aug. 21
    ✔ aug. 3rd, 2021
    ✔ aug. 3rd, '21
    ✔ aug. 4
    ✔ 4 aug.
    ✔ 4 aug. 2021
    ✔ 4 aug. 21
    ✔ aug. 4, 2021
    ✔ aug. 4, '21
    ✔ aug. 4th
    ✔ 4th of aug.
    ✔ 4th of aug., 2021
    ✔ 4th aug.
    ✔ 4th aug. 2021
    ✔ 4th aug. 21
    ✔ aug. 4th, 2021
    ✔ aug. 4th, '21
    ✔ aug. 5
    ✔ 5 aug.
    ✔ 5 aug. 2021
    ✔ 5 aug. 21
    ✔ aug. 5, 2021
    ✔ aug. 5, '21
    ✔ aug. 5th
    ✔ 5th of aug.
    ✔ 5th of aug., 2021
    ✔ 5th aug.
    ✔ 5th aug. 2021
    ✔ 5th aug. 21
    ✔ aug. 5th, 2021
    ✔ aug. 5th, '21
    ✔ aug.6
    ✔ aug. 6
    ✔ aug. 6 2021
    ✔ august
    ✔ august 01
    ✔ 01 august
    ✔ 01 august 2021
    ✔ 01 august 21
    ✔ august 01, 2021
    ✔ august 01, '21
    ✔ august 1
    ✔ 1 august
    ✔ 1 august 2021
    ✔ 1 august 21
    ✔ august 1, 2021
    ✔ august 1, '21
    ✔ august 1st
    ✔ 1st of august
    ✔ 1st of august, 2021
    ✔ 1st august
    ✔ 1st august 2021
    ✔ 1st august 21
    ✔ august 1st, 2021
    ✔ august 1st, '21
    ✔ august 2
    ✔ 2 august
    ✔ 2 august 2021
    ✔ 2 august 21
    ✔ august 2, 2021
    ✔ august 2, '21
    ✔ august 2nd
    ✔ 2nd of august
    ✔ 2nd of august, 2021
    ✔ 2nd august
    ✔ 2nd august 2021
    ✔ 2nd august 21
    ✔ august 2nd, 2021
    ✔ august 2nd, '21
    ✔ august 3
    ✔ 3 august
    ✔ 3 august 2021
    ✔ 3 august 21
    ✔ august 3, 2021
    ✔ august 3, '21
    ✔ august 3rd
    ✔ 3rd of august
    ✔ 3rd of august, 2021
    ✔ 3rd august
    ✔ 3rd august 2021
    ✔ 3rd august 21
    ✔ august 3rd, 2021
    ✔ august 3rd, '21
    ✔ august 4
    ✔ 4 august
    ✔ 4 august 2021
    ✔ 4 august 21
    ✔ august 4, 2021
    ✔ august 4, '21
    ✔ august 4th
    ✔ 4th of august
    ✔ 4th of august, 2021
    ✔ 4th august
    ✔ 4th august 2021
    ✔ 4th august 21
    ✔ august 4th, 2021
    ✔ august 4th, '21
    ✔ august 5
    ✔ 5 august
    ✔ 5 august 2021
    ✔ 5 august 21
    ✔ august 5, 2021
    ✔ august 5, '21
    ✔ august 5th
    ✔ 5th of august
    ✔ 5th of august, 2021
    ✔ 5th august
    ✔ 5th august 2021
    ✔ 5th august 21
    ✔ august 5th, 2021
    ✔ august 5th, '21
    ✔ august6
    ✔ august.6
    ✔ august.6.2021
    ✔ august-6
    ✔ august-6-2021
    ✔ august_6
    ✔ august_6_2021
    ✔ august/6
    ✔ august/6/2021
    ✔ august 6
    ✔ august 6 2021
    ✔ 1am on aug 6
    ✔ 1 on aug 6
    ✔ 1am aug 6
    ✔ aug 6 at 1am
    ✔ aug 6 at 1
    ✔ aug 6 1am
    1) 1:23am on aug 6
    2) 1:23 on aug 6
    3) 1:23 aug 6
    4) 1:23am aug 6
    5) aug 6 at 1:23am
    6) aug 6 at 1:23
    7) aug 6 1:23am
    8) aug 6 1:23


  322 passing (125ms)
  8 failing

  1) parse
       1:23am on aug 6:
     Error: syntax error
Parse tree: DateTimeExpression {
  AtTime {
    Hours {
      Digit { 1 }
    }
    ⚠ { 23am on }
  }
  DayDate {
    MonthName {
      MonthNameAbbrev { aug }
    }
    DayOfMonth {
      DayOfMonthNum { 6 }
    }
  }
}
      at Context.<anonymous> (test/parse.test.ts:14:26)
      at processImmediate (node:internal/timers:478:21)

  2) parse
       1:23 on aug 6:
     Error: syntax error
Parse tree: DateTimeExpression {
  AtTime {
    Hours {
      Digit { 1 }
    }
    ⚠ { 23 on aug }
  }
  DayDate {
    MonthName {
      MonthNameAbbrev { aug }
    }
    DayOfMonth {
      DayOfMonthNum { 6 }
    }
  }
}
      at Context.<anonymous> (test/parse.test.ts:14:26)
      at processImmediate (node:internal/timers:478:21)

  3) parse
       1:23 aug 6:
     Error: syntax error
Parse tree: DateTimeExpression {
  AtTime {
    Hours {
      Digit { 1 }
    }
    ⚠ { 23 aug 6 }
  }
  ⚠ { aug 6 }
  ⚠ {  }
  DayDate {
    DayOfMonthNum { 6 }
    ⚠ {  }
  }
}
      at Context.<anonymous> (test/parse.test.ts:14:26)
      at processImmediate (node:internal/timers:478:21)

  4) parse
       1:23am aug 6:
     Error: syntax error
Parse tree: DateTimeExpression {
  AtTime {
    Hours {
      Digit { 1 }
    }
    ⚠ { 23am aug }
  }
  ⚠ { aug 6 }
  ⚠ {  }
  DayDate {
    DayOfMonthNum { 6 }
    ⚠ {  }
  }
}
      at Context.<anonymous> (test/parse.test.ts:14:26)
      at processImmediate (node:internal/timers:478:21)

  5) parse
       aug 6 at 1:23am:
     Error: syntax error
Parse tree: DateTimeExpression {
  DayDate {
    MonthName {
      MonthNameAbbrev { aug }
    }
    DayOfMonth {
      DayOfMonthNum { 6 }
    }
  }
  AtTime {
    Hours {
      Digit { 1 }
    }
    ⚠ { 23am }
  }
}
      at Context.<anonymous> (test/parse.test.ts:14:26)
      at processImmediate (node:internal/timers:478:21)

  6) parse
       aug 6 at 1:23:
     Error: syntax error
Parse tree: DateTimeExpression {
  DayDate {
    MonthName {
      MonthNameAbbrev { aug }
    }
    DayOfMonth {
      DayOfMonthNum { 6 }
    }
  }
  AtTime {
    Hours {
      Digit { 1 }
    }
    ⚠ { 23 }
  }
}
      at Context.<anonymous> (test/parse.test.ts:14:26)
      at processImmediate (node:internal/timers:478:21)

  7) parse
       aug 6 1:23am:
     Error: syntax error
Parse tree: DateTimeExpression {
  DayDate {
    MonthName {
      MonthNameAbbrev { aug }
    }
    DayOfMonth {
      DayOfMonthNum { 6 }
    }
  }
  AtTime {
    Hours {
      Digit { 1 }
    }
    ⚠ { 23am }
  }
}
      at Context.<anonymous> (test/parse.test.ts:14:26)
      at processImmediate (node:internal/timers:478:21)

  8) parse
       aug 6 1:23:
     Error: syntax error
Parse tree: DateTimeExpression {
  DayDate {
    MonthName {
      MonthNameAbbrev { aug }
    }
    DayOfMonth {
      DayOfMonthNum { 6 }
    }
  }
  AtTime {
    Hours {
      Digit { 1 }
    }
    ⚠ { 23 }
  }
}
      at Context.<anonymous> (test/parse.test.ts:14:26)
      at processImmediate (node:internal/timers:478:21)