lib/rubocop/cop/style/magic_comment_format.rb
# frozen_string_literal: true
module RuboCop
module Cop
module Style
# Ensures magic comments are written consistently throughout your code base.
# Looks for discrepancies in separators (`-` vs `_`) and capitalization for
# both magic comment directives and values.
#
# Required capitalization can be set with the `DirectiveCapitalization` and
# `ValueCapitalization` configuration keys.
#
# NOTE: If one of these configuration is set to nil, any capitalization is allowed.
#
# @example EnforcedStyle: snake_case (default)
# # The `snake_case` style will enforce that the frozen string literal
# # comment is written in snake case. (Words separated by underscores)
# # bad
# # frozen-string-literal: true
#
# module Bar
# # ...
# end
#
# # good
# # frozen_string_literal: false
#
# module Bar
# # ...
# end
#
# @example EnforcedStyle: kebab_case
# # The `kebab_case` style will enforce that the frozen string literal
# # comment is written in kebab case. (Words separated by hyphens)
# # bad
# # frozen_string_literal: true
#
# module Baz
# # ...
# end
#
# # good
# # frozen-string-literal: true
#
# module Baz
# # ...
# end
#
# @example DirectiveCapitalization: lowercase (default)
# # bad
# # FROZEN-STRING-LITERAL: true
#
# # good
# # frozen-string-literal: true
#
# @example DirectiveCapitalization: uppercase
# # bad
# # frozen-string-literal: true
#
# # good
# # FROZEN-STRING-LITERAL: true
#
# @example DirectiveCapitalization: nil
# # any capitalization is accepted
#
# # good
# # frozen-string-literal: true
#
# # good
# # FROZEN-STRING-LITERAL: true
#
# @example ValueCapitalization: nil (default)
# # any capitalization is accepted
#
# # good
# # frozen-string-literal: true
#
# # good
# # frozen-string-literal: TRUE
#
# @example ValueCapitalization: lowercase
# # when a value is not given, any capitalization is accepted
#
# # bad
# # frozen-string-literal: TRUE
#
# # good
# # frozen-string-literal: TRUE
#
# @example ValueCapitalization: uppercase
# # bad
# # frozen-string-literal: true
#
# # good
# # frozen-string-literal: TRUE
#
class MagicCommentFormat < Base
include ConfigurableEnforcedStyle
extend AutoCorrector
SNAKE_SEPARATOR = '_'
KEBAB_SEPARATOR = '-'
MSG = 'Prefer %<style>s case for magic comments.'
MSG_VALUE = 'Prefer %<case>s for magic comment values.'
# Value object to extract source ranges for the different parts of a magic comment
class CommentRange
extend Forwardable
DIRECTIVE_REGEXP = Regexp.union(MagicComment::KEYWORDS.map do |_, v|
Regexp.new(v, Regexp::IGNORECASE)
end).freeze
VALUE_REGEXP = Regexp.new("(?:#{DIRECTIVE_REGEXP}:\s*)(.*?)(?=;|$)")
def_delegators :@comment, :text, :loc
attr_reader :comment
def initialize(comment)
@comment = comment
end
# A magic comment can contain one directive (normal style) or
# multiple directives (emacs style)
def directives
@directives ||= begin
matches = []
text.scan(DIRECTIVE_REGEXP) do
offset = Regexp.last_match.offset(0)
matches << loc.expression.adjust(begin_pos: offset.first)
.with(end_pos: loc.expression.begin_pos + offset.last)
end
matches
end
end
# A magic comment can contain one value (normal style) or
# multiple directives (emacs style)
def values
@values ||= begin
matches = []
text.scan(VALUE_REGEXP) do
offset = Regexp.last_match.offset(1)
matches << loc.expression.adjust(begin_pos: offset.first)
.with(end_pos: loc.expression.begin_pos + offset.last)
end
matches
end
end
end
def on_new_investigation
return unless processed_source.ast
magic_comments.each do |comment|
issues = find_issues(comment)
register_offenses(issues) if issues.any?
end
end
private
def magic_comments
processed_source.each_comment_in_lines(leading_comment_lines)
.select { |comment| MagicComment.parse(comment.text).valid? }
.map { |comment| CommentRange.new(comment) }
end
def leading_comment_lines
first_non_comment_token = processed_source.tokens.find { |token| !token.comment? }
if first_non_comment_token
0...first_non_comment_token.line
else
(0..)
end
end
def find_issues(comment)
issues = { directives: [], values: [] }
comment.directives.each do |directive|
issues[:directives] << directive if directive_offends?(directive)
end
comment.values.each do |value| # rubocop:disable Style/HashEachMethods
issues[:values] << value if wrong_capitalization?(value.source, value_capitalization)
end
issues
end
def directive_offends?(directive)
incorrect_separator?(directive.source) ||
wrong_capitalization?(directive.source, directive_capitalization)
end
def register_offenses(issues)
fix_directives(issues[:directives])
fix_values(issues[:values])
end
def fix_directives(issues)
return if issues.empty?
msg = format(MSG, style: expected_style)
issues.each do |directive|
add_offense(directive, message: msg) do |corrector|
replacement = replace_separator(replace_capitalization(directive.source,
directive_capitalization))
corrector.replace(directive, replacement)
end
end
end
def fix_values(issues)
return if issues.empty?
msg = format(MSG_VALUE, case: value_capitalization)
issues.each do |value|
add_offense(value, message: msg) do |corrector|
corrector.replace(value, replace_capitalization(value.source, value_capitalization))
end
end
end
def expected_style
[directive_capitalization, style].compact.join(' ').gsub(/_?case\b/, '')
end
def wrong_separator
style == :snake_case ? KEBAB_SEPARATOR : SNAKE_SEPARATOR
end
def correct_separator
style == :snake_case ? SNAKE_SEPARATOR : KEBAB_SEPARATOR
end
def incorrect_separator?(text)
text[wrong_separator]
end
def wrong_capitalization?(text, expected_case)
return false unless expected_case
case expected_case
when :lowercase
text != text.downcase
when :uppercase
text != text.upcase
end
end
def replace_separator(text)
text.tr(wrong_separator, correct_separator)
end
def replace_capitalization(text, style)
return text unless style
case style
when :lowercase
text.downcase
when :uppercase
text.upcase
end
end
def line_range(line)
processed_source.buffer.line_range(line)
end
def directive_capitalization
cop_config['DirectiveCapitalization']&.to_sym.tap do |style|
unless valid_capitalization?(style)
raise "Unknown `DirectiveCapitalization` #{style} selected!"
end
end
end
def value_capitalization
cop_config['ValueCapitalization']&.to_sym.tap do |style|
unless valid_capitalization?(style)
raise "Unknown `ValueCapitalization` #{style} selected!"
end
end
end
def valid_capitalization?(style)
return true unless style
supported_capitalizations.include?(style)
end
def supported_capitalizations
cop_config['SupportedCapitalizations'].map(&:to_sym)
end
end
end
end
end