gitlab-ce/rubocop/cop/gitlab/rails/safe_format.rb

135 lines
4.4 KiB
Ruby

# frozen_string_literal: true
module RuboCop
module Cop
module Gitlab
module Rails
# Enforce `safe_format` for externalized strings with interpolations and `.html_safe`.
#
# @example
# # bad
# _('string %{open}foo%{close}').html_safe % { open: '<b>'.html_safe, close: '</b>'.html_safe }
# format(_('string %{open}foo%{close}').html_safe, open: '<b>'.html_safe, close: '</b>'.html_safe)
# _('foo').html_safe
#
# # good
# safe_format(_('string %{open}foo%{close}'), tag_pair(tag.b, :open, :close))
# safe_format('foo')
#
# # also good no `html_safe`
# format(_('string %{var} number'), var: var)
# _('string %{var} number') % { var: var }
class SafeFormat < RuboCop::Cop::Base
extend RuboCop::Cop::AutoCorrector
MSG = 'Use `safe_format` to interpolate externalized strings. ' \
'See https://docs.gitlab.com/ee/development/i18n/externalization.html#html'
RESTRICT_ON_SEND = %i[_ s_ N_ n_].freeze
# @!method wrapped_by?(node)
def_node_matcher :wrapped_by?, <<~PATTERN
^(send _ %method ...)
PATTERN
def on_send(gettext)
return unless wrapped_by?(gettext, method: :html_safe)
return if wrapped_by?(gettext, method: :safe_format)
call, args = find_call_and_args(gettext)
node_to_replace = call
node_to_replace = node_to_replace.parent if wrapped_by?(node_to_replace, method: :html_safe)
node_to_replace = node_to_replace.parent if wrapped_by?(node_to_replace, method: :html_escape)
add_offense(call.loc.selector) do |corrector|
use_safe_format(corrector, node_to_replace, gettext, args)
end
end
private
def find_call_and_args(node)
call = node
args = []
node.each_ancestor do |ancestor|
break unless ancestor.send_type?
case ancestor.send_type? && ancestor.method_name
when :format
call = ancestor
args = ancestor.arguments.drop(1)
break
when :%
call = ancestor
args = ancestor.arguments
break
end
end
[call, args]
end
def use_safe_format(corrector, node, gettext, args)
receiver = gettext.source
if args&.any?
args = unwrap_args(args)
.then { |args| use_tag_pair(args) }
.then { |args| sourcify_args(args) }
corrector.replace(node, "safe_format(#{receiver}, #{args})")
else
corrector.replace(node, "safe_format(#{receiver})")
end
end
def unwrap_args(args)
return args[0].children if args[0].array_type? || args[0].hash_type?
args
end
# Turns `open: '<b>'.html_safe, close: '</b>'.html_safe` into
# `tag_pair(tag.b, :open, :close)`.
def use_tag_pair(args)
return args unless args.all?(&:pair_type?)
pair_hash = args.to_h { |pair| [pair.key, pair.value] }
seen = Hash.new { |hash, tag| hash[tag] = [] }
tag_pairs = []
args.each do |pair|
# We only care about { a: '<b>'.html_safe }. Ignore the rest
next unless pair.value.send_type? && pair.value.method?(:html_safe) && pair.value.receiver.str_type?
# Extract the tag from `<b>` or `</b>`.
tag = pair.value.receiver.value[%r{^</?(\w+)>}, 1]
next unless tag
seen[tag] << pair
next unless seen[tag].size == 2
closing_tag, opening_tag = seen[tag].sort_by { |pair| pair.value.receiver.value }
pair_hash.delete(closing_tag.key)
pair_hash.delete(opening_tag.key)
keys = [opening_tag, closing_tag].map { |pair| pair.key.value.inspect }.join(", ")
tag_pairs << "tag_pair(tag.#{tag}, #{keys})"
seen[tag].clear
end
tag_pairs + pair_hash.keys.map(&:parent)
end
def sourcify_args(args)
args.map { |arg| arg.respond_to?(:source) ? arg.source : arg }.join(', ')
end
end
end
end
end
end