Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 139 additions & 23 deletions lib/erb_lint/linters/rubocop.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "better_html"
require "ripper"
require "tempfile"

module ERBLint
Expand All @@ -27,11 +28,21 @@ def initialize(file_loader, config)
custom_config = config_from_path(@config.config_file_path) if @config.config_file_path
custom_config ||= config_from_hash(@config.rubocop_config)
@rubocop_config = ::RuboCop::ConfigLoader.merge_with_default(custom_config, "")
@rubocop_target_ruby_version = @rubocop_config.target_ruby_version
@rubocop_version_gte_138 = ::RuboCop::Version::STRING.to_f >= 1.38
@rubocop_global_registry = RuboCop::Cop::Registry.global if @rubocop_version_gte_138
@cop_classes = build_cop_classes
@team = build_team
end

def run(processed_source)
descendant_nodes(processed_source).each do |erb_node|
inspect_content(processed_source, erb_node)
snippets = prepare_snippets(processed_source)
return if snippets.empty?

if snippets.size == 1
run_single_snippet(processed_source, snippets[0])
else
run_batched_snippets(processed_source, snippets)
end
end

Expand All @@ -48,24 +59,124 @@ def autocorrect(_processed_source, offense)

private

def run_single_snippet(processed_source, snippet)
source = rubocop_processed_source(snippet[:aligned_source], processed_source.filename)
return unless source.valid_syntax?

activate_team(processed_source, source, snippet[:offset], snippet[:code_node], @team)
end

def run_batched_snippets(processed_source, snippets)
source, snippets_with_offsets = build_combined_source(snippets, processed_source.filename)
if source&.valid_syntax?
investigate_batched(processed_source, source, snippets_with_offsets)
return
end

# Combined source invalid — filter and retry
valid_snippets = snippets.select do |snippet|
rubocop_processed_source(snippet[:aligned_source], processed_source.filename).valid_syntax?
end
return if valid_snippets.empty?

if valid_snippets.size > 1
source, snippets_with_offsets = build_combined_source(valid_snippets, processed_source.filename)
if source&.valid_syntax?
investigate_batched(processed_source, source, snippets_with_offsets)
return
end
end

# Final fallback: per-snippet investigation
valid_snippets.each do |snippet|
run_single_snippet(processed_source, snippet)
end
end

def build_combined_source(snippets, filename)
byte_offset = 0
combined_parts = []

snippets.each do |snippet|
snippet[:byte_start] = byte_offset
combined_parts << snippet[:aligned_source]
byte_offset += snippet[:aligned_source].bytesize + 1
end

source = rubocop_processed_source(combined_parts.join("\n"), filename)
[source, snippets]
end

def descendant_nodes(processed_source)
processed_source.ast.descendants(:erb)
end

def inspect_content(processed_source, erb_node)
indicator, _, code_node, = *erb_node
return if indicator&.children&.first == "#"
def prepare_snippets(processed_source)
snippets = []
descendant_nodes(processed_source).each do |erb_node|
indicator, _, code_node, = *erb_node
next if indicator&.children&.first == "#"

original_source = code_node.loc.source
trimmed_source = original_source.sub(BLOCK_EXPR, "").sub(SUFFIX_EXPR, "")
alignment_column = code_node.loc.column
offset = code_node.loc.begin_pos - alignment_column
aligned_source = "#{" " * alignment_column}#{trimmed_source}"
original_source = code_node.loc.source
trimmed_source = original_source.sub(BLOCK_EXPR, "").sub(SUFFIX_EXPR, "")
alignment_column = code_node.loc.column
offset = code_node.loc.begin_pos - alignment_column
aligned_source = "#{" " * alignment_column}#{trimmed_source}"

source = rubocop_processed_source(aligned_source, processed_source.filename)
return unless source.valid_syntax?
# Fast syntax pre-filter using Ripper (rejects ~38% of snippets quickly)
next unless Ripper.sexp(aligned_source)

snippets << {
code_node: code_node,
aligned_source: aligned_source,
offset: offset,
}
end
snippets
end

activate_team(processed_source, source, offset, code_node, build_team)
def find_snippet_for_byte_pos(snippets, byte_pos)
# Binary search for the snippet containing this byte position
lo = 0
hi = snippets.size - 1
while lo <= hi
mid = (lo + hi) / 2
snippet_start = snippets[mid][:byte_start]
snippet_end = snippet_start + snippets[mid][:aligned_source].bytesize
if byte_pos < snippet_start
hi = mid - 1
elsif byte_pos >= snippet_end
lo = mid + 1
else
return snippets[mid]
end
end
nil
end

