lib/active_support/number_helper/number_converter.rb



# frozen_string_literal: true

require "bigdecimal"
require "bigdecimal/util"
require "active_support/core_ext/big_decimal/conversions"
require "active_support/core_ext/hash/keys"
require "active_support/i18n"
require "active_support/core_ext/class/attribute"

module ActiveSupport
  module NumberHelper
    class NumberConverter # :nodoc:
      # Default and i18n option namespace per class
      class_attribute :namespace

      # Does the object need a number that is a valid float?
      class_attribute :validate_float

      attr_reader :number, :opts

      DEFAULTS = {
        # Used in number_to_delimited
        # These are also the defaults for 'currency', 'percentage', 'precision', and 'human'
        format: {
          # Sets the separator between the units, for more precision (e.g. 1.0 / 2.0 == 0.5)
          separator: ".",
          # Delimits thousands (e.g. 1,000,000 is a million) (always in groups of three)
          delimiter: ",",
          # Number of decimals, behind the separator (the number 1 with a precision of 2 gives: 1.00)
          precision: 3,
          # If set to true, precision will mean the number of significant digits instead
          # of the number of decimal digits (1234 with precision 2 becomes 1200, 1.23543 becomes 1.2)
          significant: false,
          # If set, the zeros after the decimal separator will always be stripped (e.g.: 1.200 will be 1.2)
          strip_insignificant_zeros: false
        },

        # Used in number_to_currency
        currency: {
          format: {
            format: "%u%n",
            negative_format: "-%u%n",
            unit: "$",
            # These five are to override number.format and are optional
            separator: ".",
            delimiter: ",",
            precision: 2,
            significant: false,
            strip_insignificant_zeros: false
          }
        },

        # Used in number_to_percentage
        percentage: {
          format: {
            delimiter: "",
            format: "%n%"
          }
        },

        # Used in number_to_rounded
        precision: {
          format: {
            delimiter: ""
          }
        },

        # Used in number_to_human_size and number_to_human
        human: {
          format: {
            # These five are to override number.format and are optional
            delimiter: "",
            precision: 3,
            significant: true,
            strip_insignificant_zeros: true
          },
          # Used in number_to_human_size
          storage_units: {
            # Storage units output formatting.
            # %u is the storage unit, %n is the number (default: 2 MB)
            format: "%n %u",
            units: {
              byte: "Bytes",
              kb: "KB",
              mb: "MB",
              gb: "GB",
              tb: "TB"
            }
          },
          # Used in number_to_human
          decimal_units: {
            format: "%n %u",
            # Decimal units output formatting
            # By default we will only quantify some of the exponents
            # but the commented ones might be defined or overridden
            # by the user.
            units: {
              # femto: Quadrillionth
              # pico: Trillionth
              # nano: Billionth
              # micro: Millionth
              # mili: Thousandth
              # centi: Hundredth
              # deci: Tenth
              unit: "",
              # ten:
              #   one: Ten
              #   other: Tens
              # hundred: Hundred
              thousand: "Thousand",
              million: "Million",
              billion: "Billion",
              trillion: "Trillion",
              quadrillion: "Quadrillion"
            }
          }
        }
      }

      def self.convert(number, options)
        new(number, options).execute
      end

      def initialize(number, options)
        @number = number
        @opts = options.symbolize_keys
        @options = nil
      end

      def execute
        if !number
          nil
        elsif validate_float? && !valid_bigdecimal
          number
        else
          convert
        end
      end

      private
        def options
          @options ||= format_options.merge(opts)
        end

        def format_options
          default_format_options.merge!(i18n_format_options)
        end

        def default_format_options
          options = DEFAULTS[:format].dup
          options.merge!(DEFAULTS[namespace][:format]) if namespace
          options
        end

        def i18n_format_options
          locale = opts[:locale]
          options = I18n.translate(:'number.format', locale: locale, default: {}).dup

          if namespace
            options.merge!(I18n.translate(:"number.#{namespace}.format", locale: locale, default: {}))
          end

          options
        end

        def translate_number_value_with_default(key, **i18n_options)
          I18n.translate(key, **{ default: default_value(key), scope: :number }.merge!(i18n_options))
        end

        def translate_in_locale(key, **i18n_options)
          translate_number_value_with_default(key, **{ locale: options[:locale] }.merge(i18n_options))
        end

        def default_value(key)
          key.split(".").reduce(DEFAULTS) { |defaults, k| defaults[k.to_sym] }
        end

        def valid_bigdecimal
          case number
          when Float, Rational
            number.to_d(0)
          when String
            BigDecimal(number, exception: false)
          else
            number.to_d rescue nil
          end
        end
    end
  end
end