lib/rubocop/config_loader_resolver.rb



# frozen_string_literal: true

require 'yaml'
require 'pathname'

module RuboCop
  # A help class for ConfigLoader that handles configuration resolution.
  class ConfigLoaderResolver
    def resolve_requires(path, hash)
      config_dir = File.dirname(path)
      Array(hash.delete('require')).each do |r|
        if r.start_with?('.')
          require(File.join(config_dir, r))
        else
          require(r)
        end
      end
    end

    def resolve_inheritance(path, hash, file, debug)
      inherited_files = Array(hash['inherit_from'])
      base_configs(path, inherited_files, file)
        .reverse.each_with_index do |base_config, index|
        base_config.each do |k, v|
          next unless v.is_a?(Hash)

          if hash.key?(k)
            v = merge(v, hash[k],
                      cop_name: k, file: file, debug: debug,
                      inherited_file: inherited_files[index],
                      inherit_mode: determine_inherit_mode(hash, k))
          end
          hash[k] = v
        end
      end
    end

    def resolve_inheritance_from_gems(hash, gems)
      (gems || {}).each_pair do |gem_name, config_path|
        if gem_name == 'rubocop'
          raise ArgumentError,
                "can't inherit configuration from the rubocop gem"
        end

        hash['inherit_from'] = Array(hash['inherit_from'])
        Array(config_path).reverse_each do |path|
          # Put gem configuration first so local configuration overrides it.
          hash['inherit_from'].unshift gem_config_path(gem_name, path)
        end
      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)
      default_configuration = ConfigLoader.default_configuration

      disabled_by_default = config.for_all_cops['DisabledByDefault']
      enabled_by_default = config.for_all_cops['EnabledByDefault']

      if disabled_by_default || enabled_by_default
        default_configuration = transform(default_configuration) do |params|
          params.merge('Enabled' => !disabled_by_default)
        end
      end

      if disabled_by_default
        config = handle_disabled_by_default(config, default_configuration)
      end

      Config.new(merge(default_configuration, config,
                       inherit_mode: config['inherit_mode'] || {}),
                 config_file)
    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.
    #
    # rubocop:disable Metrics/AbcSize
    def merge(base_hash, derived_hash, **opts)
      result = base_hash.merge(derived_hash)
      keys_appearing_in_both = base_hash.keys & derived_hash.keys
      keys_appearing_in_both.each do |key|
        if base_hash[key].is_a?(Hash)
          result[key] = merge(base_hash[key], derived_hash[key], **opts)
        elsif should_union?(base_hash, key, opts[:inherit_mode])
          result[key] = base_hash[key] | derived_hash[key]
        elsif opts[:debug]
          warn_on_duplicate_setting(base_hash, derived_hash, key, opts)
        end
      end
      result
    end
    # rubocop:enable Metrics/AbcSize

    private

    def duplicate_setting?(base_hash, derived_hash, key, inherited_file)
      return false if inherited_file.nil? # Not inheritance resolving merge
      return false if inherited_file.start_with?('..') # Legitimate override
      return false if base_hash[key] == derived_hash[key] # Same value
      return false if remote_file?(inherited_file) # Can't change

      Gem.path.none? { |dir| inherited_file.start_with?(dir) } # Can change?
    end

    def warn_on_duplicate_setting(base_hash, derived_hash, key, **opts)
      return unless duplicate_setting?(base_hash, derived_hash,
                                       key, opts[:inherited_file])

      inherit_mode = opts[:inherit_mode]['merge'] ||
                     opts[:inherit_mode]['override']
      return if base_hash[key].is_a?(Array) &&
                inherit_mode && inherit_mode.include?(key)

      puts "#{PathUtil.smart_path(opts[:file])}: " \
           "#{opts[:cop_name]}:#{key} overrides " \
           "the same parameter in #{opts[:inherited_file]}"
    end

    def determine_inherit_mode(hash, key)
      cop_cfg = hash[key]
      local_inherit = cop_cfg.delete('inherit_mode') if cop_cfg.is_a?(Hash)
      local_inherit || hash['inherit_mode'] || {}
    end

    def should_union?(base_hash, key, inherit_mode)
      base_hash[key].is_a?(Array) &&
        inherit_mode &&
        inherit_mode['merge'] &&
        inherit_mode['merge'].include?(key)
    end

    def base_configs(path, inherit_from, file)
      configs = Array(inherit_from).compact.map do |f|
        ConfigLoader.load_file(inherited_file(path, f, file))
      end

      configs.compact
    end

    def inherited_file(path, inherit_from, file)
      if remote_file?(inherit_from)
        RemoteConfig.new(inherit_from, File.dirname(path))
      elsif file.is_a?(RemoteConfig)
        file.inherit_from_remote(inherit_from, path)
      else
        print 'Inheriting ' if ConfigLoader.debug?
        File.expand_path(inherit_from, File.dirname(path))
      end
    end

    def remote_file?(uri)
      regex = URI::DEFAULT_PARSER.make_regexp(%w[http https])
      uri =~ /\A#{regex}\z/
    end

    def handle_disabled_by_default(config, new_default_configuration)
      department_config = config.to_hash.reject { |cop| cop.include?('/') }
      department_config.each do |dept, dept_params|
        # Rails is always disabled by default and the department's Enabled flag
        # works like the --rails command line option, which is that when
        # AllCops:DisabledByDefault is true, each Rails cop must still be
        # explicitly mentioned in user configuration in order to be enabled.
        next if dept == 'Rails'

        next unless dept_params['Enabled']

        new_default_configuration.each do |cop, params|
          next unless cop.start_with?(dept + '/')

          # Retain original default configuration for cops in the department.
          params['Enabled'] = ConfigLoader.default_configuration[cop]['Enabled']
        end
      end

      transform(config) do |params|
        { 'Enabled' => true }.merge(params) # Set true if not set.
      end
    end

    def transform(config)
      Hash[config.map { |cop, params| [cop, yield(params)] }]
    end

    def gem_config_path(gem_name, relative_config_path)
      spec = Gem::Specification.find_by_name(gem_name)
      File.join(spec.gem_dir, relative_config_path)
    rescue Gem::LoadError => e
      raise Gem::LoadError,
            "Unable to find gem #{gem_name}; is the gem installed? #{e}"
    end
  end
end