opening-hours-syntax 0.6.4

A parser for opening_hours fields in OpenStreetMap.
Documentation
// Grammar for opening hours description as defined in OSM wiki:
// https://wiki.openstreetmap.org/wiki/Key:opening_hours/specification

// Time domain

input_opening_hours = _{ SOI ~ opening_hours ~ EOI }

opening_hours = { rule_sequence ~ ( any_rule_separator ~ rule_sequence )* }

// NOTE: the specification makes the space mandatory here but it would enforce
//       a double space in some cases.
// NOTE: the specification makes `rules_modifier` mandatory but possibly empty,
//       it is simpler to deal with by simply making it optional.
rule_sequence = { selector_sequence ~ space? ~ rules_modifier? }

// Rule separators

any_rule_separator = {
      normal_rule_separator
    | additional_rule_separator
    | fallback_rule_separator
}

normal_rule_separator = @{ ";" ~ space }

additional_rule_separator = @{ "," ~ space }

// NOTE: the specification actually enforces to prefix this with a space,
//        however it could have been already consumed by `rule_sequence`.
fallback_rule_separator = @{ space? ~ "||" ~ space }

// Rule modifier

rules_modifier = {
      comment
    | rules_modifier_enum ~ space? ~ comment?
}

rules_modifier_enum = {
      rules_modifier_enum_closed
    | rules_modifier_enum_open
    | rules_modifier_enum_unknown
}

rules_modifier_enum_closed = @{ "closed" | "off" }
rules_modifier_enum_open = @{ "open" }
rules_modifier_enum_unknown = @{ "unknown" }

// Selectors

selector_sequence = {
      always_open
    | wide_range_selectors ~ small_range_selectors?
}

always_open = @{ "24/7" }

wide_range_selectors = {
      comment ~ ":"
    | // The specified grammar is ambiguous: `year_selector` can contain a
      // single year which could also be the optional prefix of
      // `monthday_selector`. In such a case `monthday_selector` will be
      // favored.
      monthday_selector ~ week_selector? ~ separator_for_readability?
    | year_selector? ~ monthday_selector? ~ week_selector? ~
      separator_for_readability?
}

small_range_selectors = {
      weekday_selector ~ space ~ time_selector
    | weekday_selector
    | time_selector
}

// NOTE: In specification, only ":" is allowed, but other forms are quite
//       common.
separator_for_readability = _{ " " | ": " | ":" }

// Time selector

time_selector = { timespan ~ ( "," ~ timespan )* }

timespan = {
      time ~ "-" ~ extended_time ~ "/" ~ hour_minutes
    | time ~ "-" ~ extended_time ~ "/" ~ minute
    | time ~ "-" ~ extended_time ~ "+"
    | time ~ "-" ~ extended_time
    | time ~ "+"
    | time
}

timespan_plus = @{ "+" }

time = {
      hour_minutes
    | variable_time
}

extended_time = {
      extended_hour_minutes
    | variable_time
}

variable_time = {
      "(" ~ event ~ plus_or_minus ~ hour_minutes ~ ")"
    | event
}

event = { dawn | sunrise | sunset | dusk }

dawn = @{ "dawn" }
sunrise = @{ "sunrise" }
sunset = @{ "sunset" }
dusk = @{ "dusk" }

// Weekday selector

weekday_selector = {
      holiday_sequence ~ ( "," ~ weekday_sequence )?
    | weekday_sequence ~ ( "," ~ holiday_sequence )?
    | holiday_sequence ~ space ~ weekday_sequence
}

weekday_sequence = { weekday_range ~ ( "," ~ weekday_range )* }

weekday_range = {
      wday ~ "[" ~ nth_entry ~ ( "," ~ nth_entry )* ~ "]" ~ day_offset?
    | wday ~ "-" ~ wday
    | wday
}

holiday_sequence = { holiday ~ ( "," ~ holiday )* }

holiday = {
      public_holiday ~ day_offset?
    | school_holiday
}

public_holiday = @{ "PH" }

school_holiday = @{ "SH" }

