lib/ariadne/view_components/linters/base_linter.rb



# frozen_string_literal: true

require "json"
require "openssl"
require "ariadne/view_components/constants"

require_relative "tag_tree_helpers"

# :nocov:

module ERBLint
  module Linters
    # Provides the basic linter logic. When inherited, you should define:
    # * `TAGS` - required - The HTML tags that the component supports. It will be used by the linter to match elements.
    # * `MESSAGE` - required - The message shown when there's an offense.
    # * `CLASSES` - optional - The CSS classes that the component needs. The linter will only match elements with one of those classes.
    # * `REQUIRED_ARGUMENTS` - optional - A list of HTML attributes that are required by the component.
    class BaseLinter < Linter
      include TagTreeHelpers

      DUMP_FILE = ".erblint-counter-ignore.json"
      DISALLOWED_CLASSES = [].freeze
      CLASSES = [].freeze
      REQUIRED_ARGUMENTS = [].freeze

      class ConfigSchema < LinterConfig # rubocop:disable Style/Documentation
        property :override_ignores_if_correctable, accepts: [true, false], default: false, reader: :override_ignores_if_correctable?
      end

      class << self
        def inherited(base)
          super
          base.include(ERBLint::LinterRegistry)
          base.config_schema = ConfigSchema
        end
      end

      def run(processed_source)
        @total_offenses = 0
        @offenses_not_corrected = 0
        (tags, tag_tree) = build_tag_tree(processed_source)

        tags.each do |tag|
          next if tag.closing?
          next if self.class::TAGS&.none?(tag.name)

          classes = tag.attributes["class"]&.value&.split(" ") || []
          tag_tree[tag][:offense] = false

          next if classes.intersect?(self.class::DISALLOWED_CLASSES)
          next unless self.class::CLASSES.blank? || classes.intersect?(self.class::CLASSES)

          args = map_arguments(tag, tag_tree[tag])
          correction = correction(args)

          attributes = tag.attributes.each.map(&:name).join(" ")
          matches_required_attributes = self.class::REQUIRED_ARGUMENTS.blank? || self.class::REQUIRED_ARGUMENTS.all? { |arg| attributes.match?(arg) }

          tag_tree[tag][:offense] = true
          tag_tree[tag][:correctable] = matches_required_attributes && !correction.nil?
          tag_tree[tag][:message] = message(args, processed_source)
          tag_tree[tag][:correction] = correction
        end

        tag_tree.each do |tag, h|
          next unless h[:offense]

          @total_offenses += 1
          # We always fix the offenses using blocks. The closing tag corresponds to `<% end %>`.
          if h[:correctable]
            add_correction(tag, h)
          else
            @offenses_not_corrected += 1
            generate_offense(self.class, processed_source, tag, h[:message])
          end
        end

        counter_correct?(processed_source)

        dump_data(processed_source) if ENV["DUMP_LINT_DATA"] == "1"
      end

      def autocorrect(processed_source, offense)
        return unless offense.context

        lambda do |corrector|
          if offense.context.include?(counter_disable)
            correct_counter(corrector, processed_source, offense)
          else
            corrector.replace(offense.source_range, offense.context)
          end
        end
      end

      private

      def add_correction(tag, tag_tree)
        add_offense(tag.loc, tag_tree[:message], tag_tree[:correction])
        add_offense(tag_tree[:closing].loc, tag_tree[:message], "<% end %>")
      end

      # Override this function to convert the HTML element attributes to argument for a component.
      #
      # @return [Hash] if possible to map all attributes to arguments.
      # @return [Nil] if cannot map to arguments.
      def map_arguments(_tag, _tag_tree)
        nil
      end

      # Override this function to define how to autocorrect an element to a component.
      #
      # @return [String] with the text to replace the HTML element if possible to correct.
      # @return [Nil] if cannot correct element.
      def correction(_tag)
        nil
      end

      # Override this function to customize the linter message.
      #
      # @return [String] message to show on linter error.
      def message(_tag, _processed_source)
        self.class::MESSAGE
      end

      def counter_disable
        "erblint:counter #{self.class.name.demodulize}"
      end

      def correct_counter(corrector, processed_source, offense)
        if processed_source.file_content.include?(counter_disable)
          # update the counter if exists
          corrector.replace(offense.source_range, offense.context)
        else
          # add comment with counter if none
          corrector.insert_before(processed_source.source_buffer.source_range, "#{offense.context}\n")
        end
      end

      def tags(processed_source)
        processed_source.parser.nodes_with_type(:tag).map { |tag_node| BetterHtml::Tree::Tag.from_node(tag_node) }
      end

      def counter_correct?(processed_source)
        comment_node = nil
        expected_count = 0
        rule_name = self.class.name.match(/:?:?(\w+)\Z/)[1]

        processed_source.parser.ast.descendants(:erb).each do |node|
          indicator_node, _, code_node, = *node
          indicator = indicator_node&.loc&.source
          comment = code_node&.loc&.source&.strip

          if indicator == "#" && comment.start_with?("erblint:count") && comment.match(rule_name)
            comment_node = node
            expected_count = comment.match(/\s(\d+)\s?$/)[1].to_i
          end
        end

        # Unless explicitly set, we don't want to mark correctable offenses if the counter is correct.
        if !@config.override_ignores_if_correctable? && expected_count == @total_offenses
          clear_offenses
          return
        end

        if @offenses_not_corrected.zero?
          # have to adjust to get `\n` so we delete the whole line
          add_offense(processed_source.to_source_range(comment_node.loc.adjust(end_pos: 1)), "Unused erblint:count comment for #{rule_name}", "") if comment_node
          return
        end

        first_offense = @offenses[0]

        if comment_node.nil?
          add_offense(processed_source.to_source_range(first_offense.source_range), "#{rule_name}: If you must, add <%# erblint:counter #{rule_name} #{@offenses_not_corrected} %> to bypass this check.", "<%# erblint:counter #{rule_name} #{@offenses_not_corrected} %>")
        elsif expected_count != @offenses_not_corrected
          add_offense(processed_source.to_source_range(comment_node.loc), "Incorrect erblint:counter number for #{rule_name}. Expected: #{expected_count}, actual: #{@offenses_not_corrected}.", "<%# erblint:counter #{rule_name} #{@offenses_not_corrected} %>")
        # the only offenses remaining are not autocorrectable, so we can ignore them
        elsif expected_count == @offenses_not_corrected && @offenses.size == @offenses_not_corrected
          clear_offenses
        end
      end

      def generate_offense(klass, processed_source, tag, message = nil, replacement = nil)
        message ||= klass::MESSAGE
        klass_name = klass.name.demodulize
        offense = ["#{klass_name}:#{message}", tag.node.loc.source].join("\n")
        add_offense(processed_source.to_source_range(tag.loc), offense, replacement)
      end

      def dump_data(processed_source)
        return if @total_offenses.zero?

        data = File.exist?(DUMP_FILE) ? JSON.parse(File.read(DUMP_FILE)) : {}

        data[processed_source.filename] ||= {}
        data[processed_source.filename][self.class.name.demodulize] = @total_offenses

        File.write(DUMP_FILE, JSON.pretty_generate(data))
      end
    end
  end
end