lib/rspec/support/object_formatter.rb



# frozen_string_literal: true

RSpec::Support.require_rspec_support 'matcher_definition'

module RSpec
  module Support
    # Provide additional output details beyond what `inspect` provides when
    # printing Time, DateTime, or BigDecimal
    # @api private
    class ObjectFormatter # rubocop:disable Metrics/ClassLength
      ELLIPSIS = "..."

      attr_accessor :max_formatted_output_length

      # Methods are deferred to a default instance of the class to maintain the interface
      # For example, calling ObjectFormatter.format is still possible
      def self.default_instance
        @default_instance ||= new
      end

      def self.format(object)
        default_instance.format(object)
      end

      def self.prepare_for_inspection(object)
        default_instance.prepare_for_inspection(object)
      end

      def initialize(max_formatted_output_length=200)
        @max_formatted_output_length = max_formatted_output_length
        @current_structure_stack = []
      end

      def format(object)
        if max_formatted_output_length.nil?
          prepare_for_inspection(object).inspect
        else
          formatted_object = prepare_for_inspection(object).inspect
          if formatted_object.length < max_formatted_output_length
            formatted_object
          else
            beginning = truncate_string formatted_object, 0, max_formatted_output_length / 2
            ending = truncate_string formatted_object, -max_formatted_output_length / 2, -1
            beginning + ELLIPSIS + ending
          end
        end
      end

      # Prepares the provided object to be formatted by wrapping it as needed
      # in something that, when `inspect` is called on it, will produce the
      # desired output.
      #
      # This allows us to apply the desired formatting to hash/array data structures
      # at any level of nesting, simply by walking that structure and replacing items
      # with custom items that have `inspect` defined to return the desired output
      # for that item. Then we can just use `Array#inspect` or `Hash#inspect` to
      # format the entire thing.
      def prepare_for_inspection(object)
        case object
        when Array
          prepare_array(object)
        when Hash
          prepare_hash(object)
        when Symbol
          object
        else
          inspector_class = INSPECTOR_CLASSES.find { |inspector| inspector.can_inspect?(object) }
          inspector_class.new(object, self)
        end
      end

      def prepare_array(array)
        with_entering_structure(array) do
          array.map { |element| prepare_element(element) }
        end
      end

      def prepare_hash(input_hash)
        with_entering_structure(input_hash) do
          sort_hash_keys(input_hash).inject({}) do |output_hash, key_and_value|
            key, value = key_and_value.map { |element| prepare_element(element) }
            output_hash[key] = value
            output_hash
          end
        end
      end

      def sort_hash_keys(input_hash)
        if input_hash.keys.all? { |k| k.is_a?(String) || k.is_a?(Symbol) }
          Hash[input_hash.sort_by { |k, _v| k.to_s }]
        else
          input_hash
        end
      end

      def prepare_element(element)
        if recursive_structure?(element)
          case element
          when Array then InspectableItem.new('[...]')
          when Hash then InspectableItem.new('{...}')
          else raise # This won't happen
          end
        else
          prepare_for_inspection(element)
        end
      end

      def with_entering_structure(structure)
        @current_structure_stack.push(structure)
        return_value = yield
        @current_structure_stack.pop
        return_value
      end

      def recursive_structure?(object)
        @current_structure_stack.any? { |seen_structure| seen_structure.equal?(object) }
      end

      InspectableItem = Struct.new(:text) do
        def inspect
          text
        end

        def pretty_print(pp)
          pp.text(text)
        end
      end

      BaseInspector = Struct.new(:object, :formatter) do
        def self.can_inspect?(_object)
          raise NotImplementedError
        end

        def inspect
          raise NotImplementedError
        end

        def pretty_print(pp)
          pp.text(inspect)
        end
      end

      class TimeInspector < BaseInspector
        FORMAT = "%Y-%m-%d %H:%M:%S"

        def self.can_inspect?(object)
          Time === object
        end

        if Time.method_defined?(:nsec)
          def inspect
            object.strftime("#{FORMAT}.#{"%09d" % object.nsec} %z")
          end
        else # for 1.8.7
          def inspect
            object.strftime("#{FORMAT}.#{"%06d" % object.usec} %z")
          end
        end
      end

      class DateTimeInspector < BaseInspector
        FORMAT = "%a, %d %b %Y %H:%M:%S.%N %z"

        def self.can_inspect?(object)
          defined?(DateTime) && DateTime === object
        end

        # ActiveSupport sometimes overrides inspect. If `ActiveSupport` is
        # defined use a custom format string that includes more time precision.
        def inspect
          if defined?(ActiveSupport)
            object.strftime(FORMAT)
          else
            object.inspect
          end
        end
      end

      class BigDecimalInspector < BaseInspector
        def self.can_inspect?(object)
          defined?(BigDecimal) && BigDecimal === object
        end

        def inspect
          "#{object.to_s('F')} (#{object.inspect})"
        end
      end

      class DescribableMatcherInspector < BaseInspector
        def self.can_inspect?(object)
          Support.is_a_matcher?(object) && object.respond_to?(:description)
        end

        def inspect
          object.description
        end
      end

      class UninspectableObjectInspector < BaseInspector
        OBJECT_ID_FORMAT = '%#016x'

        def self.can_inspect?(object)
          object.inspect
          false
        rescue NoMethodError
          true
        end

        def inspect
          "#<#{klass}:#{native_object_id}>"
        end

        def klass
          Support.class_of(object)
        end

        # http://stackoverflow.com/a/2818916
        def native_object_id
          OBJECT_ID_FORMAT % (object.__id__ << 1)
        rescue NoMethodError
          # In Ruby 1.9.2, BasicObject responds to none of #__id__, #object_id, #id...
          '-'
        end
      end

      class DelegatorInspector < BaseInspector
        def self.can_inspect?(object)
          defined?(Delegator) && Delegator === object
        end

        def inspect
          "#<#{object.class}(#{formatter.format(object.send(:__getobj__))})>"
        end
      end

      class InspectableObjectInspector < BaseInspector
        def self.can_inspect?(object)
          object.inspect
          true
        rescue NoMethodError
          false
        end

        def inspect
          object.inspect
        end
      end

      INSPECTOR_CLASSES = [
        TimeInspector,
        DateTimeInspector,
        BigDecimalInspector,
        UninspectableObjectInspector,
        DescribableMatcherInspector,
        DelegatorInspector,
        InspectableObjectInspector
      ].tap do |classes|
        # 2.4 has improved BigDecimal formatting so we do not need
        # to provide our own.
        # https://github.com/ruby/bigdecimal/pull/42
        classes.delete(BigDecimalInspector) if RUBY_VERSION >= '2.4'
      end

    private

      # Returns the substring defined by the start_index and end_index
      # If the string ends with a partial ANSI code code then that
      # will be removed as printing partial ANSI
      # codes to the terminal can lead to corruption
      def truncate_string(str, start_index, end_index)
        cut_str = str[start_index..end_index]

        # ANSI color codes are like: \e[33m so anything with \e[ and a
        # number without a 'm' is an incomplete color code
        cut_str.sub(/\e\[\d+$/, '')
      end
    end
  end
end