lib/active_support/duration/iso8601_parser.rb



# frozen_string_literal: true

require "strscan"

module ActiveSupport
  class Duration
    # Parses a string formatted according to ISO 8601 Duration into the hash.
    #
    # See {ISO 8601}[https://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 = "."
      COMMA = ","

      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
          PERIOD_OR_COMMA.match?(scanner[1]) ? 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

          true
        end
    end
  end
end