# frozen_string_literal: true require 'yaml' require_relative 'smell_detectors/base_detector' require_relative 'errors/bad_detector_in_comment_error' require_relative 'errors/bad_detector_configuration_key_in_comment_error' require_relative 'errors/garbage_detector_configuration_in_comment_error' module Reek # # A comment header from an abstract syntax tree; found directly above # module, class and method definitions. # class CodeComment CONFIGURATION_REGEX = / :reek: # prefix (\w+) # smell detector e.g.: UncommunicativeVariableName ( \s* (\{.*?\}) # optional details in hash style e.g.: { max_methods: 30 } )? /x.freeze SANITIZE_REGEX = /(#|\n|\s)+/.freeze # Matches '#', newlines and > 1 whitespaces. DISABLE_DETECTOR_CONFIGURATION = '{ enabled: false }' MINIMUM_CONTENT_LENGTH = 2 attr_reader :config # # @param comment [String] the original comment as found in the source code # @param line [Integer] start of the expression the comment belongs to # @param source [String] Path to source file or "string" # def initialize(comment:, line: nil, source: nil) @original_comment = comment @line = line @source = source @config = Hash.new { |hash, key| hash[key] = {} } @original_comment.scan(CONFIGURATION_REGEX) do |detector_name, _option_string, options| CodeCommentValidator.new(detector_name: detector_name, original_comment: original_comment, line: line, source: source, options: options).validate @config.merge! detector_name => YAML.safe_load(options || DISABLE_DETECTOR_CONFIGURATION, permitted_classes: [Regexp]) end end def descriptive? sanitized_comment.split(/\s+/).length >= MINIMUM_CONTENT_LENGTH end private attr_reader :original_comment, :source, :line def sanitized_comment @sanitized_comment ||= original_comment. gsub(CONFIGURATION_REGEX, ''). gsub(SANITIZE_REGEX, ' '). strip end # # A typical configuration via code comment looks like this: # # :reek:DuplicateMethodCall { enabled: false } # # There are a lot of ways a user can introduce some errors here: # # 1.) Unknown smell detector # 2.) Garbage in the detector configuration like { thats: a: bad: config } # 3.) Unknown configuration keys (e.g. by doing a simple typo: "exclude" vs. "exlude" ) # 4.) Bad data types given as values for those keys # This class validates [1], [2] and [3] at the moment but will also validate # [4] in the future. # # @quality :reek:TooManyInstanceVariables { max_instance_variables: 7 } class CodeCommentValidator # # @param detector_name [String] the detector class that was parsed out of the original # comment, e.g. "DuplicateMethodCall" or "UnknownSmellDetector" # @param original_comment [String] the original comment as found in the source code # @param line [Integer] start of the expression the comment belongs to # @param source [String] path to source file or "string" # @param options [String] the configuration options as String for the detector that were # extracted from the original comment def initialize(detector_name:, original_comment:, line:, source:, options: {}) @detector_name = detector_name @original_comment = original_comment @line = line @source = source @options = options @detector_class = nil # We only know this one after our first initial checks @parsed_options = nil # We only know this one after our first initial checks end # # Method can raise the following errors: # * Errors::BadDetectorInCommentError # * Errors::GarbageDetectorConfigurationInCommentError # * Errors::BadDetectorConfigurationKeyInCommentError # @return [undefined] def validate escalate_bad_detector escalate_bad_detector_configuration escalate_unknown_configuration_key end private attr_reader :detector_name, :original_comment, :line, :source, :options, :detector_class, :parsed_options def escalate_bad_detector return if SmellDetectors::BaseDetector.valid_detector?(detector_name) raise Errors::BadDetectorInCommentError.new(detector_name: detector_name, original_comment: original_comment, source: source, line: line) end def escalate_bad_detector_configuration @parsed_options = YAML.safe_load(options || CodeComment::DISABLE_DETECTOR_CONFIGURATION, permitted_classes: [Regexp]) rescue Psych::SyntaxError raise Errors::GarbageDetectorConfigurationInCommentError.new(detector_name: detector_name, original_comment: original_comment, source: source, line: line) end def escalate_unknown_configuration_key @detector_class = SmellDetectors::BaseDetector.to_detector(detector_name) return if given_keys_legit? raise Errors::BadDetectorConfigurationKeyInCommentError.new(detector_name: detector_name, offensive_keys: configuration_keys_difference, original_comment: original_comment, source: source, line: line) end # @return [Boolean] all keys in code comment are applicable to the detector in question def given_keys_legit? given_configuration_keys.subset? valid_detector_keys end # @return [Set] the configuration keys that are found in the code comment def given_configuration_keys parsed_options.keys.map(&:to_sym).to_set end # @return [String] all keys from the code comment that look bad def configuration_keys_difference given_configuration_keys.difference(valid_detector_keys). to_a.map { |key| "'#{key}'" }. join(', ') end # @return [Set] all keys that are legit for the given detector def valid_detector_keys detector_class.configuration_keys end end end end