bbatsov/rubocop

View on GitHub
lib/rubocop/config_loader.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# frozen_string_literal: true

require 'erb'
require 'yaml'
require_relative 'config_finder'

module RuboCop
  # Raised when a RuboCop configuration file is not found.
  class ConfigNotFoundError < Error
  end

  # 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 ConfigLoader
    DOTFILE = ConfigFinder::DOTFILE
    RUBOCOP_HOME = File.realpath(File.join(File.dirname(__FILE__), '..', '..'))
    DEFAULT_FILE = File.join(RUBOCOP_HOME, 'config', 'default.yml')

    class << self
      include FileFinder

      PENDING_BANNER = <<~BANNER
        The following cops were added to RuboCop, but are not configured. Please set Enabled to either `true` or `false` in your `.rubocop.yml` file.

        Please also note that you can opt-in to new cops by default by adding this to your config:
          AllCops:
            NewCops: enable
      BANNER

      attr_accessor :debug, :ignore_parent_exclusion, :disable_pending_cops, :enable_pending_cops,
                    :ignore_unrecognized_cops
      attr_writer :default_configuration
      attr_reader :loaded_features

      alias debug? debug
      alias ignore_parent_exclusion? ignore_parent_exclusion

      def clear_options
        @debug = nil
        @loaded_features = Set.new
        FileFinder.root_level = nil
      end

      def load_file(file, check: true)
        path = file_path(file)

        hash = load_yaml_configuration(path)

        loaded_features = resolver.resolve_requires(path, hash)
        add_loaded_features(loaded_features)

        resolver.override_department_setting_for_cops({}, hash)
        resolver.resolve_inheritance_from_gems(hash)
        resolver.resolve_inheritance(path, hash, file, debug?)
        hash.delete('inherit_from')

        # Adding missing namespaces only after resolving requires & inheritance,
        # since both can introduce new cops that need to be considered here.
        add_missing_namespaces(path, hash)

        Config.create(hash, path, check: check)
      end

      def load_yaml_configuration(absolute_path)
        file_contents = read_file(absolute_path)
        yaml_code = Dir.chdir(File.dirname(absolute_path)) { ERB.new(file_contents).result }
        yaml_tree = check_duplication(yaml_code, absolute_path)
        hash = yaml_tree_to_hash(yaml_tree) || {}

        puts "configuration from #{absolute_path}" if debug?

        raise(TypeError, "Malformed configuration in #{absolute_path}") unless hash.is_a?(Hash)

        hash
      end

      def add_missing_namespaces(path, hash)
        # Using `hash.each_key` will cause the
        # `can't add a new key into hash during iteration` error
        hash_keys = hash.keys
        hash_keys.each do |key|
          q = Cop::Registry.qualified_cop_name(key, path)
          next if q == key

          hash[q] = hash.delete(key)
        end
      end

      # Return a recursive merge of two hashes. That is, a normal hash merge,
      # with the addition that any value that is a hash, and occurs in both
      # arguments, will also be merged. And so on.
      def merge(base_hash, derived_hash)
        resolver.merge(base_hash, derived_hash)
      end

      # Returns the path of .rubocop.yml searching upwards in the
      # directory structure starting at the given directory where the
      # inspected file is. If no .rubocop.yml is found there, the
      # user's home directory is checked. If there's no .rubocop.yml
      # there either, the path to the default file is returned.
      def configuration_file_for(target_dir)
        ConfigFinder.find_config_path(target_dir)
      end

      def configuration_from_file(config_file, check: true)
        return default_configuration if config_file == DEFAULT_FILE

        config = load_file(config_file, check: check)
        config.validate_after_resolution if check

        if ignore_parent_exclusion?
          print 'Ignoring AllCops/Exclude from parent folders' if debug?
        else
          add_excludes_from_files(config, config_file)
        end

        merge_with_default(config, config_file).tap do |merged_config|
          unless possible_new_cops?(merged_config)
            pending_cops = pending_cops_only_qualified(merged_config.pending_cops)
            warn_on_pending_cops(pending_cops) unless pending_cops.empty?
          end
        end
      end

      def pending_cops_only_qualified(pending_cops)
        pending_cops.select { |cop| Cop::Registry.qualified_cop?(cop.name) }
      end

      def possible_new_cops?(config)
        disable_pending_cops || enable_pending_cops ||
          config.disabled_new_cops? || config.enabled_new_cops?
      end

      def add_excludes_from_files(config, config_file)
        exclusion_file = find_last_file_upwards(DOTFILE, config_file, ConfigFinder.project_root)

        return unless exclusion_file
        return if PathUtil.relative_path(exclusion_file) == PathUtil.relative_path(config_file)

        print 'AllCops/Exclude ' if debug?
        config.add_excludes_from_higher_level(load_file(exclusion_file))
      end

      def default_configuration
        @default_configuration ||= begin
          print 'Default ' if debug?
          load_file(DEFAULT_FILE)
        end
      end

      # @api private
      def inject_defaults!(project_root)
        path = File.join(project_root, 'config', 'default.yml')
        config = load_file(path)
        new_config = ConfigLoader.merge_with_default(config, path)
        puts "configuration from #{path}" if debug?
        @default_configuration = new_config
      end

      # Returns the path RuboCop inferred as the root of the project. No file
      # searches will go past this directory.
      # @deprecated Use `RuboCop::ConfigFinder.project_root` instead.
      def project_root
        warn Rainbow(<<~WARNING).yellow, uplevel: 1
          `RuboCop::ConfigLoader.project_root` is deprecated and will be removed in RuboCop 2.0. \
          Use `RuboCop::ConfigFinder.project_root` instead.
        WARNING

        ConfigFinder.project_root
      end

      def warn_on_pending_cops(pending_cops)
        warn Rainbow(PENDING_BANNER).yellow

        pending_cops.each { |cop| warn_pending_cop cop }

        warn Rainbow('For more information: https://docs.rubocop.org/rubocop/versioning.html').yellow
      end

      def warn_pending_cop(cop)
        version = cop.metadata['VersionAdded'] || 'N/A'

        warn Rainbow("#{cop.name}: # new in #{version}").yellow
        warn Rainbow('  Enabled: true').yellow
      end

      # Merges the given configuration with the default one.
      def merge_with_default(config, config_file, unset_nil: true)
        resolver.merge_with_default(config, config_file, unset_nil: unset_nil)
      end

      # @api private
      # Used to add features that were required inside a config or from
      # the CLI using `--require`.
      def add_loaded_features(loaded_features)
        @loaded_features.merge(Array(loaded_features))
      end

      private

      def file_path(file)
        File.absolute_path(file.is_a?(RemoteConfig) ? file.file : file)
      end

      def resolver
        @resolver ||= ConfigLoaderResolver.new
      end

      def check_duplication(yaml_code, absolute_path)
        smart_path = PathUtil.smart_path(absolute_path)
        YAMLDuplicationChecker.check(yaml_code, absolute_path) do |key1, key2|
          value = key1.value
          # .start_line is only available since ruby 2.5 / psych 3.0
          message = if key1.respond_to? :start_line
                      line1 = key1.start_line + 1
                      line2 = key2.start_line + 1
                      "#{smart_path}:#{line1}: " \
                        "`#{value}` is concealed by line #{line2}"
                    else
                      "#{smart_path}: `#{value}` is concealed by duplicate"
                    end
          warn Rainbow(message).yellow
        end
      end

      # Read the specified file, or exit with a friendly, concise message on
      # stderr. Care is taken to use the standard OS exit code for a "file not
      # found" error.
      def read_file(absolute_path)
        File.read(absolute_path, encoding: Encoding::UTF_8)
      rescue Errno::ENOENT
        raise ConfigNotFoundError, "Configuration file not found: #{absolute_path}"
      end

      def yaml_tree_to_hash(yaml_tree)
        yaml_tree_to_hash!(yaml_tree)
      rescue ::StandardError
        if defined?(::SafeYAML)
          raise 'SafeYAML is unmaintained, no longer needed and should be removed'
        end

        raise
      end

      def yaml_tree_to_hash!(yaml_tree)
        return nil unless yaml_tree

        # Optimization: Because we checked for duplicate keys, we already have the
        # yaml tree and don't need to parse it again.
        # Also see https://github.com/ruby/psych/blob/v5.1.2/lib/psych.rb#L322-L336
        class_loader = YAML::ClassLoader::Restricted.new(%w[Regexp Symbol], [])
        scanner = YAML::ScalarScanner.new(class_loader)
        visitor = YAML::Visitors::ToRuby.new(scanner, class_loader)
        visitor.accept(yaml_tree)
      end
    end

    # Initializing class ivars
    clear_options
  end
end