lib/rspec/core/formatters/exception_presenter.rb



module RSpec
  module Core
    module Formatters
      # @private
      class ExceptionPresenter
        attr_reader :exception, :example, :description, :message_color,
                    :detail_formatter, :extra_detail_formatter, :backtrace_formatter
        private :message_color, :detail_formatter, :extra_detail_formatter, :backtrace_formatter

        def initialize(exception, example, options={})
          @exception               = exception
          @example                 = example
          @message_color           = options.fetch(:message_color)          { RSpec.configuration.failure_color }
          @description             = options.fetch(:description_formatter)  { Proc.new { example.full_description } }.call(self)
          @detail_formatter        = options.fetch(:detail_formatter)       { Proc.new {} }
          @extra_detail_formatter  = options.fetch(:extra_detail_formatter) { Proc.new {} }
          @backtrace_formatter     = options.fetch(:backtrace_formatter)    { RSpec.configuration.backtrace_formatter }
          @indentation             = options.fetch(:indentation, 2)
          @skip_shared_group_trace = options.fetch(:skip_shared_group_trace, false)
          @failure_lines           = options[:failure_lines]
        end

        def message_lines
          add_shared_group_lines(failure_lines, Notifications::NullColorizer)
        end

        def colorized_message_lines(colorizer=::RSpec::Core::Formatters::ConsoleCodes)
          add_shared_group_lines(failure_lines, colorizer).map do |line|
            colorizer.wrap line, message_color
          end
        end

        def formatted_backtrace
          backtrace_formatter.format_backtrace(exception.backtrace, example.metadata)
        end

        def colorized_formatted_backtrace(colorizer=::RSpec::Core::Formatters::ConsoleCodes)
          formatted_backtrace.map do |backtrace_info|
            colorizer.wrap "# #{backtrace_info}", RSpec.configuration.detail_color
          end
        end

        def fully_formatted(failure_number, colorizer=::RSpec::Core::Formatters::ConsoleCodes)
          alignment_basis = "#{' ' * @indentation}#{failure_number}) "
          indentation = ' ' * alignment_basis.length

          "\n#{alignment_basis}#{description_and_detail(colorizer, indentation)}" \
          "\n#{formatted_message_and_backtrace(colorizer, indentation)}" \
          "#{extra_detail_formatter.call(failure_number, colorizer, indentation)}"
        end

        def failure_slash_error_line
          @failure_slash_error_line ||= "Failure/Error: #{read_failed_line.strip}"
        end

      private

        def description_and_detail(colorizer, indentation)
          detail = detail_formatter.call(example, colorizer, indentation)
          return (description || detail) unless description && detail
          "#{description}\n#{indentation}#{detail}"
        end

        if String.method_defined?(:encoding)
          def encoding_of(string)
            string.encoding
          end

          def encoded_string(string)
            RSpec::Support::EncodedString.new(string, Encoding.default_external)
          end
        else # for 1.8.7
          # :nocov:
          def encoding_of(_string)
          end

          def encoded_string(string)
            RSpec::Support::EncodedString.new(string)
          end
          # :nocov:
        end

        def exception_class_name
          name = exception.class.name.to_s
          name = "(anonymous error class)" if name == ''
          name
        end

        def failure_lines
          @failure_lines ||=
            begin
              lines = []
              lines << failure_slash_error_line unless (description == failure_slash_error_line)
              lines << "#{exception_class_name}:" unless exception_class_name =~ /RSpec/
              encoded_string(exception.message.to_s).split("\n").each do |line|
                lines << "  #{line}"
              end
              lines
            end
        end

        def add_shared_group_lines(lines, colorizer)
          return lines if @skip_shared_group_trace

          example.metadata[:shared_group_inclusion_backtrace].each do |frame|
            lines << colorizer.wrap(frame.description, RSpec.configuration.default_color)
          end

          lines
        end

        def read_failed_line
          matching_line = find_failed_line
          unless matching_line
            return "Unable to find matching line from backtrace"
          end

          file_path, line_number = matching_line.match(/(.+?):(\d+)(|:\d+)/)[1..2]

          if File.exist?(file_path)
            File.readlines(file_path)[line_number.to_i - 1] ||
              "Unable to find matching line in #{file_path}"
          else
            "Unable to find #{file_path} to read failed line"
          end
        rescue SecurityError
          "Unable to read failed line"
        end

        def find_failed_line
          example_path = example.metadata[:absolute_file_path].downcase
          exception.backtrace.find do |line|
            next unless (line_path = line[/(.+?):(\d+)(|:\d+)/, 1])
            File.expand_path(line_path).downcase == example_path
          end
        end

        def formatted_message_and_backtrace(colorizer, indentation)
          lines = colorized_message_lines(colorizer) + colorized_formatted_backtrace(colorizer)

          formatted = ""

          lines.each do |line|
            formatted << RSpec::Support::EncodedString.new("#{indentation}#{line}\n", encoding_of(formatted))
          end

          formatted
        end

        # @private
        # Configuring the `ExceptionPresenter` with the right set of options to handle
        # pending vs failed vs skipped and aggregated (or not) failures is not simple.
        # This class takes care of building an appropriate `ExceptionPresenter` for the
        # provided example.
        class Factory
          def build
            ExceptionPresenter.new(@exception, @example, options)
          end

        private

          def initialize(example)
            @example          = example
            @execution_result = example.execution_result
            @exception        = if @execution_result.status == :pending
                                  @execution_result.pending_exception
                                else
                                  @execution_result.exception
                                end
          end

          def options
            with_multiple_error_options_as_needed(@exception, pending_options || {})
          end

          def pending_options
            if @execution_result.pending_fixed?
              {
                :description_formatter => Proc.new { "#{@example.full_description} FIXED" },
                :message_color         => RSpec.configuration.fixed_color,
                :failure_lines         => [
                  "Expected pending '#{@execution_result.pending_message}' to fail. No Error was raised."
                ]
              }
            elsif @execution_result.status == :pending
              {
                :message_color    => RSpec.configuration.pending_color,
                :detail_formatter => PENDING_DETAIL_FORMATTER
              }
            end
          end

          def with_multiple_error_options_as_needed(exception, options)
            return options unless multiple_exceptions_error?(exception)

            options = options.merge(
              :failure_lines          => [],
              :extra_detail_formatter => sub_failure_list_formatter(exception, options[:message_color]),
              :detail_formatter       => multiple_exception_summarizer(exception,
                                                                       options[:detail_formatter],
                                                                       options[:message_color])
            )

            options[:description_formatter] &&= Proc.new {}

            return options unless exception.aggregation_metadata[:hide_backtrace]
            options[:backtrace_formatter] = EmptyBacktraceFormatter
            options
          end

          def multiple_exceptions_error?(exception)
            MultipleExceptionError::InterfaceTag === exception
          end

          def multiple_exception_summarizer(exception, prior_detail_formatter, color)
            lambda do |example, colorizer, indentation|
              summary = if exception.aggregation_metadata[:hide_backtrace]
                          # Since the backtrace is hidden, the subfailures will come
                          # immediately after this, and using `:` will read well.
                          "Got #{exception.exception_count_description}:"
                        else
                          # The backtrace comes after this, so using a `:` doesn't make sense
                          # since the failures may be many lines below.
                          "#{exception.summary}."
                        end

              summary = colorizer.wrap(summary, color || RSpec.configuration.failure_color)
              return summary unless prior_detail_formatter
              "#{prior_detail_formatter.call(example, colorizer, indentation)}\n#{indentation}#{summary}"
            end
          end

          def sub_failure_list_formatter(exception, message_color)
            common_backtrace_truncater = CommonBacktraceTruncater.new(exception)

            lambda do |failure_number, colorizer, indentation|
              exception.all_exceptions.each_with_index.map do |failure, index|
                options = with_multiple_error_options_as_needed(
                  failure,
                  :description_formatter   => :failure_slash_error_line.to_proc,
                  :indentation             => indentation.length,
                  :message_color           => message_color || RSpec.configuration.failure_color,
                  :skip_shared_group_trace => true
                )

                failure   = common_backtrace_truncater.with_truncated_backtrace(failure)
                presenter = ExceptionPresenter.new(failure, @example, options)
                presenter.fully_formatted("#{failure_number}.#{index + 1}", colorizer)
              end.join
            end
          end

          # @private
          # Used to prevent a confusing backtrace from showing up from the `aggregate_failures`
          # block declared for `:aggregate_failures` metadata.
          module EmptyBacktraceFormatter
            def self.format_backtrace(*)
              []
            end
          end

          # @private
          class CommonBacktraceTruncater
            def initialize(parent)
              @parent = parent
            end

            def with_truncated_backtrace(child)
              child_bt  = child.backtrace
              parent_bt = @parent.backtrace
              return child if child_bt.nil? || child_bt.empty? || parent_bt.nil?

              index_before_first_common_frame = -1.downto(-child_bt.size).find do |index|
                parent_bt[index] != child_bt[index]
              end

              return child if index_before_first_common_frame == -1

              child = child.dup
              child.set_backtrace(child_bt[0..index_before_first_common_frame])
              child
            end
          end
        end

        # @private
        PENDING_DETAIL_FORMATTER = Proc.new do |example, colorizer|
          colorizer.wrap("# #{example.execution_result.pending_message}", :detail)
        end
      end
    end

    # Provides a single exception instance that provides access to
    # multiple sub-exceptions. This is used in situations where a single
    # individual spec has multiple exceptions, such as one in the `it` block
    # and one in an `after` block.
    class MultipleExceptionError < StandardError
      # @private
      # Used so there is a common module in the ancestor chain of this class
      # and `RSpec::Expectations::MultipleExpectationsNotMetError`, which allows
      # code to detect exceptions that are instances of either, without first
      # checking to see if rspec-expectations is loaded.
      module InterfaceTag
        # Appends the provided exception to the list.
        # @param exception [Exception] Exception to append to the list.
        # @private
        def add(exception)
          # `PendingExampleFixedError` can be assigned to an example that initially has no
          # failures, but when the `aggregate_failures` around hook completes, it notifies of
          # a failure. If we do not ignore `PendingExampleFixedError` it would be surfaced to
          # the user as part of a multiple exception error, which is undesirable. While it's
          # pretty weird we handle this here, it's the best solution I've been able to come
          # up with, and `PendingExampleFixedError` always represents the _lack_ of any exception
          # so clearly when we are transitioning to a `MultipleExceptionError`, it makes sense to
          # ignore it.
          return if Pending::PendingExampleFixedError === exception

          all_exceptions << exception

          if exception.class.name =~ /RSpec/
            failures << exception
          else
            other_errors << exception
          end
        end

        # Provides a way to force `ex` to be something that satisfies the multiple
        # exception error interface. If it already satisfies it, it will be returned;
        # otherwise it will wrap it in a `MultipleExceptionError`.
        # @private
        def self.for(ex)
          return ex if self === ex
          MultipleExceptionError.new(ex)
        end
      end

      include InterfaceTag

      # @return [Array<Exception>] The list of failures.
      attr_reader :failures

      # @return [Array<Exception>] The list of other errors.
      attr_reader :other_errors

      # @return [Array<Exception>] The list of failures and other exceptions, combined.
      attr_reader :all_exceptions

      # @return [Hash] Metadata used by RSpec for formatting purposes.
      attr_reader :aggregation_metadata

      # @return [nil] Provided only for interface compatibility with
      #   `RSpec::Expectations::MultipleExpectationsNotMetError`.
      attr_reader :aggregation_block_label

      # @param exceptions [Array<Exception>] The initial list of exceptions.
      def initialize(*exceptions)
        super()

        @failures                = []
        @other_errors            = []
        @all_exceptions          = []
        @aggregation_metadata    = { :hide_backtrace => true }
        @aggregation_block_label = nil

        exceptions.each { |e| add e }
      end

      # @return [String] Combines all the exception messages into a single string.
      # @note RSpec does not actually use this -- instead it formats each exception
      #   individually.
      def message
        all_exceptions.map(&:message).join("\n\n")
      end

      # @return [String] A summary of the failure, including the block label and a count of failures.
      def summary
        "Got #{exception_count_description}"
      end

      # return [String] A description of the failure/error counts.
      def exception_count_description
        failure_count = Formatters::Helpers.pluralize(failures.size, "failure")
        return failure_count if other_errors.empty?
        error_count = Formatters::Helpers.pluralize(other_errors.size, "other error")
        "#{failure_count} and #{error_count}"
      end
    end
  end
end