# 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