lib/rubocop/config_loader.rb



# frozen_string_literal: true

require 'yaml'
require 'pathname'

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 = '.rubocop.yml'.freeze
    RUBOCOP_HOME = File.realpath(File.join(File.dirname(__FILE__), '..', '..'))
    DEFAULT_FILE = File.join(RUBOCOP_HOME, 'config', 'default.yml')
    AUTO_GENERATED_FILE = '.rubocop_todo.yml'.freeze

    class << self
      include FileFinder

      attr_accessor :debug, :auto_gen_config, :ignore_parent_exclusion
      attr_writer :default_configuration

      alias debug? debug
      alias auto_gen_config? auto_gen_config
      alias ignore_parent_exclusion? ignore_parent_exclusion

      def clear_options
        @debug = @auto_gen_config = nil
        FileFinder.root_level = nil
      end

      def load_file(file)
        path = File.absolute_path(file.is_a?(RemoteConfig) ? file.file : file)

        hash = load_yaml_configuration(path)

        # Resolve requires first in case they define additional cops
        resolver.resolve_requires(path, hash)

        add_missing_namespaces(path, hash)
        target_ruby_version_to_f!(hash)

        resolver.resolve_inheritance_from_gems(hash, hash.delete('inherit_gem'))
        resolver.resolve_inheritance(path, hash, file, debug?)

        hash.delete('inherit_from')

        Config.create(hash, path)
      end

      def add_missing_namespaces(path, hash)
        hash.keys.each do |key|
          q = Cop::Cop.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)
        find_file_upwards(DOTFILE, target_dir, use_home: true) || DEFAULT_FILE
      end

      def configuration_from_file(config_file)
        config = load_file(config_file)
        return config if config_file == DEFAULT_FILE

        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)
      end

      def add_excludes_from_files(config, config_file)
        found_files = find_files_upwards(DOTFILE, config_file, use_home: true)
        return if found_files.empty?
        return if PathUtil.relative_path(found_files.last) ==
                  PathUtil.relative_path(config_file)

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

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

      # Merges the given configuration with the default one. If
      # AllCops:DisabledByDefault is true, it changes the Enabled params so
      # that only cops from user configuration are enabled.
      # If AllCops::EnabledByDefault is true, it changes the Enabled params
      # so that only cops explicitly disabled in user configuration are
      # disabled.
      def merge_with_default(config, config_file)
        resolver.merge_with_default(config, config_file)
      end

      def target_ruby_version_to_f!(hash)
        version = 'TargetRubyVersion'
        return unless hash['AllCops'] && hash['AllCops'][version]

        hash['AllCops'][version] = hash['AllCops'][version].to_f
      end

      def add_inheritance_from_auto_generated_file
        file_string = " #{AUTO_GENERATED_FILE}"

        if File.exist?(DOTFILE)
          files = Array(load_yaml_configuration(DOTFILE)['inherit_from'])
          return if files.include?(AUTO_GENERATED_FILE)

          files.unshift(AUTO_GENERATED_FILE)
          file_string = "\n  - " + files.join("\n  - ") if files.size > 1
          rubocop_yml_contents = IO.read(DOTFILE, encoding: Encoding::UTF_8)
                                   .sub(/^inherit_from: *[.\w]+/, '')
                                   .sub(/^inherit_from: *(\n *- *[.\w]+)+/, '')
        end
        write_dotfile(file_string, rubocop_yml_contents)
        puts "Added inheritance from `#{AUTO_GENERATED_FILE}` in `#{DOTFILE}`."
      end

      private

      def write_dotfile(file_string, rubocop_yml_contents)
        File.open(DOTFILE, 'w') do |f|
          f.write "inherit_from:#{file_string}\n"
          f.write "\n#{rubocop_yml_contents}" if rubocop_yml_contents
        end
      end

      def resolver
        @resolver ||= ConfigLoaderResolver.new
      end

      def load_yaml_configuration(absolute_path)
        yaml_code = read_file(absolute_path)
        hash = yaml_safe_load(yaml_code, absolute_path) || {}

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

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

        hash
      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)
        IO.read(absolute_path, encoding: Encoding::UTF_8)
      rescue Errno::ENOENT
        raise ConfigNotFoundError,
              "Configuration file not found: #{absolute_path}"
      end

      def yaml_safe_load(yaml_code, filename)
        if defined?(SafeYAML) && SafeYAML.respond_to?(:load)
          SafeYAML.load(yaml_code, filename,
                        whitelisted_tags: %w[!ruby/regexp])
        else
          YAML.safe_load(yaml_code, [Regexp, Symbol], [], false, filename)
        end
      end
    end

    # Initializing class ivars
    clear_options
  end
end