# frozen_string_literal: true # The Lint/UnneededCopDisableDirective cop needs to be disabled so as # to be able to provide a (bad) example of an unneeded disable. # rubocop:disable Lint/UnneededCopDisableDirective module RuboCop module Cop module Lint # This cop detects instances of rubocop:disable comments that can be # removed without causing any offenses to be reported. It's implemented # as a cop in that it inherits from the Cop base class and calls # add_offense. The unusual part of its implementation is that it doesn't # have any on_* methods or an investigate method. This means that it # doesn't take part in the investigation phase when the other cops do # their work. Instead, it waits until it's called in a later stage of the # execution. The reason it can't be implemented as a normal cop is that # it depends on the results of all other cops to do its work. # # # @example # # bad # # rubocop:disable Metrics/LineLength # x += 1 # # rubocop:enable Metrics/LineLength # # # good # x += 1 class UnneededCopDisableDirective < Cop include NameSimilarity include RangeHelp COP_NAME = 'Lint/UnneededCopDisableDirective' def check(offenses, cop_disabled_line_ranges, comments) unneeded_cops = Hash.new { |h, k| h[k] = Set.new } each_unneeded_disable(cop_disabled_line_ranges, offenses, comments) do |comment, unneeded_cop| unneeded_cops[comment].add(unneeded_cop) end add_offenses(unneeded_cops) end def autocorrect(args) lambda do |corrector| ranges, range = *args # Ranges are sorted by position. range = if range.source.start_with?('#') comment_range_with_surrounding_space(range) else directive_range_in_list(range, ranges) end corrector.remove(range) end end private def comment_range_with_surrounding_space(range) # Eat the entire comment, the preceding space, and the preceding # newline if there is one. original_begin = range.begin_pos range = range_with_surrounding_space(range: range, side: :left, newlines: true) range_with_surrounding_space(range: range, side: :right, # Special for a comment that # begins the file: remove # the newline at the end. newlines: original_begin.zero?) end def directive_range_in_list(range, ranges) # Is there any cop between this one and the end of the line, which # is NOT being removed? if ends_its_line?(ranges.last) && trailing_range?(ranges, range) # Eat the comma on the left. range = range_with_surrounding_space(range: range, side: :left) range = range_with_surrounding_comma(range, :left) end range = range_with_surrounding_comma(range, :right) # Eat following spaces up to EOL, but not the newline itself. range_with_surrounding_space(range: range, side: :right, newlines: false) end def each_unneeded_disable(cop_disabled_line_ranges, offenses, comments, &block) disabled_ranges = cop_disabled_line_ranges[COP_NAME] || [0..0] cop_disabled_line_ranges.each do |cop, line_ranges| each_already_disabled(line_ranges, disabled_ranges, comments) do |comment| yield comment, cop end each_line_range(line_ranges, disabled_ranges, offenses, comments, cop, &block) end end def each_line_range(line_ranges, disabled_ranges, offenses, comments, cop) line_ranges.each_with_index do |line_range, ix| comment = comments.find { |c| c.loc.line == line_range.begin } next if ignore_offense?(disabled_ranges, line_range) unneeded_cop = find_unneeded(comment, offenses, cop, line_range, line_ranges[ix + 1]) yield comment, unneeded_cop if unneeded_cop end end def each_already_disabled(line_ranges, disabled_ranges, comments) line_ranges.each_cons(2) do |previous_range, range| next if ignore_offense?(disabled_ranges, range) next if previous_range.end != range.begin # If a cop is disabled in a range that begins on the same line as # the end of the previous range, it means that the cop was # already disabled by an earlier comment. So it's unneeded # whether there are offenses or not. unneeded_comment = comments.find do |c| c.loc.line == range.begin && # Comments disabling all cops don't count since it's reasonable # to disable a few select cops first and then all cops further # down in the code. !all_disabled?(c) end yield unneeded_comment if unneeded_comment end end def find_unneeded(comment, offenses, cop, line_range, next_line_range) if all_disabled?(comment) # If there's a disable all comment followed by a comment # specifically disabling `cop`, we don't report the `all` # comment. If the disable all comment is truly unneeded, we will # detect that when examining the comments of another cop, and we # get the full line range for the disable all. if (next_line_range.nil? || line_range.last != next_line_range.first) && offenses.none? { |o| line_range.cover?(o.line) } 'all' end else cop_offenses = offenses.select { |o| o.cop_name == cop } cop if cop_offenses.none? { |o| line_range.cover?(o.line) } end end def all_disabled?(comment) comment.text =~ /rubocop\s*:\s*(?:disable|todo)\s+all\b/ end def ignore_offense?(disabled_ranges, line_range) disabled_ranges.any? do |range| range.cover?(line_range.min) && range.cover?(line_range.max) end end def directive_count(comment) match = comment.text.match(CommentConfig::COMMENT_DIRECTIVE_REGEXP) _, cops_string = match.captures cops_string.split(/,\s*/).size end def add_offenses(unneeded_cops) unneeded_cops.each do |comment, cops| if all_disabled?(comment) || directive_count(comment) == cops.size add_offense_for_entire_comment(comment, cops) else add_offense_for_some_cops(comment, cops) end end end def add_offense_for_entire_comment(comment, cops) location = comment.loc.expression cop_list = cops.sort.map { |c| describe(c) } add_offense( [[location], location], location: location, message: "Unnecessary disabling of #{cop_list.join(', ')}." ) end def add_offense_for_some_cops(comment, cops) cop_ranges = cops.map { |c| [c, cop_range(comment, c)] } cop_ranges.sort_by! { |_, r| r.begin_pos } ranges = cop_ranges.map { |_, r| r } cop_ranges.each do |cop, range| add_offense( [ranges, range], location: range, message: "Unnecessary disabling of #{describe(cop)}." ) end end def cop_range(comment, cop) matching_range(comment.loc.expression, cop) || matching_range(comment.loc.expression, Badge.parse(cop).cop_name) || raise("Couldn't find #{cop} in comment: #{comment.text}") end def matching_range(haystack, needle) offset = (haystack.source =~ Regexp.new(Regexp.escape(needle))) return unless offset offset += haystack.begin_pos Parser::Source::Range.new(haystack.source_buffer, offset, offset + needle.size) end def trailing_range?(ranges, range) ranges .drop_while { |r| !r.equal?(range) } .each_cons(2) .map { |range1, range2| range1.end.join(range2.begin).source } .all? { |intervening| intervening =~ /\A\s*,\s*\Z/ } end def describe(cop) if cop == 'all' 'all cops' elsif all_cop_names.include?(cop) "`#{cop}`" else similar = find_similar_name(cop, []) if similar "`#{cop}` (did you mean `#{similar}`?)" else "`#{cop}` (unknown cop)" end end end def collect_variable_like_names(scope) all_cop_names.each { |name| scope << name } end def all_cop_names @all_cop_names ||= Cop.registry.names end def ends_its_line?(range) line = range.source_buffer.source_line(range.last_line) (line =~ /\s*\z/) == range.last_column end end end end end # rubocop:enable Lint/UnneededCopDisableDirective