135 lines
4.4 KiB
Ruby
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
|