nth_entry = {
      nth ~ "-" ~ nth
    | nth_minus ~ nth         // TODO: "nth last day of the month"
    | nth
}

nth = { '1'..'5' }

nth_minus = { "-" }

day_offset = { space ~ plus_or_minus ~ positive_number ~ space ~ "day" ~ "s"? }

// Week selector

week_selector = { "week" ~ week ~ ( "," ~ week )* }

week = { weeknum ~ ( "-" ~ weeknum ~  ( "/" ~ positive_number )? )? }

// Month selector

monthday_selector = { monthday_range ~ ( "," ~ monthday_range )* }

monthday_range = {
      date_from ~ date_offset? ~ space? ~ "-" ~ space? ~ date_to ~ date_offset?
    | date_from ~ date_offset ~ monthday_range_plus?
    | date_from               ~ monthday_range_plus?
    | year? ~ month ~ ( "-" ~ month )?
}

monthday_range_plus = @{ "+" }

date_offset = {
      (plus_or_minus ~ wday) ~ day_offset
    | (plus_or_minus ~ wday)
    | day_offset
}

// NOTE: In specification space separators are not allowed.
date_from = {
      (year ~ " "?)? ~ month ~ " "? ~ daynum
    | year? ~ variable_date
}

date_to = {
      date_from
    | daynum
}

variable_date = { "easter" }


// Year selector

year_selector = { year_range ~ ( "," ~ year_range )* }

year_range = {
    year ~ year_range_plus
  | year ~ ( "-" ~ year ~ ( "/" ~ positive_number )? )?
}

year_range_plus = @{ "+" }

// Basic elements

plus_or_minus = { plus | minus }

plus = @{ "+" }

minus = @{ "-" }

hour = @{
      '0'..'1' ~ '0'..'9'  // 00 -> 19
    |      "2" ~ '0'..'4'  // 20 -> 24
}

extended_hour = @{
     '0'..'3' ~ '0'..'9'  // 00 -> 39
    |     "4" ~ '0'..'8'  // 40 -> 48
}

minute = @{ '0'..'5' ~ '0'..'9' }

hour_minutes = { hour ~ ":" ~ minute }

extended_hour_minutes = { extended_hour ~ ":" ~ minute }

wday = { sunday | monday | tuesday | wednesday | thursday | friday | saturday }

sunday = @{ "Su" }
monday = @{ "Mo" }
tuesday = @{ "Tu" }
wednesday = @{ "We" }
thursday = @{ "Th" }
friday = @{ "Fr" }
saturday = @{ "Sa" }

// NOTE: In specification, single digit numbers are not allowed, but it seems
//       pretty common.
daynum = @{
      '0'..'2' ~ '0'..'9'  // 00 -> 29
    |      "3" ~ '0'..'1'  // 30 -> 31
    |            '0'..'9'  //  0 -> 9
}

weeknum = @{
           "0" ~ '1'..'9'  // 01 -> 09
    | '1'..'4' ~ '0'..'9'  // 10 -> 49
    |      "5" ~ '0'..'3'  // 50 -> 53
}

month = {
    january | february | march | april | may | june | july | august | september
    | october | november | december
}

january = @{ "Jan" }
february = @{ "Feb" }
march = @{ "Mar" }
april = @{ "Apr" }
may = @{ "May" }
june = @{ "Jun" }
july = @{ "Jul" }
august = @{ "Aug" }
september = @{ "Sep" }
october = @{ "Oct" }
november = @{ "Nov" }
december = @{ "Dec" }

year = @{
          ( "19" ~ ASCII_DIGIT{2} )  // 1900 -> 1999
    | ( '2'..'9' ~ ASCII_DIGIT{3} )  // 2000 -> 9999
}

positive_number = @{ "0"* ~ ASCII_NONZERO_DIGIT ~ ASCII_DIGIT* }

comment = { comment_delimiter ~ comment_inner ~ comment_delimiter }

comment_delimiter = _{ "\"" }

comment_inner = @{ comment_character+ }

comment_character = @{ !comment_delimiter ~ ANY }

space = _{ " " }