From d19ef1d065d3c5fa7d1278b8ff1f34334d5a9ee5 Mon Sep 17 00:00:00 2001 From: Ryan Quanz Date: Sun, 29 Mar 2026 23:25:11 -0400 Subject: [PATCH] perf: memoize erb_nodes and tag_nodes in ProcessedSource Add memoized erb_nodes and tag_nodes methods to ProcessedSource so that multiple linters share a single AST traversal per file instead of each traversing independently. Update all linters to use the memoized methods. --- lib/erb_lint/linters/allowed_script_type.rb | 3 +- .../linters/closing_erb_tag_indent.rb | 2 +- lib/erb_lint/linters/comment_syntax.rb | 2 +- lib/erb_lint/linters/deprecated_classes.rb | 2 +- .../linters/no_javascript_tag_helper.rb | 3 +- .../linters/require_input_autocomplete.rb | 14 +++--- lib/erb_lint/linters/require_script_nonce.rb | 14 +++--- lib/erb_lint/linters/right_trim.rb | 2 +- lib/erb_lint/linters/rubocop.rb | 2 +- lib/erb_lint/linters/self_closing_tag.rb | 2 +- lib/erb_lint/linters/space_around_erb_tag.rb | 2 +- lib/erb_lint/linters/space_in_html_tag.rb | 2 +- lib/erb_lint/linters/strict_locals.rb | 2 +- lib/erb_lint/processed_source.rb | 9 ++++ spec/erb_lint/processed_source_spec.rb | 49 +++++++++++++++++++ 15 files changed, 81 insertions(+), 29 deletions(-) create mode 100644 spec/erb_lint/processed_source_spec.rb diff --git a/lib/erb_lint/linters/allowed_script_type.rb b/lib/erb_lint/linters/allowed_script_type.rb index 64a55c2b..dccc47cb 100644 --- a/lib/erb_lint/linters/allowed_script_type.rb +++ b/lib/erb_lint/linters/allowed_script_type.rb @@ -21,8 +21,7 @@ class ConfigSchema < LinterConfig self.config_schema = ConfigSchema def run(processed_source) - parser = processed_source.parser - parser.nodes_with_type(:tag).each do |tag_node| + processed_source.tag_nodes.each do |tag_node| tag = BetterHtml::Tree::Tag.from_node(tag_node) next if tag.closing? next unless tag.name == "script" diff --git a/lib/erb_lint/linters/closing_erb_tag_indent.rb b/lib/erb_lint/linters/closing_erb_tag_indent.rb index 504eaddb..0fb176a2 100644 --- a/lib/erb_lint/linters/closing_erb_tag_indent.rb +++ b/lib/erb_lint/linters/closing_erb_tag_indent.rb @@ -11,7 +11,7 @@ class ClosingErbTagIndent < Linter END_SPACES = /([[:space:]]*)\z/m def run(processed_source) - processed_source.ast.descendants(:erb).each do |erb_node| + processed_source.erb_nodes.each do |erb_node| _, _, code_node, = *erb_node code = code_node.children.first diff --git a/lib/erb_lint/linters/comment_syntax.rb b/lib/erb_lint/linters/comment_syntax.rb index bd7f2b6d..3c0b48e0 100644 --- a/lib/erb_lint/linters/comment_syntax.rb +++ b/lib/erb_lint/linters/comment_syntax.rb @@ -14,7 +14,7 @@ def run(processed_source) file_content = processed_source.file_content return if file_content.empty? - processed_source.ast.descendants(:erb).each do |erb_node| + processed_source.erb_nodes.each do |erb_node| indicator_node, _, code_node, _ = *erb_node next if code_node.nil? diff --git a/lib/erb_lint/linters/deprecated_classes.rb b/lib/erb_lint/linters/deprecated_classes.rb index 040f4f22..7efb0019 100644 --- a/lib/erb_lint/linters/deprecated_classes.rb +++ b/lib/erb_lint/linters/deprecated_classes.rb @@ -89,7 +89,7 @@ def tags(processed_source) end def tag_nodes(processed_source) - processed_source.parser.nodes_with_type(:tag) + processed_source.tag_nodes end def generate_offenses(class_name, range) diff --git a/lib/erb_lint/linters/no_javascript_tag_helper.rb b/lib/erb_lint/linters/no_javascript_tag_helper.rb index 3c25dd6c..bdb32424 100644 --- a/lib/erb_lint/linters/no_javascript_tag_helper.rb +++ b/lib/erb_lint/linters/no_javascript_tag_helper.rb @@ -17,8 +17,7 @@ class ConfigSchema < LinterConfig self.config_schema = ConfigSchema def run(processed_source) - parser = processed_source.parser - parser.ast.descendants(:erb).each do |erb_node| + processed_source.erb_nodes.each do |erb_node| indicator_node, _, code_node, _ = *erb_node indicator = indicator_node&.loc&.source next if indicator == "#" diff --git a/lib/erb_lint/linters/require_input_autocomplete.rb b/lib/erb_lint/linters/require_input_autocomplete.rb index 4a779e5f..6ec245b0 100644 --- a/lib/erb_lint/linters/require_input_autocomplete.rb +++ b/lib/erb_lint/linters/require_input_autocomplete.rb @@ -42,16 +42,14 @@ class RequireInputAutocomplete < Linter ].freeze def run(processed_source) - parser = processed_source.parser - - find_html_input_tags(parser) - find_rails_helper_input_tags(parser) + find_html_input_tags(processed_source) + find_rails_helper_input_tags(processed_source) end private - def find_html_input_tags(parser) - parser.nodes_with_type(:tag).each do |tag_node| + def find_html_input_tags(processed_source) + processed_source.tag_nodes.each do |tag_node| tag = BetterHtml::Tree::Tag.from_node(tag_node) autocomplete_attribute = tag.attributes["autocomplete"] @@ -82,8 +80,8 @@ def html_type_requires_autocomplete_attribute?(type_attribute) type_present && HTML_INPUT_TYPES_REQUIRING_AUTOCOMPLETE.include?(type_attribute.value) end - def find_rails_helper_input_tags(parser) - parser.ast.descendants(:erb).each do |erb_node| + def find_rails_helper_input_tags(processed_source) + processed_source.erb_nodes.each do |erb_node| indicator_node, _, code_node, _ = *erb_node source = code_node.loc.source ruby_node = extract_ruby_node(source) diff --git a/lib/erb_lint/linters/require_script_nonce.rb b/lib/erb_lint/linters/require_script_nonce.rb index e575dfbb..340280d2 100644 --- a/lib/erb_lint/linters/require_script_nonce.rb +++ b/lib/erb_lint/linters/require_script_nonce.rb @@ -11,16 +11,14 @@ class RequireScriptNonce < Linter include LinterRegistry def run(processed_source) - parser = processed_source.parser - - find_html_script_tags(parser) - find_rails_helper_script_tags(parser) + find_html_script_tags(processed_source) + find_rails_helper_script_tags(processed_source) end private - def find_html_script_tags(parser) - parser.nodes_with_type(:tag).each do |tag_node| + def find_html_script_tags(processed_source) + processed_source.tag_nodes.each do |tag_node| tag = BetterHtml::Tree::Tag.from_node(tag_node) nonce_attribute = tag.attributes["nonce"] @@ -52,8 +50,8 @@ def html_javascript_type_attribute?(tag) type_attribute.value_node.to_a[1] != "application/javascript" end - def find_rails_helper_script_tags(parser) - parser.ast.descendants(:erb).each do |erb_node| + def find_rails_helper_script_tags(processed_source) + processed_source.erb_nodes.each do |erb_node| indicator_node, _, code_node, _ = *erb_node source = code_node.loc.source ruby_node = extract_ruby_node(source) diff --git a/lib/erb_lint/linters/right_trim.rb b/lib/erb_lint/linters/right_trim.rb index c96301fb..a995bc7c 100644 --- a/lib/erb_lint/linters/right_trim.rb +++ b/lib/erb_lint/linters/right_trim.rb @@ -13,7 +13,7 @@ class ConfigSchema < LinterConfig self.config_schema = ConfigSchema def run(processed_source) - processed_source.ast.descendants(:erb).each do |erb_node| + processed_source.erb_nodes.each do |erb_node| _, _, _, trim_node = *erb_node next if trim_node.nil? || trim_node.loc.source == @config.enforced_style diff --git a/lib/erb_lint/linters/rubocop.rb b/lib/erb_lint/linters/rubocop.rb index abe8e3b6..d219c476 100644 --- a/lib/erb_lint/linters/rubocop.rb +++ b/lib/erb_lint/linters/rubocop.rb @@ -49,7 +49,7 @@ def autocorrect(_processed_source, offense) private def descendant_nodes(processed_source) - processed_source.ast.descendants(:erb) + processed_source.erb_nodes end def inspect_content(processed_source, erb_node) diff --git a/lib/erb_lint/linters/self_closing_tag.rb b/lib/erb_lint/linters/self_closing_tag.rb index e5fc4cd4..61ac8740 100644 --- a/lib/erb_lint/linters/self_closing_tag.rb +++ b/lib/erb_lint/linters/self_closing_tag.rb @@ -32,7 +32,7 @@ class ConfigSchema < LinterConfig ] def run(processed_source) - processed_source.ast.descendants(:tag).each do |tag_node| + processed_source.tag_nodes.each do |tag_node| tag = BetterHtml::Tree::Tag.from_node(tag_node) next unless SELF_CLOSING_TAGS.include?(tag.name) diff --git a/lib/erb_lint/linters/space_around_erb_tag.rb b/lib/erb_lint/linters/space_around_erb_tag.rb index 0a8ce7d3..791b9a41 100644 --- a/lib/erb_lint/linters/space_around_erb_tag.rb +++ b/lib/erb_lint/linters/space_around_erb_tag.rb @@ -12,7 +12,7 @@ class SpaceAroundErbTag < Linter END_SPACES = /([[:space:]]*)\z/m def run(processed_source) - processed_source.ast.descendants(:erb).each do |erb_node| + processed_source.erb_nodes.each do |erb_node| indicator_node, ltrim, code_node, rtrim = *erb_node indicator = indicator_node&.loc&.source next if indicator == "#" || indicator == "%" diff --git a/lib/erb_lint/linters/space_in_html_tag.rb b/lib/erb_lint/linters/space_in_html_tag.rb index 44360c9b..a2b3972a 100644 --- a/lib/erb_lint/linters/space_in_html_tag.rb +++ b/lib/erb_lint/linters/space_in_html_tag.rb @@ -7,7 +7,7 @@ class SpaceInHtmlTag < Linter include LinterRegistry def run(processed_source) - processed_source.ast.descendants(:tag).each do |tag_node| + processed_source.tag_nodes.each do |tag_node| start_solidus, name, attributes, end_solidus = *tag_node next_loc = name&.loc&.begin_pos || attributes&.loc&.begin_pos || diff --git a/lib/erb_lint/linters/strict_locals.rb b/lib/erb_lint/linters/strict_locals.rb index f533a8f5..49411e00 100644 --- a/lib/erb_lint/linters/strict_locals.rb +++ b/lib/erb_lint/linters/strict_locals.rb @@ -18,7 +18,7 @@ def run(processed_source) file_content = processed_source.file_content return if file_content.empty? - strict_locals_node = processed_source.ast.descendants(:erb).find do |erb_node| + strict_locals_node = processed_source.erb_nodes.find do |erb_node| indicator_node, _, code_node, _ = *erb_node indicator_node_str = indicator_node&.deconstruct&.last diff --git a/lib/erb_lint/processed_source.rb b/lib/erb_lint/processed_source.rb index 1fcb3190..2eb0e6b7 100644 --- a/lib/erb_lint/processed_source.rb +++ b/lib/erb_lint/processed_source.rb @@ -14,6 +14,15 @@ def ast @parser.ast end + # Memoized descendant lookups — many linters traverse the same node types + def erb_nodes + @erb_nodes ||= ast.descendants(:erb).to_a + end + + def tag_nodes + @tag_nodes ||= parser.nodes_with_type(:tag).to_a + end + def source_buffer @source_buffer ||= begin buffer = Parser::Source::Buffer.new(filename) diff --git a/spec/erb_lint/processed_source_spec.rb b/spec/erb_lint/processed_source_spec.rb new file mode 100644 index 00000000..cf6eb453 --- /dev/null +++ b/spec/erb_lint/processed_source_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe ERBLint::ProcessedSource do + let(:file) { <<~FILE } +
+ + + <%= helper_method %> + <% if condition %> + <%= value %> + <% end %> +
+ FILE + let(:processed_source) { described_class.new("file.html.erb", file) } + + describe "#erb_nodes" do + it "returns an array of erb nodes" do + expect(processed_source.erb_nodes).to(be_an(Array)) + expect(processed_source.erb_nodes).not_to(be_empty) + end + + it "returns the same object on subsequent calls" do + expect(processed_source.erb_nodes).to(equal(processed_source.erb_nodes)) + end + + it "matches ast.descendants(:erb)" do + expect(processed_source.erb_nodes).to(eq(processed_source.ast.descendants(:erb).to_a)) + end + end + + describe "#tag_nodes" do + it "returns an array of tag nodes" do + expect(processed_source.tag_nodes).to(be_an(Array)) + expect(processed_source.tag_nodes).not_to(be_empty) + end + + it "returns the same object on subsequent calls" do + expect(processed_source.tag_nodes).to(equal(processed_source.tag_nodes)) + end + + it "matches parser.nodes_with_type(:tag)" do + expect(processed_source.tag_nodes).to(eq(processed_source.parser.nodes_with_type(:tag).to_a)) + end + end +end