class ERBLint::Linters::BaseLinter
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.
Constants
- CLASSES
- DISALLOWED_CLASSES
- DUMP_FILE
- REQUIRED_ARGUMENTS
- SELF_CLOSING_TAGS
Public Class Methods
# 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
# 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
# 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
# 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
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
# 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
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
# 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
# File lib/primer/view_components/linters/base_linter.rb, line 125 def counter_disable "erblint:counter #{self.class.name.demodulize}" end
# 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
# 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
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
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
# 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