|
| 1 | +# encoding: UTF-8 |
| 2 | +# frozen_string_literal: true |
| 3 | + |
| 4 | +require 'strscan' |
| 5 | + |
| 6 | +module TZInfo |
| 7 | + # An {InvalidPosixTimeZone} exception is raised if an invalid POSIX-style |
| 8 | + # time zone string is encountered. |
| 9 | + # |
| 10 | + # @private |
| 11 | + class InvalidPosixTimeZone < StandardError #:nodoc: |
| 12 | + end |
| 13 | + |
| 14 | + # A parser for POSIX-style TZ strings used in zoneinfo files and specified |
| 15 | + # by tzfile.5 and tzset.3. |
| 16 | + # |
| 17 | + # @private |
| 18 | + class PosixTimeZoneParser #:nodoc: |
| 19 | + # Parses a POSIX-style TZ string, returning either a TimezoneOffset or |
| 20 | + # an AnnualRules instance. |
| 21 | + def parse(tz_string) |
| 22 | + raise InvalidPosixTimeZone unless tz_string.kind_of?(String) |
| 23 | + return nil if tz_string.empty? |
| 24 | + |
| 25 | + s = StringScanner.new(tz_string) |
| 26 | + check_scan(s, /([^-+,\d<][^-+,\d]*) | <([^>]+)>/x) |
| 27 | + std_abbrev = s[1] || s[2] |
| 28 | + check_scan(s, /([-+]?\d+)(?::(\d+)(?::(\d+))?)?/) |
| 29 | + std_offset = get_offset_from_hms(s[1], s[2], s[3]) |
| 30 | + |
| 31 | + if s.scan(/([^-+,\d<][^-+,\d]*) | <([^>]+)>/x) |
| 32 | + dst_abbrev = s[1] || s[2] |
| 33 | + |
| 34 | + if s.scan(/([-+]?\d+)(?::(\d+)(?::(\d+))?)?/) |
| 35 | + dst_offset = get_offset_from_hms(s[1], s[2], s[3]) |
| 36 | + else |
| 37 | + # POSIX is negative for ahead of UTC. |
| 38 | + dst_offset = std_offset - 3600 |
| 39 | + end |
| 40 | + |
| 41 | + dst_difference = std_offset - dst_offset |
| 42 | + |
| 43 | + start_rule = parse_rule(s, 'start') |
| 44 | + end_rule = parse_rule(s, 'end') |
| 45 | + |
| 46 | + raise InvalidPosixTimeZone, "Expected the end of a POSIX-style time zone string but found '#{s.rest}'." if s.rest? |
| 47 | + |
| 48 | + if start_rule.is_always_first_day_of_year? && start_rule.transition_at == 0 && |
| 49 | + end_rule.is_always_last_day_of_year? && end_rule.transition_at == 86400 + dst_difference |
| 50 | + # Constant daylight savings time. |
| 51 | + # POSIX is negative for ahead of UTC. |
| 52 | + TimezoneOffset.new(-std_offset, dst_difference, dst_abbrev.to_sym) |
| 53 | + else |
| 54 | + AnnualRules.new( |
| 55 | + TimezoneOffset.new(-std_offset, 0, std_abbrev.to_sym), |
| 56 | + TimezoneOffset.new(-std_offset, dst_difference, dst_abbrev.to_sym), |
| 57 | + start_rule, |
| 58 | + end_rule) |
| 59 | + end |
| 60 | + elsif !s.rest? |
| 61 | + # Constant standard time. |
| 62 | + # POSIX is negative for ahead of UTC. |
| 63 | + TimezoneOffset.new(-std_offset, 0, std_abbrev.to_sym) |
| 64 | + else |
| 65 | + raise InvalidPosixTimeZone, "Expected the end of a POSIX-style time zone string but found '#{s.rest}'." |
| 66 | + end |
| 67 | + end |
| 68 | + |
| 69 | + private |
| 70 | + |
| 71 | + # Parses the rule from the TZ string, returning a TransitionRule. |
| 72 | + def parse_rule(s, type) |
| 73 | + check_scan(s, /,(?: (?: J(\d+) ) | (\d+) | (?: M(\d+)\.(\d)\.(\d) ) )/x) |
| 74 | + julian_day_of_year = s[1] |
| 75 | + absolute_day_of_year = s[2] |
| 76 | + month = s[3] |
| 77 | + week = s[4] |
| 78 | + day_of_week = s[5] |
| 79 | + |
| 80 | + if s.scan(/\//) |
| 81 | + check_scan(s, /([-+]?\d+)(?::(\d+)(?::(\d+))?)?/) |
| 82 | + transition_at = get_seconds_after_midnight_from_hms(s[1], s[2], s[3]) |
| 83 | + else |
| 84 | + transition_at = 7200 |
| 85 | + end |
| 86 | + |
| 87 | + begin |
| 88 | + if julian_day_of_year |
| 89 | + JulianDayOfYearTransitionRule.new(julian_day_of_year.to_i, transition_at) |
| 90 | + elsif absolute_day_of_year |
| 91 | + AbsoluteDayOfYearTransitionRule.new(absolute_day_of_year.to_i, transition_at) |
| 92 | + elsif week == '5' |
| 93 | + LastDayOfMonthTransitionRule.new(month.to_i, day_of_week.to_i, transition_at) |
| 94 | + else |
| 95 | + DayOfMonthTransitionRule.new(month.to_i, week.to_i, day_of_week.to_i, transition_at) |
| 96 | + end |
| 97 | + rescue ArgumentError => e |
| 98 | + raise InvalidPosixTimeZone, "Invalid #{type} rule in POSIX-style time zone string: #{e}" |
| 99 | + end |
| 100 | + end |
| 101 | + |
| 102 | + # Returns an offset in seconds from hh:mm:ss values. The value can be |
| 103 | + # negative. -02:33:12 would represent 2 hours, 33 minutes and 12 seconds |
| 104 | + # ahead of UTC. |
| 105 | + def get_offset_from_hms(h, m, s) |
| 106 | + h = h.to_i |
| 107 | + m = m.to_i |
| 108 | + s = s.to_i |
| 109 | + raise InvalidPosixTimeZone, "Invalid minute #{m} in offset for POSIX-style time zone string." if m > 59 |
| 110 | + raise InvalidPosixTimeZone, "Invalid second #{s} in offset for POSIX-style time zone string." if s > 59 |
| 111 | + magnitude = (h.abs * 60 + m) * 60 + s |
| 112 | + h < 0 ? -magnitude : magnitude |
| 113 | + end |
| 114 | + |
| 115 | + # Returns the seconds from midnight from hh:mm:ss values. Hours can exceed |
| 116 | + # 24 for a time on the following day. Hours can be negative to subtract |
| 117 | + # hours from midnight on the given day. -02:33:12 represents 22:33:12 on |
| 118 | + # the prior day. |
| 119 | + def get_seconds_after_midnight_from_hms(h, m, s) |
| 120 | + h = h.to_i |
| 121 | + m = m.to_i |
| 122 | + s = s.to_i |
| 123 | + raise InvalidPosixTimeZone, "Invalid minute #{m} in time for POSIX-style time zone string." if m > 59 |
| 124 | + raise InvalidPosixTimeZone, "Invalid second #{s} in time for POSIX-style time zone string." if s > 59 |
| 125 | + (h * 3600) + m * 60 + s |
| 126 | + end |
| 127 | + |
| 128 | + # Scans for a pattern and raises an exception if the pattern does not |
| 129 | + # match the input. |
| 130 | + def check_scan(s, pattern) |
| 131 | + result = s.scan(pattern) |
| 132 | + raise InvalidPosixTimeZone, "Expected '#{s.rest}' to match #{pattern} in POSIX-style time zone string." unless result |
| 133 | + result |
| 134 | + end |
| 135 | + end |
| 136 | +end |
0 commit comments