def investigate_batched(processed_source, source, snippets)
report = @team.investigate(source)
report.offenses.each do |rubocop_offense|
next if rubocop_offense.disabled?

offense_byte_pos = rubocop_offense.location.begin_pos
snippet = find_snippet_for_byte_pos(snippets, offense_byte_pos)
next unless snippet

begin_in_snippet = rubocop_offense.location.begin_pos - snippet[:byte_start]
end_in_snippet = rubocop_offense.location.end_pos - snippet[:byte_start]

correction = rubocop_offense.corrector if rubocop_offense.corrected?

offense_range = processed_source
.to_source_range(begin_in_snippet...end_in_snippet)
.offset(snippet[:offset])

# Correction positions are in combined-source space; adjust offset so
# import! maps them back through snippet-space to ERB-source space.
correction_offset = snippet[:offset] - snippet[:byte_start]
add_offense(rubocop_offense, offense_range, correction, correction_offset, snippet[:code_node].loc.range)
end
end

def activate_team(processed_source, source, offset, code_node, team)
Expand Down Expand Up @@ -95,29 +206,34 @@ def tempfile_from(filename, content)
def rubocop_processed_source(content, filename)
source = ::RuboCop::ProcessedSource.new(
content,
@rubocop_config.target_ruby_version,
@rubocop_target_ruby_version,
filename,
)
if ::RuboCop::Version::STRING.to_f >= 1.38
registry = RuboCop::Cop::Registry.global
source.registry = registry
if @rubocop_version_gte_138
source.registry = @rubocop_global_registry
source.config = @rubocop_config
end
source
end

def cop_classes
if @only_cops.present?
selected_cops = ::RuboCop::Cop::Registry.all.select { |cop| cop.match?(@only_cops) }
::RuboCop::Cop::Registry.new(selected_cops)
def build_cop_classes
all_cops = if @only_cops.present?
::RuboCop::Cop::Registry.all.select { |cop| cop.match?(@only_cops) }
else
::RuboCop::Cop::Registry.new(::RuboCop::Cop::Registry.all)
::RuboCop::Cop::Registry.all
end

# Filter to only cops that are enabled in the merged config
enabled_cops = all_cops.select do |cop|
@rubocop_config.for_cop(cop).fetch("Enabled") { true } != false
end

::RuboCop::Cop::Registry.new(enabled_cops)
end

def build_team
::RuboCop::Cop::Team.mobilize(
cop_classes,
@cop_classes,
@rubocop_config,
extra_details: true,
display_cop_names: true,
Expand Down
51 changes: 51 additions & 0 deletions spec/erb_lint/linters/rubocop_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,57 @@
it { expect(corrected_content).to(eq("<% dont_auto_correct_me(safe_method(dont_auto_correct_me)) %>\n")) }
end

context "batched investigation finds offenses across multiple erb tags" do
let(:file) { <<~FILE }
<div>
<%= auto_correct_me %>
<span><%= auto_correct_me(:arg) %></span>
</div>
FILE

it "finds offenses in both erb tags" do
expect(subject.size).to(eq(2))
end

it "correctly maps offense positions back to source" do
expect(subject[0].source_range.source).to(eq("auto_correct_me"))
expect(subject[1].source_range.source).to(eq("auto_correct_me"))
end

context "when autocorrecting" do
subject { corrected_content }

it "autocorrects both offenses" do
expect(subject).to(include("safe_method"))
expect(subject).not_to(include("auto_correct_me"))
end
end
end

context "batched investigation with mixed valid and invalid syntax" do
let(:file) { <<~FILE }
<% if condition? %>
<%= auto_correct_me %>
<% end %>
<%= auto_correct_me(:another) %>
FILE

it "finds offenses in valid-syntax erb tags" do
expect(subject.size).to(eq(2))
expect(subject[0].source_range.source).to(eq("auto_correct_me"))
expect(subject[1].source_range.source).to(eq("auto_correct_me"))
end
end

context "single erb tag does not use batching" do
let(:file) { <<~FILE }
<div><%= auto_correct_me %></div>
FILE

it { expect(subject.size).to(eq(1)) }
it { expect(subject.first.source_range.source).to(eq("auto_correct_me")) }
end

private

def arbitrary_error_message(range)
Expand Down
Loading