aboutsummaryrefslogtreecommitdiffstats
path: root/activesupport/lib/active_support/duration/iso8601_parser.rb
blob: 07af58ad99914dc58bf520ca98dce65a8c74b9ef (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
require 'strscan'

module ActiveSupport
  class Duration
    # Parses a string formatted according to ISO 8601 Duration into the hash.
    #
    # See {ISO 8601}[http://en.wikipedia.org/wiki/ISO_8601#Durations] for more information.
    #
    # This parser allows negative parts to be present in pattern.
    class ISO8601Parser # :nodoc:
      class ParsingError < ::ArgumentError; end

      PERIOD_OR_COMMA = /\.|,/
      PERIOD = '.'.freeze
      COMMA = ','.freeze

      SIGN_MARKER = /\A\-|\+|/
      DATE_MARKER = /P/
      TIME_MARKER = /T/
      DATE_COMPONENT = /(\-?\d+(?:[.,]\d+)?)(Y|M|D|W)/
      TIME_COMPONENT = /(\-?\d+(?:[.,]\d+)?)(H|M|S)/

      DATE_TO_PART = { 'Y' => :years, 'M' => :months, 'W' => :weeks, 'D' => :days }
      TIME_TO_PART = { 'H' => :hours, 'M' => :minutes, 'S' => :seconds }

      DATE_COMPONENTS = [:years, :months, :days]
      TIME_COMPONENTS = [:hours, :minutes, :seconds]

      attr_reader :parts, :scanner
      attr_accessor :mode, :sign

      def initialize(string)
        @scanner = StringScanner.new(string)
        @parts = {}
        @mode = :start
        @sign = 1
      end

      def parse!
        while !finished?
          case mode
            when :start
              if scan(SIGN_MARKER)
                self.sign = (scanner.matched == '-') ? -1 : 1
                self.mode = :sign
              else
                raise_parsing_error
              end

            when :sign
              if scan(DATE_MARKER)
                self.mode = :date
              else
                raise_parsing_error
              end

            when :date
              if scan(TIME_MARKER)
                self.mode = :time
              elsif scan(DATE_COMPONENT)
                parts[DATE_TO_PART[scanner[2]]] = number * sign
              else
                raise_parsing_error
              end

            when :time
              if scan(TIME_COMPONENT)
                parts[TIME_TO_PART[scanner[2]]] = number * sign
              else
                raise_parsing_error
              end

          end
        end

        validate!
        parts
      end

      private

      def finished?
        scanner.eos?
      end

      # Parses number which can be a float with either comma or period.
      def number
        scanner[1] =~ PERIOD_OR_COMMA ? scanner[1].tr(COMMA, PERIOD).to_f : scanner[1].to_i
      end

      def scan(pattern)
        scanner.scan(pattern)
      end

      def raise_parsing_error(reason = nil)
        raise ParsingError, "Invalid ISO 8601 duration: #{scanner.string.inspect} #{reason}".strip
      end

      # Checks for various semantic errors as stated in ISO 8601 standard.
      def validate!
        raise_parsing_error('is empty duration') if parts.empty?

        # Mixing any of Y, M, D with W is invalid.
        if parts.key?(:weeks) && (parts.keys & DATE_COMPONENTS).any?
          raise_parsing_error('mixing weeks with other date parts not allowed')
        end

        # Specifying an empty T part is invalid.
        if mode == :time && (parts.keys & TIME_COMPONENTS).empty?
          raise_parsing_error('time part marker is present but time part is empty')
        end

        fractions = parts.values.reject(&:zero?).select { |a| (a % 1) != 0 }
        unless fractions.empty? || (fractions.size == 1 && fractions.last == @parts.values.reject(&:zero?).last)
          raise_parsing_error '(only last part can be fractional)'
        end

        return true
      end
    end
  end
end