lib/yard/doctest/example.rb



module YARD
  module Doctest
    class Example < ::Minitest::Spec

      # @return [String] namespace path of example (e.g. `Foo#bar`)
      attr_accessor :definition

      # @return [String] filepath to definition (e.g. `app/app.rb:10`)
      attr_accessor :filepath

      # @return [Array<Hash>] assertions to be done
      attr_accessor :asserts

      #
      # Generates a spec and registers it to Minitest runner.
      #
      def generate
        this = self

        Class.new(this.class).class_eval do
          require 'minitest/autorun'

          %w[. support spec].each do |dir|
            require "#{dir}/doctest_helper" if File.exist?("#{dir}/doctest_helper.rb")
          end

          return if YARD::Doctest.skips.any? { |skip| this.definition.include?(skip) }

          begin
            object_name = this.definition.split(/#|\./).first
            scope = Object.const_get(object_name) if const_defined?(object_name)
          rescue NameError
          end

          describe this.definition do
            # Append this.name to this.definition if YARD's @example tag is followed by
            # descriptive text, to support hooks for multiple examples per code object.
            example_name = if this.name.empty?
                             this.definition
                           else
                             "#{this.definition}@#{this.name}"
                           end

            register_hooks(example_name, YARD::Doctest.hooks)

            it this.name do
              global_constants = Object.constants
              scope_constants = scope.constants if scope
              this.asserts.each do |assert|
                expected, actual = assert[:expected], assert[:actual]
                if expected.empty?
                  evaluate_example(this, actual, scope)
                else
                  assert_example(this, expected, actual, scope)
                end
              end
              clear_extra_constants(Object, global_constants)
              clear_extra_constants(scope, scope_constants) if scope
            end
          end
        end
      end

      protected

      def evaluate_example(example, actual, bind)
        evaluate(actual, bind)
      rescue StandardError => error
        add_filepath_to_backtrace(error, example.filepath)
        raise error
      end

      def assert_example(example, expected, actual, bind)
        assert_equal(evaluate_with_assertion(expected, bind),
                     evaluate_with_assertion(actual, bind))
      rescue Minitest::Assertion => error
        add_filepath_to_backtrace(error, example.filepath)
        raise error
      end

      def evaluate_with_assertion(code, bind)
        evaluate(code, bind)
      rescue StandardError => error
        "#<#{error.class}: #{error}>"
      end

      def evaluate(code, bind)
        context(bind).eval(code)
      end

      def context(bind)
        @context ||= begin
          if bind
            context = bind.class_eval('binding', __FILE__, __LINE__)
            # Oh my god, what is happening here?
            # We need to transplant instance variables from the current binding.
            instance_variables.each do |instance_variable_name|
              local_variable_name = "__yard_doctest__#{instance_variable_name.to_s.delete('@')}"
              context.local_variable_set(local_variable_name, instance_variable_get(instance_variable_name))
              context.eval("#{instance_variable_name} = #{local_variable_name}")
            end
            context
          else
            binding
          end
        end
      end

      def add_filepath_to_backtrace(exception, filepath)
        backtrace = exception.backtrace
        line = backtrace.find { |l| l =~ %r{lib/yard/doctest/example} }
        index = backtrace.index(line)
        backtrace = backtrace.insert(index + 1, filepath)
        exception.set_backtrace backtrace
      end

      def clear_extra_constants(scope, constants)
        (scope.constants - constants).each do |constant|
          scope.__send__(:remove_const, constant)
        end
      end

      def self.register_hooks(example_name, all_hooks)
        all_hooks.each do |type, hooks|
          global_hooks = hooks.select { |hook| !hook[:test] }
          test_hooks = hooks.select { |hook| hook[:test] && example_name.include?(hook[:test]) }
          __send__(type) do
            (global_hooks + test_hooks).each { |hook| instance_exec(&hook[:block]) }
          end
        end
      end

    end # Example
  end # Doctest
end # YARD