lib/rubocop/cop/inclusivity/race.rb in rubocop-inclusivity-1.0.0 vs lib/rubocop/cop/inclusivity/race.rb in rubocop-inclusivity-1.1.0

- old
+ new

@@ -1,7 +1,11 @@ # frozen_string_literal: true +require "active_support" + +# https://github.com/rubocop-hq/rubocop-ast/blob/5cd306f40e5d5ba4dacf78c698354747cdac7825/docs/modules/ROOT/pages/node_types.adoc + module RuboCop module Cop module Inclusivity # Detects potentially insensitive langugae used in variable names and # suggests alternatives that promote inclusivity. @@ -14,44 +18,155 @@ # blacklist = 1 # # # good # banlist = 1 # - class Race < Cop + class Race < Base + extend AutoCorrector + include ActiveSupport::Inflector + + ALLOWLIST = "Allowlist" + OFFENSES = "Offenses" + DOUBLE_QUOTE = "\"" MSG = "`%s` may be insensitive. Consider alternatives: %s" + PARTIAL = "partial" + SINGLE_QUOTE = "'" + SYMBOL = ":" + UTF_8 = "UTF-8" - def on_lvasgn(node) + def simple_substitution(node) name, = *node - return unless name + name && check(node.loc.name, name) + end + alias on_lvasgn simple_substitution + alias on_ivasgn simple_substitution + alias on_cvasgn simple_substitution + alias on_arg simple_substitution + alias on_optarg simple_substitution + alias on_restarg simple_substitution + alias on_kwoptarg simple_substitution + alias on_kwarg simple_substitution + alias on_kwrestarg simple_substitution + alias on_blockarg simple_substitution + alias on_lvar simple_substitution + alias on_cvar simple_substitution - check_name(node, name, node.loc.name) + def on_casgn(node) + _parent, constant_name, _value = *node + check(node.loc.name, constant_name) { |alternative| alternative.upcase } end - alias on_ivasgn on_lvasgn - alias on_cvasgn on_lvasgn - alias on_arg on_lvasgn - alias on_optarg on_lvasgn - alias on_restarg on_lvasgn - alias on_kwoptarg on_lvasgn - alias on_kwarg on_lvasgn - alias on_kwrestarg on_lvasgn - alias on_blockarg on_lvasgn - alias on_lvar on_lvasgn + def on_sym(node) + name, = *node + check(node.source_range, name) { |replacement| node.source[0] == SYMBOL ? ":#{replacement}" : replacement } + end + + def on_str(node) + name, = *node + check(node.source_range, name) do |replacement| + quote = determine_quote(node.source) + "#{quote}#{replacement.downcase}#{quote}" + end + end + + def on_def(node) + name, _args, _forward_args = *node + check(node.loc.name, name) + end + + def on_send(node) + match_methods_and_variables(node) do |variable_name| + check(node.loc.selector, variable_name.to_s.delete_suffix("=")) + end + end + + def on_const(node) + match_consts(node) do |const_name| + check(node.loc.name, const_name) + end + end + private - def check_name(node, name, name_range) - if (alternatives = preferred_language(name)) - msg = message(name, alternatives) - add_offense(node, location: name_range, message: msg) + def_node_matcher :match_methods_and_variables, <<~PATTERN + (send _ $_ ...) + PATTERN + + def_node_matcher :match_consts, <<~PATTERN + (const ... $_) + PATTERN + + def determine_quote(source) + return SINGLE_QUOTE if source[0] == SINGLE_QUOTE + DOUBLE_QUOTE if source[0] == DOUBLE_QUOTE + end + + def check(range, input) + string = input.to_s.encode(Encoding.find(UTF_8), invalid: :replace, undef: :replace, replace: "") + alternatives = preferred_language(string) + return unless alternatives + + add_offense(range, message: format(MSG, string, alternatives.join(", "))) do |corrector| + replacement = block_given? ? yield(alternatives.first) : alternatives.first + corrector.replace(range, replacement) end end def preferred_language(word) - cop_config["Offenses"][word.to_s.downcase] + exclusive_language_matcher.match(word) do |match| + next if allow?(word) + + offense = match[0].downcase + alternatives = cop_config["Offenses"].fetch(offense) + matcher = %r{#{offense}}i + + alternatives.map do |alternative| + word.to_s.gsub(matcher) { |match| replace_match(match, alternative) } + end + end end - def message(insensitive, alternatives) - format(MSG, insensitive, alternatives.join(", ")) + def on_new_investigation + processed_source.comments.each do |comment| + start_position = comment.location.expression.to_range.first + comment.text.split(" ").each do |word| + alternatives = preferred_language(word) + next unless alternatives + + buffer = @processed_source.buffer + end_position = start_position + word.to_s.size + range = Parser::Source::Range.new(buffer, start_position, end_position) + add_offense(range, message: format(MSG, word.to_s, alternatives.join(", "))) do |corrector| + corrector.replace(range, alternatives.first) + end + ensure + start_position += word.size + 1 + end + end + end + + def replace_match(word, alternative) + normalized_alternative = alternative.downcase + return normalized_alternative if word.downcase == word + return normalized_alternative.upcase if word.upcase == word + return underscore(normalized_alternative) if underscore(word) == word + return camelize(normalized_alternative) if camelize(word) == word + return camelize(normalized_alternative, false) if camelize(word, false) == word + word + end + + def allow?(text) + allowlist.any? do |(allow, config)| + config.fetch(PARTIAL, false) ? text.to_s.match(%r{#{allow}}i) : text.to_s.downcase == allow.to_s.downcase + end + end + + def allowlist + @_allowlist ||= cop_config[ALLOWLIST] || {} + end + + def exclusive_language_matcher + @_exclusive_language_matcher ||= %r{(#{cop_config[OFFENSES].keys.join("|")})}i end end end end end