simplecov-ruby/simplecov

View on GitHub
lib/simplecov/configuration.rb

Summary

Maintainability
A
0 mins
Test Coverage
# frozen_string_literal: true

require "fileutils"
require "docile"
require_relative "formatter/multi_formatter"

module SimpleCov
  #
  # Bundles the configuration options used for SimpleCov. All methods
  # defined here are usable from SimpleCov directly. Please check out
  # SimpleCov documentation for further info.
  #
  module Configuration
    attr_writer :filters, :groups, :formatter, :print_error_status

    #
    # The root for the project. This defaults to the
    # current working directory.
    #
    # Configure with SimpleCov.root('/my/project/path')
    #
    def root(root = nil)
      return @root if defined?(@root) && root.nil?

      @coverage_path = nil # invalidate cache
      @root = File.expand_path(root || Dir.getwd)
    end

    #
    # The name of the output and cache directory. Defaults to 'coverage'
    #
    # Configure with SimpleCov.coverage_dir('cov')
    #
    def coverage_dir(dir = nil)
      return @coverage_dir if defined?(@coverage_dir) && dir.nil?

      @coverage_path = nil # invalidate cache
      @coverage_dir = dir || "coverage"
    end

    #
    # Returns the full path to the output directory using SimpleCov.root
    # and SimpleCov.coverage_dir, so you can adjust this by configuring those
    # values. Will create the directory if it's missing
    #
    def coverage_path
      @coverage_path ||= begin
        coverage_path = File.expand_path(coverage_dir, root)
        FileUtils.mkdir_p coverage_path
        coverage_path
      end
    end

    #
    # Coverage results will always include files matched by this glob, whether
    # or not they were explicitly required. Without this, un-required files
    # will not be present in the final report.
    #
    def track_files(glob)
      @tracked_files = glob
    end

    #
    # Returns the glob that will be used to include files that were not
    # explicitly required.
    #
    def tracked_files
      @tracked_files if defined?(@tracked_files)
    end

    #
    # Returns the list of configured filters. Add filters using SimpleCov.add_filter.
    #
    def filters
      @filters ||= []
    end

    # The name of the command (a.k.a. Test Suite) currently running. Used for result
    # merging and caching. It first tries to make a guess based upon the command line
    # arguments the current test suite is running on and should automatically detect
    # unit tests, functional tests, integration tests, rpsec and cucumber and label
    # them properly. If it fails to recognize the current command, the command name
    # is set to the shell command that the current suite is running on.
    #
    # You can specify it manually with SimpleCov.command_name("test:units") - please
    # also check out the corresponding section in README.rdoc
    def command_name(name = nil)
      @name = name unless name.nil?
      @name ||= SimpleCov::CommandGuesser.guess
      @name
    end

    #
    # Gets or sets the configured formatter.
    #
    # Configure with: SimpleCov.formatter(SimpleCov::Formatter::SimpleFormatter)
    #
    def formatter(formatter = nil)
      return @formatter if defined?(@formatter) && formatter.nil?

      @formatter = formatter
      raise "No formatter configured. Please specify a formatter using SimpleCov.formatter = SimpleCov::Formatter::SimpleFormatter" unless @formatter

      @formatter
    end

    #
    # Sets the configured formatters.
    #
    def formatters=(formatters)
      @formatter = SimpleCov::Formatter::MultiFormatter.new(formatters)
    end

    #
    # Gets the configured formatters.
    #
    def formatters
      if @formatter.is_a?(SimpleCov::Formatter::MultiFormatter)
        @formatter.formatters
      else
        Array(formatter)
      end
    end

    #
    # Whether we should print non-success status codes. This can be
    # configured with the #print_error_status= method.
    #
    def print_error_status
      defined?(@print_error_status) ? @print_error_status : true
    end

    #
    # Certain code blocks (i.e. Ruby-implementation specific code) can be excluded from
    # the coverage metrics by wrapping it inside # :nocov: comment blocks. The nocov token
    # can be configured to be any other string using this.
    #
    # Configure with SimpleCov.nocov_token('skip') or it's alias SimpleCov.skip_token('skip')
    #
    def nocov_token(nocov_token = nil)
      return @nocov_token if defined?(@nocov_token) && nocov_token.nil?

      @nocov_token = nocov_token || "nocov"
    end
    alias skip_token nocov_token

    #
    # Returns the configured groups. Add groups using SimpleCov.add_group
    #
    def groups
      @groups ||= {}
    end

    #
    # Returns the hash of available profiles
    #
    def profiles
      @profiles ||= SimpleCov::Profiles.new
    end

    def adapters
      warn "#{Kernel.caller.first}: [DEPRECATION] #adapters is deprecated. Use #profiles instead."
      profiles
    end

    #
    # Allows you to configure simplecov in a block instead of prepending SimpleCov to all config methods
    # you're calling.
    #
    #     SimpleCov.configure do
    #       add_filter 'foobar'
    #     end
    #
    # This is equivalent to SimpleCov.add_filter 'foobar' and thus makes it easier to set a bunch of configure
    # options at once.
    #
    def configure(&block)
      Docile.dsl_eval(self, &block)
    end

    #
    # Gets or sets the behavior to process coverage results.
    #
    # By default, it will call SimpleCov.result.format!
    #
    # Configure with:
    #
    #     SimpleCov.at_exit do
    #       puts "Coverage done"
    #       SimpleCov.result.format!
    #     end
    #
    def at_exit(&block)
      return Proc.new unless running || block

      @at_exit = block if block
      @at_exit ||= proc { SimpleCov.result.format! }
    end

    # gets or sets the enabled_for_subprocess configuration
    # when true, this will inject SimpleCov code into Process.fork
    def enable_for_subprocesses(value = nil)
      return @enable_for_subprocesses if defined?(@enable_for_subprocesses) && value.nil?

      @enable_for_subprocesses = value || false
    end

    # gets the enabled_for_subprocess configuration
    def enabled_for_subprocesses?
      enable_for_subprocesses
    end

    #
    # Gets or sets the behavior to start a new forked Process.
    #
    # By default, it will add " (Process #{pid})" to the command_name, and start SimpleCov in quiet mode
    #
    # Configure with:
    #
    #     SimpleCov.at_fork do |pid|
    #       SimpleCov.start do
    #         # This needs a unique name so it won't be ovewritten
    #         SimpleCov.command_name "#{SimpleCov.command_name} (subprocess: #{pid})"
    #         # be quiet, the parent process will be in charge of using the regular formatter and checking coverage totals
    #         SimpleCov.print_error_status = false
    #         SimpleCov.formatter SimpleCov::Formatter::SimpleFormatter
    #         SimpleCov.minimum_coverage 0
    #         # start
    #         SimpleCov.start
    #       end
    #     end
    #
    def at_fork(&block)
      @at_fork = block if block
      @at_fork ||= lambda { |pid|
        # This needs a unique name so it won't be ovewritten
        SimpleCov.command_name "#{SimpleCov.command_name} (subprocess: #{pid})"
        # be quiet, the parent process will be in charge of using the regular formatter and checking coverage totals
        SimpleCov.print_error_status = false
        SimpleCov.formatter SimpleCov::Formatter::SimpleFormatter
        SimpleCov.minimum_coverage 0
        # start
        SimpleCov.start
      }
    end

    #
    # Returns the project name - currently assuming the last dirname in
    # the SimpleCov.root is this.
    #
    def project_name(new_name = nil)
      return @project_name if defined?(@project_name) && @project_name && new_name.nil?

      @project_name = new_name if new_name.is_a?(String)
      @project_name ||= File.basename(root.split("/").last).capitalize.tr("_", " ")
    end

    #
    # Defines whether to use result merging so all your test suites (test:units, test:functionals, cucumber, ...)
    # are joined and combined into a single coverage report
    #
    def use_merging(use = nil)
      @use_merging = use unless use.nil?
      @use_merging = true unless defined?(@use_merging) && @use_merging == false
    end

    #
    # Defines the maximum age (in seconds) of a resultset to still be included in merged results.
    # i.e. If you run cucumber features, then later rake test, if the stored cucumber resultset is
    # more seconds ago than specified here, it won't be taken into account when merging (and is also
    # purged from the resultset cache)
    #
    # Of course, this only applies when merging is active (e.g. SimpleCov.use_merging is not false!)
    #
    # Default is 600 seconds (10 minutes)
    #
    # Configure with SimpleCov.merge_timeout(3600) # 1hr
    #
    def merge_timeout(seconds = nil)
      @merge_timeout = seconds if seconds.is_a?(Integer)
      @merge_timeout ||= 600
    end

    #
    # Defines the minimum overall coverage required for the testsuite to pass.
    # SimpleCov will return non-zero if the current coverage is below this threshold.
    #
    # Default is 0% (disabled)
    #
    def minimum_coverage(coverage = nil)
      return @minimum_coverage ||= {} unless coverage

      coverage = {primary_coverage => coverage} if coverage.is_a?(Numeric)

      raise_on_invalid_coverage(coverage, "minimum_coverage")

      @minimum_coverage = coverage
    end

    def raise_on_invalid_coverage(coverage, coverage_setting)
      coverage.each_key { |criterion| raise_if_criterion_disabled(criterion) }
      coverage.each_value do |percent|
        minimum_possible_coverage_exceeded(coverage_setting) if percent && percent > 100
      end
    end

    #
    # Defines the maximum coverage drop at once allowed for the testsuite to pass.
    # SimpleCov will return non-zero if the coverage decreases by more than this threshold.
    #
    # Default is 100% (disabled)
    #
    def maximum_coverage_drop(coverage_drop = nil)
      return @maximum_coverage_drop ||= {} unless coverage_drop

      coverage_drop = {primary_coverage => coverage_drop} if coverage_drop.is_a?(Numeric)

      raise_on_invalid_coverage(coverage_drop, "maximum_coverage_drop")

      @maximum_coverage_drop = coverage_drop
    end

    #
    # Defines the minimum coverage per file required for the testsuite to pass.
    # SimpleCov will return non-zero if the current coverage of the least covered file
    # is below this threshold.
    #
    # Default is 0% (disabled)
    #
    def minimum_coverage_by_file(coverage = nil)
      return @minimum_coverage_by_file ||= {} unless coverage

      coverage = {primary_coverage => coverage} if coverage.is_a?(Numeric)

      raise_on_invalid_coverage(coverage, "minimum_coverage_by_file")

      @minimum_coverage_by_file = coverage
    end

    #
    # Refuses any coverage drop. That is, coverage is only allowed to increase.
    # SimpleCov will return non-zero if the coverage decreases.
    #
    def refuse_coverage_drop(*criteria)
      criteria = coverage_criteria if criteria.empty?

      maximum_coverage_drop(criteria.to_h { |c| [c, 0] })
    end

    #
    # Add a filter to the processing chain.
    # There are four ways to define a filter:
    #
    # * as a String that will then be matched against all source files' file paths,
    #     SimpleCov.add_filter 'app/models' # will reject all your models
    # * as a block which will be passed the source file in question and should either
    #   return a true or false value, depending on whether the file should be removed
    #     SimpleCov.add_filter do |src_file|
    #       File.basename(src_file.filename) == 'environment.rb'
    #     end # Will exclude environment.rb files from the results
    # * as an array of strings that are matched against all source files' file
    #   paths and then ignored (basically string filter multiple times)
    #     SimpleCov.add_filter ['app/models', 'app/helpers'] # ignores both dirs
    # * as an instance of a subclass of SimpleCov::Filter. See the documentation there
    #   on how to define your own filter classes
    #
    def add_filter(filter_argument = nil, &filter_proc)
      filters << parse_filter(filter_argument, &filter_proc)
    end

    #
    # Define a group for files. Works similar to add_filter, only that the first
    # argument is the desired group name and files PASSING the filter end up in the group
    # (while filters exclude when the filter is applicable).
    #
    def add_group(group_name, filter_argument = nil, &filter_proc)
      groups[group_name] = parse_filter(filter_argument, &filter_proc)
    end

    SUPPORTED_COVERAGE_CRITERIA = %i[line branch].freeze
    DEFAULT_COVERAGE_CRITERION = :line
    #
    # Define which coverage criterion should be evaluated.
    #
    # Possible coverage criteria:
    # * :line - coverage based on lines aka has this line been executed?
    # * :branch - coverage based on branches aka has this branch (think conditions) been executed?
    #
    # If not set the default is `:line`
    #
    # @param [Symbol] criterion
    #
    def coverage_criterion(criterion = nil)
      return @coverage_criterion ||= primary_coverage unless criterion

      raise_if_criterion_unsupported(criterion)

      @coverage_criterion = criterion
    end

    def enable_coverage(criterion)
      raise_if_criterion_unsupported(criterion)

      coverage_criteria << criterion
    end

    def primary_coverage(criterion = nil)
      if criterion.nil?
        @primary_coverage ||= DEFAULT_COVERAGE_CRITERION
      else
        raise_if_criterion_disabled(criterion)
        @primary_coverage = criterion
      end
    end

    def coverage_criteria
      @coverage_criteria ||= Set[primary_coverage]
    end

    def coverage_criterion_enabled?(criterion)
      coverage_criteria.member?(criterion)
    end

    def clear_coverage_criteria
      @coverage_criteria = nil
    end

    def branch_coverage?
      branch_coverage_supported? && coverage_criterion_enabled?(:branch)
    end

    def coverage_start_arguments_supported?
      # safe to cache as within one process this value should never
      # change
      return @coverage_start_arguments_supported if defined?(@coverage_start_arguments_supported)

      @coverage_start_arguments_supported = begin
        require "coverage"
        !Coverage.method(:start).arity.zero?
      end
    end

    def branch_coverage_supported?
      coverage_start_arguments_supported? && RUBY_ENGINE != "jruby"
    end

    def coverage_for_eval_supported?
      require "coverage"
      defined?(Coverage.supported?) && Coverage.supported?(:eval)
    end

    def coverage_for_eval_enabled?
      @coverage_for_eval_enabled ||= false
    end

    def enable_coverage_for_eval
      if coverage_for_eval_supported?
        @coverage_for_eval_enabled = true
      else
        warn "Coverage for eval is not available; Use Ruby 3.2.0 or later"
      end
    end

  private

    def raise_if_criterion_disabled(criterion)
      raise_if_criterion_unsupported(criterion)
      # rubocop:disable Style/IfUnlessModifier
      unless coverage_criterion_enabled?(criterion)
        raise "Coverage criterion #{criterion}, is disabled! Please enable it first through enable_coverage #{criterion} (if supported)"
      end
      # rubocop:enable Style/IfUnlessModifier
    end

    def raise_if_criterion_unsupported(criterion)
      # rubocop:disable Style/IfUnlessModifier
      unless SUPPORTED_COVERAGE_CRITERIA.member?(criterion)
        raise "Unsupported coverage criterion #{criterion}, supported values are #{SUPPORTED_COVERAGE_CRITERIA}"
      end
      # rubocop:enable Style/IfUnlessModifier
    end

    def minimum_possible_coverage_exceeded(coverage_option)
      warn "The coverage you set for #{coverage_option} is greater than 100%"
    end

    #
    # The actual filter processor. Not meant for direct use
    #
    def parse_filter(filter_argument = nil, &filter_proc)
      filter = filter_argument || filter_proc

      if filter
        SimpleCov::Filter.build_filter(filter)
      else
        raise ArgumentError, "Please specify either a filter or a block to filter with"
      end
    end
  end
end