# 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' XDG_CONFIG = 'config.yml' RUBOCOP_HOME = File.realpath(File.join(File.dirname(__FILE__), '..', '..')) DEFAULT_FILE = File.join(RUBOCOP_HOME, 'config', 'default.yml') class << self include FileFinder attr_accessor :debug, :ignore_parent_exclusion, :disable_pending_cops, :enable_pending_cops attr_writer :default_configuration, :project_root 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) # Resolve requires first in case they define additional cops loaded_features = resolver.resolve_requires(path, hash) add_loaded_features(loaded_features) add_missing_namespaces(path, hash) resolver.override_department_setting_for_cops({}, hash) resolver.resolve_inheritance_from_gems(hash) resolver.resolve_inheritance(path, hash, file, debug?) hash.delete('inherit_from') 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 } check_duplication(yaml_code, absolute_path) hash = yaml_safe_load(yaml_code, absolute_path) || {} 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) find_project_dotfile(target_dir) || find_user_dotfile || find_user_xdg_config || DEFAULT_FILE end def configuration_from_file(config_file, check: true) return default_configuration if config_file == DEFAULT_FILE config = load_file(config_file, check: 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| warn_on_pending_cops(merged_config.pending_cops) unless possible_new_cops?(merged_config) end 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, 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 # Returns the path rubocop inferred as the root of the project. No file # searches will go past this directory. def project_root @project_root ||= find_project_root end 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 def warn_on_pending_cops(pending_cops) return if pending_cops.empty? 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 find_project_dotfile(target_dir) find_file_upwards(DOTFILE, target_dir, project_root) end def find_project_root pwd = Dir.pwd gems_file = find_last_file_upwards('Gemfile', pwd) || find_last_file_upwards('gems.rb', pwd) return unless gems_file File.dirname(gems_file) end def find_user_dotfile return unless ENV.key?('HOME') file = File.join(Dir.home, DOTFILE) return file if File.exist?(file) end def find_user_xdg_config xdg_config_home = expand_path(ENV.fetch('XDG_CONFIG_HOME', '~/.config')) xdg_config = File.join(xdg_config_home, 'rubocop', XDG_CONFIG) return xdg_config if File.exist?(xdg_config) end def expand_path(path) File.expand_path(path) rescue ArgumentError # Could happen because HOME or ID could not be determined. Fall back on # using the path literally in that case. path 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_safe_load(yaml_code, filename) yaml_safe_load!(yaml_code, filename) rescue ::StandardError if defined?(::SafeYAML) raise 'SafeYAML is unmaintained, no longer needed and should be removed' end raise end if Gem::Version.new(Psych::VERSION) >= Gem::Version.new('3.1.0') def yaml_safe_load!(yaml_code, filename) YAML.safe_load(yaml_code, permitted_classes: [Regexp, Symbol], permitted_symbols: [], aliases: true, filename: filename) end else # Ruby < 2.6 def yaml_safe_load!(yaml_code, filename) YAML.safe_load(yaml_code, [Regexp, Symbol], [], true, filename) end end end # Initializing class ivars clear_options end end