class ERBLint::Linters::BaseLinter

Provides the basic linter logic. When inherited, you should define:

Constants

CLASSES
DISALLOWED_CLASSES
DUMP_FILE
REQUIRED_ARGUMENTS
SELF_CLOSING_TAGS

from github.com/Shopify/erb-lint/blob/6179ee2d9d681a6ec4dd02351a1e30eefa748d3d/lib/erb_lint/linters/self_closing_tag.rb

Public Class Methods

inherited(base) click to toggle source
Calls superclass method
# File lib/primer/view_components/linters/base_linter.rb, line 32
def self.inherited(base)
  super
  base.include(ERBLint::LinterRegistry)
  base.config_schema = ConfigSchema
end

Public Instance Methods

autocorrect(processed_source, offense) click to toggle source
# File lib/primer/view_components/linters/base_linter.rb, line 83
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
run(processed_source) click to toggle source
# File lib/primer/view_components/linters/base_linter.rb, line 38
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 unless self.class::TAGS&.include?(tag.name)

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

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

    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

Private Instance Methods

add_correction(tag, tag_tree) click to toggle source
# File lib/primer/view_components/linters/base_linter.rb, line 97
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
build_tag_tree(processed_source) click to toggle source

This assumes that the AST provided represents valid HTML, where each tag has a corresponding closing tag. From the tags, we build a structured tree which represents the tag hierarchy. With this, we are able to know where the tags start and end.

# File lib/primer/view_components/linters/base_linter.rb, line 142
def build_tag_tree(processed_source)
  nodes = processed_source.ast.children
  tag_tree = {}
  tags = []
  current_opened_tag = nil

  nodes.each do |node|
    if node.type == :tag
      # get the tag from previously calculated list so the references are the same
      tag = BetterHtml::Tree::Tag.from_node(node)
      tags << tag

      if tag.closing?
        if current_opened_tag && tag.name == current_opened_tag.name
          tag_tree[current_opened_tag][:closing] = tag
          current_opened_tag = tag_tree[current_opened_tag][:parent]
        end

        next
      end

      self_closing = self_closing?(tag)

      tag_tree[tag] = {
        tag: tag,
        closing: self_closing ? tag : nil,
        parent: current_opened_tag,
        children: []
      }

      tag_tree[current_opened_tag][:children] << tag_tree[tag] if current_opened_tag
      current_opened_tag = tag unless self_closing
    elsif current_opened_tag
      tag_tree[current_opened_tag][:children] << node
    end
  end

  [tags, tag_tree]
end
correct_counter(corrector, processed_source, offense) click to toggle source
# File lib/primer/view_components/linters/base_linter.rb, line 129
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
correction(_tag) click to toggle source

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.

# File lib/primer/view_components/linters/base_linter.rb, line 114
def correction(_tag)
  nil
end
counter_correct?(processed_source) click to toggle source
# File lib/primer/view_components/linters/base_linter.rb, line 190
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
counter_disable() click to toggle source
# File lib/primer/view_components/linters/base_linter.rb, line 125
def counter_disable
  "erblint:counter #{self.class.name.demodulize}"
end
dump_data(processed_source) click to toggle source
# File lib/primer/view_components/linters/base_linter.rb, line 237
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
generate_offense(klass, processed_source, tag, message = nil, replacement = nil) click to toggle source
# File lib/primer/view_components/linters/base_linter.rb, line 230
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
map_arguments(_tag, _tag_tree) click to toggle source

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.

# File lib/primer/view_components/linters/base_linter.rb, line 106
def map_arguments(_tag, _tag_tree)
  nil
end
message(_tag, _processed_source) click to toggle source

Override this function to customize the linter message.

@return [String] message to show on linter error.

# File lib/primer/view_components/linters/base_linter.rb, line 121
def message(_tag, _processed_source)
  self.class::MESSAGE
end
self_closing?(tag) click to toggle source
# File lib/primer/view_components/linters/base_linter.rb, line 182
def self_closing?(tag)
  tag.self_closing? || SELF_CLOSING_TAGS.include?(tag.name)
end
tags(processed_source) click to toggle source
# File lib/primer/view_components/linters/base_linter.rb, line 186
def tags(processed_source)
  processed_source.parser.nodes_with_type(:tag).map { |tag_node| BetterHtml::Tree::Tag.from_node(tag_node) }
end