rubocop-hq/rubocop

View on GitHub
lib/rubocop/config.rb

Summary

Maintainability
C
1 day
Test Coverage
A
91%
# frozen_string_literal: true

# FIXME: Moving Rails department code to RuboCop Rails will remove
# the following rubocop:disable comment.
# rubocop:disable Metrics/ClassLength
module RuboCop
  # This class represents the configuration of the RuboCop application
  # and all its cops. A Config is associated with a YAML configuration
  # file from which it was read. Several different Configs can be used
  # during a run of the rubocop program, if files in several
  # directories are inspected.
  class Config
    include PathUtil
    include FileFinder
    extend Forwardable

    CopConfig = Struct.new(:name, :metadata)

    DEFAULT_RAILS_VERSION = 5.0
    attr_reader :loaded_path

    def self.create(hash, path, check: true)
      config = new(hash, path)
      config.check if check

      config
    end

    # rubocop:disable Metrics/AbcSize
    def initialize(hash = RuboCop::ConfigLoader.default_configuration, loaded_path = nil)
      @loaded_path = loaded_path
      @for_cop = Hash.new do |h, cop|
        cop_name = cop.respond_to?(:cop_name) ? cop.cop_name : cop
        qualified_cop_name = Cop::Registry.qualified_cop_name(cop_name, loaded_path)
        cop_options = self[qualified_cop_name].dup || {}
        cop_options['Enabled'] = enable_cop?(qualified_cop_name, cop_options)
        h[cop] = h[cop_name] = cop_options
      end
      @hash = hash
      @validator = ConfigValidator.new(self)

      @badge_config_cache = {}.compare_by_identity
      @clusivity_config_exists_cache = {}
    end
    # rubocop:enable Metrics/AbcSize

    def loaded_features
      @loaded_features ||= ConfigLoader.loaded_features
    end

    def check
      deprecation_check { |deprecation_message| warn("#{loaded_path} - #{deprecation_message}") }
      @validator.validate
      make_excludes_absolute
      self
    end

    def validate_after_resolution
      @validator.validate_after_resolution
      self
    end

    def_delegators :@hash, :[], :[]=, :delete, :dig, :each, :key?, :keys, :each_key,
                   :fetch, :map, :merge, :replace, :to_h, :to_hash, :transform_values
    def_delegators :@validator, :validate, :target_ruby_version

    def to_s
      @to_s ||= @hash.to_s
    end

    def signature
      @signature ||= Digest::SHA1.hexdigest(to_s)
    end

    # True if this is a config file that is shipped with RuboCop
    def internal?
      base_config_path = File.expand_path(File.join(ConfigLoader::RUBOCOP_HOME, 'config'))
      File.expand_path(loaded_path).start_with?(base_config_path)
    end

    def make_excludes_absolute
      each_key do |key|
        @validator.validate_section_presence(key)
        next unless self[key]['Exclude']

        self[key]['Exclude'].map! do |exclude_elem|
          if exclude_elem.is_a?(String) && !absolute?(exclude_elem)
            File.expand_path(File.join(base_dir_for_path_parameters, exclude_elem))
          else
            exclude_elem
          end
        end
      end
    end

    def add_excludes_from_higher_level(highest_config)
      return unless highest_config.for_all_cops['Exclude']

      excludes = for_all_cops['Exclude'] ||= []
      highest_config.for_all_cops['Exclude'].each do |path|
        unless path.is_a?(Regexp) || absolute?(path)
          path = File.join(File.dirname(highest_config.loaded_path), path)
        end
        excludes << path unless excludes.include?(path)
      end
    end

    def deprecation_check
      %w[Exclude Include].each do |key|
        plural = "#{key}s"
        next unless for_all_cops[plural]

        for_all_cops[key] = for_all_cops[plural] # Stay backwards compatible.
        for_all_cops.delete(plural)
        yield "AllCops/#{plural} was renamed to AllCops/#{key}"
      end
    end

    # @return [Config] for the given cop / cop name.
    # Note: the 'Enabled' attribute is calculated according to the department's
    # and 'AllCops' configuration; other attributes are not inherited.
    def for_cop(cop)
      @for_cop[cop]
    end

    # @return [Config] for the given cop merged with that of its department (if any)
    # Note: the 'Enabled' attribute is same as that returned by `for_cop`
    def for_badge(badge)
      @badge_config_cache[badge] ||= begin
        department_config = self[badge.department_name]
        cop_config = for_cop(badge.to_s)
        if department_config
          department_config.merge(cop_config)
        else
          cop_config
        end
      end
    end

    # @return [Boolean] whether config for this badge has 'Include' or 'Exclude' keys
    # @api private
    def clusivity_config_for_badge?(badge)
      exists = @clusivity_config_exists_cache[badge.to_s]
      return exists unless exists.nil?

      cop_config = for_badge(badge)
      @clusivity_config_exists_cache[badge.to_s] = cop_config['Include'] || cop_config['Exclude']
    end

    # @return [Config] for the given department name.
    # Note: the 'Enabled' attribute will be present only if specified
    # at the department's level
    def for_department(department_name)
      @for_department ||= Hash.new { |h, dept| h[dept] = self[dept] || {} }
      @for_department[department_name.to_s]
    end

    def for_all_cops
      @for_all_cops ||= self['AllCops'] || {}
    end

    def disabled_new_cops?
      for_all_cops['NewCops'] == 'disable'
    end

    def enabled_new_cops?
      for_all_cops['NewCops'] == 'enable'
    end

    def active_support_extensions_enabled?
      for_all_cops['ActiveSupportExtensionsEnabled']
    end

    def file_to_include?(file)
      relative_file_path = path_relative_to_config(file)

      # Optimization to quickly decide if the given file is hidden (on the top
      # level) and cannot be matched by any pattern.
      is_hidden = relative_file_path.start_with?('.') && !relative_file_path.start_with?('..')
      return false if is_hidden && !possibly_include_hidden?

      absolute_file_path = File.expand_path(file)

      patterns_to_include.any? do |pattern|
        if block_given?
          yield pattern, relative_file_path, absolute_file_path
        else
          match_path?(pattern, relative_file_path) || match_path?(pattern, absolute_file_path)
        end
      end
    end

    def allowed_camel_case_file?(file)
      # Gemspecs are allowed to have dashes because that fits with bundler best
      # practices in the case when the gem is nested under a namespace (e.g.,
      # `bundler-console` conveys `Bundler::Console`).
      return true if File.extname(file) == '.gemspec'

      file_to_include?(file) do |pattern, relative_path, absolute_path|
        /[A-Z]/.match?(pattern.to_s) &&
          (match_path?(pattern, relative_path) || match_path?(pattern, absolute_path))
      end
    end

    # Returns true if there's a chance that an Include pattern matches hidden
    # files, false if that's definitely not possible.
    def possibly_include_hidden?
      return @possibly_include_hidden if defined?(@possibly_include_hidden)

      @possibly_include_hidden = patterns_to_include.any? do |s|
        s.is_a?(Regexp) || s.start_with?('.') || s.include?('/.')
      end
    end

    def file_to_exclude?(file)
      file = File.expand_path(file)
      patterns_to_exclude.any? { |pattern| match_path?(pattern, file) }
    end

    def patterns_to_include
      for_all_cops['Include'] || []
    end

    def patterns_to_exclude
      for_all_cops['Exclude'] || []
    end

    def path_relative_to_config(path)
      relative_path(path, base_dir_for_path_parameters)
    end

    # Paths specified in configuration files starting with .rubocop are
    # relative to the directory where that file is. Paths in other config files
    # are relative to the current directory. This is so that paths in
    # config/default.yml, for example, are not relative to RuboCop's config
    # directory since that wouldn't work.
    def base_dir_for_path_parameters
      @base_dir_for_path_parameters ||=
        if loaded_path && File.basename(loaded_path).start_with?('.rubocop') &&
           loaded_path != File.join(Dir.home, ConfigLoader::DOTFILE)
          File.expand_path(File.dirname(loaded_path))
        else
          Dir.pwd
        end
    end

    def parser_engine
      @parser_engine ||= for_all_cops.fetch('ParserEngine', :parser_whitequark).to_sym
    end

    def target_rails_version
      @target_rails_version ||=
        if for_all_cops['TargetRailsVersion']
          for_all_cops['TargetRailsVersion'].to_f
        elsif target_rails_version_from_bundler_lock_file
          target_rails_version_from_bundler_lock_file
        else
          DEFAULT_RAILS_VERSION
        end
    end

    def smart_loaded_path
      PathUtil.smart_path(@loaded_path)
    end

    # @return [String, nil]
    def bundler_lock_file_path
      return nil unless loaded_path

      base_path = base_dir_for_path_parameters
      ['Gemfile.lock', 'gems.locked'].each do |file_name|
        path = find_file_upwards(file_name, base_path)
        return path if path
      end
      nil
    end

    def pending_cops
      keys.each_with_object([]) do |qualified_cop_name, pending_cops|
        department = department_of(qualified_cop_name)
        next if department && department['Enabled'] == false

        cop_metadata = self[qualified_cop_name]
        next unless cop_metadata['Enabled'] == 'pending'

        pending_cops << CopConfig.new(qualified_cop_name, cop_metadata)
      end
    end

    # Returns target's locked gem versions (i.e. from Gemfile.lock or gems.locked)
    # @returns [Hash{String => Gem::Version}] The locked gem versions, keyed by the gems' names.
    def gem_versions_in_target
      @gem_versions_in_target ||= read_gem_versions_from_target_lockfile
    end

    def inspect # :nodoc:
      "#<#{self.class.name}:#{object_id} @loaded_path=#{loaded_path}>"
    end

    private

    # @return [Float, nil] The Rails version as a `major.minor` Float.
    def target_rails_version_from_bundler_lock_file
      @target_rails_version_from_bundler_lock_file ||= read_rails_version_from_bundler_lock_file
    end

    # @return [Float, nil] The Rails version as a `major.minor` Float.
    def read_rails_version_from_bundler_lock_file
      return nil unless gem_versions_in_target

      # Look for `railties` instead of `rails`, to support apps that only use a subset of `rails`
      # See https://github.com/rubocop/rubocop/pull/11289
      rails_version_in_target = gem_versions_in_target['railties']
      return nil unless rails_version_in_target

      gem_version_to_major_minor_float(rails_version_in_target)
    end

    # @param [Gem::Version] gem_version an object like `Gem::Version.new("7.1.2.3")`
    # @return [Float] The major and minor version, like `7.1`
    def gem_version_to_major_minor_float(gem_version)
      segments = gem_version.canonical_segments
      # segments.fetch(0).to_f + (segments.fetch(1, 0.0).to_f / 10)
      Float("#{segments.fetch(0)}.#{segments.fetch(1, 0)}")
    end

    # @returns [Hash{String => Gem::Version}] The locked gem versions, keyed by the gems' names.
    def read_gem_versions_from_target_lockfile
      lockfile_path = bundler_lock_file_path
      return nil unless lockfile_path

      Lockfile.new(lockfile_path).gem_versions
    end

    def enable_cop?(qualified_cop_name, cop_options)
      # If the cop is explicitly enabled or `Lint/Syntax`, the other checks can be skipped.
      return true if cop_options['Enabled'] == true || qualified_cop_name == 'Lint/Syntax'

      department = department_of(qualified_cop_name)
      cop_enabled = cop_options.fetch('Enabled') { !for_all_cops['DisabledByDefault'] }
      return true if cop_enabled == 'override_department'
      return false if department && department['Enabled'] == false

      cop_enabled
    end

    def department_of(qualified_cop_name)
      *cop_department, _ = qualified_cop_name.split('/')
      return nil if cop_department.empty?

      self[cop_department.join('/')]
    end
  end
end
# rubocop:enable Metrics/ClassLength