struct Time::Format # :nodoc: struct Parser include Pattern # :nodoc: RFC_2822_LOCATIONS = { "UT" => Location::UTC, "GMT" => Location::UTC, "EST" => Location.fixed("EST", -5 * 3600), "EDT" => Location.fixed("EDT", -4 * 3600), "CST" => Location.fixed("CST", -6 * 3600), "CDT" => Location.fixed("CDT", -5 * 3600), "MST" => Location.fixed("MST", -7 * 3600), "MDT" => Location.fixed("MDT", -6 * 3600), "PST" => Location.fixed("PST", -8 * 3600), "PDT" => Location.fixed("PDT", -7 * 3600), } @unix_seconds : Int64? @location : Location? @calendar_week_week : Int32? @calendar_week_year : Int32? @day_of_week : Time::DayOfWeek? @day_of_year : Int32? def initialize(string) @reader = Char::Reader.new(string) @year = 1 @month = 1 @day = 1 @hour = 0 @minute = 0 @second = 0 @nanosecond = 0 @pm = false @hour_is_12 = false @nanosecond_offset = 0_i64 end def time(location : Location? = nil) : Time if @hour_is_12 if @hour > 12 raise ArgumentError.new("Invalid hour for 12-hour clock") end if @pm @hour += 12 unless @hour == 12 else if @hour == 0 raise ArgumentError.new("Invalid hour for 12-hour clock") end @hour = 0 if @hour == 12 end end if unix_seconds = @unix_seconds return Time.unix(unix_seconds) end location = @location || location if location.nil? raise "Time format did not include time zone and no default location provided", pos: false end if (calendar_week_week = @calendar_week_week) && (calendar_week_year = @calendar_week_year) && (day_of_week = @day_of_week) # If all components of a week date are available, they are used to create a Time instance time = Time.week_date calendar_week_year, calendar_week_week, day_of_week, @hour, @minute, @second, nanosecond: @nanosecond, location: location else if day_of_year = @day_of_year raise "Invalid day of year" unless day_of_year.in?(1..Time.days_in_year(@year)) days_per_month = Time.leap_year?(@year) ? DAYS_MONTH_LEAP : DAYS_MONTH month = 1 day = day_of_year while day > days_per_month[month] day -= days_per_month[month] month += 1 end else month = @month day = @day end time = Time.local @year, month, day, @hour, @minute, @second, nanosecond: @nanosecond, location: location end time = time.shift 0, @nanosecond_offset time end def year @year = consume_number(4) end def year_modulo_100 year = consume_number(2) if 69 <= year <= 99 @year = year + 1900 elsif 0 <= year @year = year + 2000 else raise "Invalid year" end end def year_divided_by_100 @year = consume_number(2) * 100 end def full_or_short_year @year = case year = consume_number(4) when 0..49 year + 2000 when 50..999 year + 1900 else year end end def calendar_week_year @calendar_week_year = consume_number(4) end def calendar_week_year_modulo100 @calendar_week_year = consume_number(2) end def month @month = consume_number(2) end def month_zero_padded month end def month_blank_padded @month = consume_number_blank_padded(2) end def month_name string = consume_string if string.size < 3 raise "Invalid month" end string = string.capitalize index = MONTH_NAMES.index &.starts_with?(string) if index @month = 1 + index else raise "Invalid month" end end def month_name_upcase month_name end def short_month_name string = consume_string if string.size != 3 raise "Invalid month" end string = string.capitalize index = MONTH_NAMES.index &.starts_with?(string) if index @month = 1 + index else raise "Invalid month" end end def short_month_name_upcase month_name end def calendar_week_week @calendar_week_week = consume_number(2) end def day_of_month @day = consume_number(2) end def day_of_month_zero_padded @day = consume_number(2) end def day_of_month_blank_padded @day = consume_number_blank_padded(2) end def day_name string = consume_string if string.size < 3 raise "Invalid day name" end string = string.capitalize index = DAY_NAMES.index &.starts_with?(string) unless index raise "Invalid day name" end end def day_name_upcase day_name end def short_day_name day_name end def short_day_name_upcase day_name end def short_day_name_with_comma? return unless current_char.ascii_letter? short_day_name char ',' whitespace end def day_of_year_zero_padded @day_of_year = consume_number(3) end def hour_24_zero_padded @hour_is_12 = false @hour = consume_number(2) end def hour_24_blank_padded @hour_is_12 = false @hour = consume_number_blank_padded(2) end def hour_12_zero_padded hour_24_zero_padded @hour_is_12 = true end def hour_12_blank_padded @hour_is_12 = true @hour = consume_number_blank_padded(2) end def minute @minute = consume_number(2) end def second @second = consume_number(2) end def milliseconds second_decimals 3 end def microseconds second_decimals 6 end def nanoseconds second_decimals 9 end def second_fraction second_decimals 9 # consume trailing numbers while current_char.ascii_number? next_char end end private def second_decimals(precision) pos = @reader.pos # Consume at most *precision* digits as i64 decimals = consume_number_i64(precision) # Multiply the parsed value if does not match the expected precision digits = @reader.pos - pos precision_shift = digits < precision ? precision - digits : 0 # Adjust to nanoseconds nanoseconds_shift = 9 - precision @nanosecond = (decimals * 10 ** (precision_shift + nanoseconds_shift)).to_i end def second_fraction?(fraction_digits = nil) if current_char == '.' next_char nanoseconds end end def am_pm string = consume_string case string.downcase when "am" @pm = false when "pm" @pm = true else raise "Invalid am/pm" end end def am_pm_upcase am_pm end def day_of_week_monday_1_7 @day_of_week = Time::DayOfWeek.from_value(consume_number(1)) end def day_of_week_sunday_0_6 @day_of_week = Time::DayOfWeek.from_value(consume_number(1)) end def unix_seconds negative = false case current_char when '-' negative = true next_char when '+' next_char else # no sign prefix end @unix_seconds = consume_number_i64(19) * (negative ? -1 : 1) end def time_zone(with_seconds = false) case current_char when 'Z' time_zone_z when 'U' if next_char == 'T' && next_char == 'C' @location = Location::UTC next_char else raise "Invalid timezone" end when '-', '+' time_zone_offset else raise "Invalid timezone" end end def time_zone_z_or_offset(**options) case current_char when 'Z', 'z' time_zone_z when '-', '+' time_zone_offset(**options) else raise "Invalid timezone" end end def time_zone_z raise "Invalid timezone" unless current_char.in?('Z', 'z') @location = Location::UTC next_char end def time_zone_offset(force_colon = false, allow_colon = true, format_seconds = false, parse_seconds = true, force_zero_padding = true, force_minutes = true) case current_char when '-' sign = -1 when '+' sign = 1 else raise "Invalid timezone" end char = next_char raise "Invalid timezone" unless char.ascii_number? hours = char.to_i char = next_char if char.ascii_number? hours = hours * 10 + char.to_i char = next_char elsif force_zero_padding raise "Invalid timezone" end if char == ':' raise "Invalid timezone" unless allow_colon char = next_char elsif force_colon raise "Invalid timezone" end if char.ascii_number? minutes = char.to_i char = next_char if char.ascii_number? minutes = minutes * 10 + char.to_i char = next_char elsif force_zero_padding raise "Invalid timezone" end elsif force_minutes raise "Invalid timezone" else minutes = 0 end seconds = 0 if @reader.has_next? && parse_seconds pos = @reader.pos if char == ':' char = next_char raise "Invalid timezone" unless char.ascii_number? elsif force_colon && char.ascii_number? raise "Invalid timezone" end if char.ascii_number? seconds = char.to_i char = next_char raise "Invalid timezone" unless char.ascii_number? seconds = seconds * 10 + char.to_i next_char else @reader.pos = pos end end @location = Location.fixed(sign * (3600 * hours + 60 * minutes + seconds)) end def time_zone_colon time_zone end def time_zone_colon_with_seconds time_zone(with_seconds: true) end def time_zone_gmt consume_string == "GMT" || raise "Invalid timezone" @location = Location::UTC end def time_zone_rfc2822 case current_char when '-', '+' time_zone_offset(allow_colon: false) else zone = consume_string @location = RFC_2822_LOCATIONS.fetch(zone, Location::UTC) end end def time_zone_gmt_or_rfc2822(**options) time_zone_rfc2822 end def time_zone_name(zone = false) case current_char when '-', '+' time_zone_offset else start_pos = @reader.pos while @reader.has_next? && (!current_char.whitespace? || current_char == Char::ZERO) next_char end zone_name = @reader.string.byte_slice(start_pos, @reader.pos - start_pos) if zone_name.in?("Z", "UTC") @location = Time::Location::UTC else @location = Time::Location.load(zone_name) end end end def char?(char, *alternatives) if current_char == char || alternatives.includes?(current_char) next_char true else false end end def char(char, *alternatives) if char.ascii_whitespace? skip_spaces return end unless @reader.has_next? if alternatives.empty? raise "Expected #{char.inspect} but the end of the input was reached" else raise "Expected one of #{char.inspect}, #{alternatives.join(", ", &.inspect)} but reached the input end" end end unless char?(char, *alternatives) raise "Unexpected char: #{current_char.inspect}" end end def consume_number(max_digits) consume_number_i64(max_digits).to_i end def consume_number?(max_digits) consume_number_i64?(max_digits).try(&.to_i) end def consume_number_i64(max_digits) consume_number_i64?(max_digits) || raise "Invalid number" end def consume_number_i64?(max_digits) n = 0_i64 char = current_char if char.ascii_number? n = (char - '0').to_i64 char = next_char else return nil end max_digits -= 1 while max_digits > 0 && char.ascii_number? n = n * 10 + (char - '0') char = next_char max_digits -= 1 end n end def consume_number_blank_padded(max_digits) if current_char.ascii_whitespace? max_digits -= 1 next_char end consume_number(max_digits) end def consume_string start_pos = @reader.pos while current_char.ascii_letter? next_char end @reader.string.byte_slice(start_pos, @reader.pos - start_pos) end def skip_space next_char if current_char.ascii_whitespace? end def skip_spaces while current_char.ascii_whitespace? next_char end end def whitespace unless current_char.ascii_whitespace? ::raise "Unexpected char: #{current_char.inspect}" end next_char end def current_char @reader.current_char end def next_char @reader.next_char end def raise(message, pos = @reader.pos) string = @reader.string if pos.is_a?(Int) string = "#{string.byte_slice(0, pos)}>>#{string.byte_slice(pos, string.bytesize - pos)}" ::raise Error.new("#{message} at #{@reader.pos}: #{string.inspect}") else ::raise Error.new("#{message}: #{string.inspect}") end end end end