# frozen_string_literal: true require "rubocop" require "ariadne/classify/utilities" require "ariadne/classify/validation" # :nocov: module RuboCop module Cop module Ariadne # This cop ensures that components use System Arguments instead of CSS classes. # # bad # heroicon(icon: :icon, variant: HeroiconsHelper::Icon::VARIANT_OUTLINE) # heroicon(icon: "icon", variant: HeroiconsHelper::Icon::VARIANT_OUTLINE) # heroicon(icon: "icon-with-dashes") # heroicon(icon: @ivar) # heroicon(icon: condition > "icon" : "other-icon") # # good # ariadne_heroicon(icon: :icon, variant: HeroiconsHelper::Icon::VARIANT_OUTLINE) # ariadne_heroicon(icon: :"icon-with-dashes", variant: HeroiconsHelper::Icon::VARIANT_OUTLINE) # ariadne_heroicon(icon: @ivar, variant: HeroiconsHelper::Icon::VARIANT_OUTLINE) # ariadne_heroicon(icon: condition > "icon" : "other-icon", variant: HeroiconsHelper::Icon::VARIANT_OUTLINE) class AriadneHeroicon < RuboCop::Cop::Cop INVALID_MESSAGE = <<~STR Replace the `heroicon` helper with `ariadne_heroicon`. See https://ariadne.style/view-components/components/heroicon for details. STR ICON_ATTRIBUTES = ["icon", "variant"].freeze SIZE_ATTRIBUTES = ["height", "width", "size"].freeze STRING_ATTRIBUTES = ["aria-", "data-"].freeze REST_ATTRIBUTES = ["title"].freeze VALID_ATTRIBUTES = [*ICON_ATTRIBUTES, *SIZE_ATTRIBUTES, *STRING_ATTRIBUTES, *REST_ATTRIBUTES, "class"].freeze STRING_ATTRIBUTE_REGEX = Regexp.union(STRING_ATTRIBUTES).freeze ATTRIBUTE_REGEX = Regexp.union(VALID_ATTRIBUTES).freeze INVALID_ATTRIBUTE = -1 def on_send(node) return unless node.method_name == :heroicon return unless node.arguments? kwargs = kwargs(node) return unless kwargs.type == :hash attributes = kwargs.keys.map(&:value) # Don't convert unknown attributes return unless attributes.all? { |attribute| attribute.match?(ATTRIBUTE_REGEX) } # Can't convert size return if heroicon_size_attributes(kwargs) == INVALID_ATTRIBUTE # find class pair classes = classes(kwargs) return if classes == INVALID_ATTRIBUTE # check if classes are convertible if classes.present? attributes = ::Ariadne::Classify::Utilities.classes_to_hash(classes) invalid_classes = (attributes[:classes]&.split(" ") || []).select { |class_name| ::Ariadne::Classify::Validation.invalid?(class_name) } # Uses system argument that can't be converted return if invalid_classes.present? end add_offense(node, message: INVALID_MESSAGE) end def autocorrect(node) lambda do |corrector| kwargs = kwargs(node) # Converting arguments for the component classes = classes(kwargs) icon_and_variant = transform_icon_and_variant(kwargs) size_attributes = transform_sizes(kwargs) rest_attributes = rest_args(kwargs) args = arguments_as_string(node, icon_and_variant, size_attributes, rest_attributes, classes) if node.dot? corrector.replace(node.loc.expression, "#{node.receiver.source}.ariadne_heroicon(#{args})") else corrector.replace(node.loc.expression, "ariadne_heroicon(#{args})") end end end private def transform_icon_and_variant(kwargs) kwargs.pairs.each_with_object({}) do |pair, h| next unless ICON_ATTRIBUTES.include?(pair.key.value.to_s) # We only support symbol or string values... h[pair.key.value] = case pair.value.type when :str { value: pair.value.value.to_s, type: :str } when :sym { value: pair.value.source.to_sym, type: :sym } else # ... but calling source will also get when you want, for :const, :if, etc. { value: pair.value.source, type: :other } end end end private def transform_sizes(kwargs) attributes = heroicon_size_attributes(kwargs) attributes.transform_values do |size| if size.between?(10, 16) "" elsif size.between?(22, 26) ":medium" else size end end end private def rest_args(kwargs) kwargs.pairs.each_with_object({}) do |pair, h| next unless REST_ATTRIBUTES.include?(pair.key.value.to_s) h[pair.key.value] = pair.value.source end end private def heroicon_size_attributes(kwargs) kwargs.pairs.each_with_object({}) do |pair, h| next unless SIZE_ATTRIBUTES.include?(pair.key.value.to_s) # We only support string or int values. case pair.value.type when :int h[pair.key.value] = pair.value.source.to_i when :str h[pair.key.value] = pair.value.value.to_i else return INVALID_ATTRIBUTE end end end private def classes(kwargs) # find class pair class_arg = kwargs.pairs.find { |kwarg| kwarg.key.value == :class } return if class_arg.blank? return INVALID_ATTRIBUTE unless class_arg.value.type == :str class_arg.value.value end private def arguments_as_string(node, icon_and_variant, size_attributes, rest_attributes, classes) icon = case icon_and_variant[:icon][:type] when :str "icon: \"#{icon_and_variant[:icon][:value]}\"" when :sym, :other "icon: #{icon_and_variant[:icon][:value]}" end variant = case icon_and_variant[:variant][:type] when :str "variant: \"#{icon_and_variant[:variant][:value]}\"" when :sym, :other "variant: #{icon_and_variant[:variant][:value]}" end args = "#{icon}, #{variant}" size_args = size_attributes_to_string(size_attributes) string_args = string_args_to_string(node) rest_args = rest_args_to_string(rest_attributes) args = "#{args}, #{size_args}" if size_args.present? args = "#{args}, #{rest_args}" if rest_args.present? args = "#{args}, #{utilities_args(classes)}" if classes.present? args = "#{args}, #{string_args}" if string_args.present? args end private def rest_args_to_string(attrs) return if attrs.blank? attrs.map do |key, value| "#{key}: #{value}" end.join(", ") end private def utilities_args(classes) args = ::Ariadne::Classify::Utilities.classes_to_hash(classes) color = case args[:color] when :text_white :on_emphasis when Symbol args[:color].to_s.gsub("text_", "icon_").to_sym end args[:color] = color if color ::Ariadne::Classify::Utilities.hash_to_args(args) end private def size_attributes_to_string(size_attributes) # No arguments if they map to the default size return if size_attributes.blank? || size_attributes.values.all?(&:blank?) # Return mapped argument to `size` return "size: :medium" if size_attributes.values.any?(":medium") size_attributes.map do |key, value| "#{key}: #{value}" end.join(", ") end private def string_args_to_string(node) kwargs = kwargs(node) args = kwargs.pairs.each_with_object([]) do |pair, acc| next unless pair.key.value.to_s.match?(STRING_ATTRIBUTE_REGEX) key = pair.key.value.to_s == "data-test-selector" ? "test_selector" : "\"#{pair.key.value}\"" acc << "#{key}: #{pair.value.source}" end args.join(",") end Kwargs = Struct.new(:keys, :pairs, :type) def kwargs(node) return node.arguments.last if node.arguments.size > 1 keys = node.arguments.first.keys pairs = node.arguments.first.pairs Kwargs.new(keys, pairs, :hash) end private def icon(node) return node.source unless node.type == :str return ":#{node.value}" unless node.value.include?("-") # If the icon contains `-` we need to cast the string as a symbol # E.g: `arrow-down` becomes `:"arrow-down"` ":#{node.source}" end end end end end