Skip to content

Commit

Permalink
Add API for 3rd party template support
Browse files Browse the repository at this point in the history
  • Loading branch information
r7kamura authored and bbatsov committed Feb 3, 2023
1 parent f2bb6f6 commit 3c9c494
Show file tree
Hide file tree
Showing 7 changed files with 231 additions and 29 deletions.
1 change: 1 addition & 0 deletions changelog/new_add_api_for_3rd_party_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* [#10839](https://github.com/rubocop/rubocop/pull/10839): Add API for 3rd party template support. ([@r7kamura][])
40 changes: 40 additions & 0 deletions docs/modules/ROOT/pages/extensions.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,43 @@ For example, when you have defined `MyCustomFormatter` in
----
$ rubocop --require ./path/to/my_custom_formatter --format MyCustomFormatter
----

== Template support

RuboCop has API for extensions to support templates such as ERB, Haml, Slim, etc.

Normally, RuboCop extracts one Ruby code from one Ruby file, however there are multiple embedded Ruby codes in one template file. To solve this problem, RuboCop has a mechanism called `Rubocop::Runner.ruby_extractors`, to which any Ruby extractor can be added on the extension side.

Ruby extractor must be a callable object that takes a `RuboCop::ProcessedSource` and returns an `Array` of `Hash` that contains Ruby source codes and their offsets from original source code, or returns `nil` for unrelated file.

[source,ruby]
---
ruby_extractor.call(processed_source)
---

An example returned value from a Ruby extractor would be as follows:

[source]
---
[
{
offset: 2,
processed_source: #<RuboCop::ProcessedSource>
},
{
offset: 10,
processed_source: #<RuboCop::ProcessedSource>
},
]
---

On the extension side, the code would be something like this:

[source,ruby]
---
RuboCop::Runner.ruby_extractors.unshift(ruby_extractor)
---

`RuboCop::Runners.ruby_extractors` is processed from the beginning and ends when one of them returns a non-nil value. By default, there is a Ruby extractor that returns the given Ruby source code with offset 0, so you can unshift any Ruby extractor before it.

NOTE: This is still an experimental feature and may change in the future.
36 changes: 27 additions & 9 deletions lib/rubocop/cop/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ def add_offense(node_or_range, message: nil, severity: nil, &block)

status, corrector = enabled_line?(range.line) ? correct(range, &block) : :disabled

# Since this range may be generated from Ruby code embedded in some
# template file, we convert it to location info in the original file.
range = range_for_original(range)

current_offenses << Offense.new(severity, range, message, name, status, corrector)
end

Expand Down Expand Up @@ -286,6 +290,21 @@ def self.callbacks_needed
end
# rubocop:enable Layout/ClassStructure

# Called before any investigation
# @api private
def begin_investigation(processed_source, offset: 0, original: processed_source)
@current_offenses = nil
@current_offense_locations = nil
@currently_disabled_lines = nil
@processed_source = processed_source
@current_corrector = nil

# We need to keep track of the original source and offset,
# because `processed_source` here may be an embedded code in it.
@current_offset = offset
@current_original = original
end

private

### Reserved for Cop::Cop
Expand Down Expand Up @@ -320,15 +339,6 @@ def current_offenses
@restrict_on_send ||= self::RESTRICT_ON_SEND.to_a.freeze
end

# Called before any investigation
def begin_investigation(processed_source)
@current_offenses = nil
@current_offense_locations = nil
@currently_disabled_lines = nil
@processed_source = processed_source
@current_corrector = nil
end

EMPTY_OFFENSES = [].freeze
private_constant :EMPTY_OFFENSES
# Called to complete an investigation
Expand Down Expand Up @@ -459,6 +469,14 @@ def custom_severity
warn(Rainbow(message).red)
end
end

def range_for_original(range)
::Parser::Source::Range.new(
@current_original.buffer,
range.begin_pos + @current_offset,
range.end_pos + @current_offset
)
end
end
end
end
10 changes: 8 additions & 2 deletions lib/rubocop/cop/commissioner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,10 @@ def on_#{node_type}(node) # def on_send(node)
end

# @return [InvestigationReport]
def investigate(processed_source)
def investigate(processed_source, offset: 0, original: processed_source)
reset

@cops.each { |cop| cop.send :begin_investigation, processed_source }
begin_investigation(processed_source, offset: offset, original: original)
if processed_source.valid_syntax?
invoke(:on_new_investigation, @cops)
invoke_with_argument(:investigate, @forces, processed_source)
Expand All @@ -95,6 +95,12 @@ def investigate(processed_source)

private

def begin_investigation(processed_source, offset:, original:)
@cops.each do |cop|
cop.begin_investigation(processed_source, offset: offset, original: original)
end
end

def trigger_responding_cops(callback, node)
@callbacks[callback]&.each do |cop|
with_cop_error_handling(cop, node) do
Expand Down
33 changes: 19 additions & 14 deletions lib/rubocop/cop/team.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def inspect_file(processed_source)
end

# @return [Commissioner::InvestigationReport]
def investigate(processed_source)
def investigate(processed_source, offset: 0, original: processed_source)
be_ready

# The autocorrection process may have to be repeated multiple times
Expand All @@ -87,14 +87,15 @@ def investigate(processed_source)
on_duty = roundup_relevant_cops(processed_source.file_path)

autocorrect_cops, other_cops = on_duty.partition(&:autocorrect?)
report = investigate_partial(autocorrect_cops, processed_source,
offset: offset, original: original)

report = investigate_partial(autocorrect_cops, processed_source)

unless autocorrect(processed_source, report)
unless autocorrect(processed_source, report, offset: offset, original: original)
# If we corrected some errors, another round of inspection will be
# done, and any other offenses will be caught then, so only need
# to check other_cops if no correction was done
report = report.merge(investigate_partial(other_cops, processed_source))
report = report.merge(investigate_partial(other_cops, processed_source,
offset: offset, original: original))
end

process_errors(processed_source.path, report.errors)
Expand All @@ -116,12 +117,12 @@ def external_dependency_checksum

private

def autocorrect(processed_source, report)
def autocorrect(processed_source, report, original:, offset:)
@updated_source_file = false
return unless autocorrect?
return if report.processed_source.parser_error

new_source = autocorrect_report(report)
new_source = autocorrect_report(report, original: original, offset: offset)

return unless new_source

Expand Down Expand Up @@ -149,9 +150,9 @@ def reset
end

# @return [Commissioner::InvestigationReport]
def investigate_partial(cops, processed_source)
def investigate_partial(cops, processed_source, offset:, original:)
commissioner = Commissioner.new(cops, self.class.forces_for(cops), @options)
commissioner.investigate(processed_source)
commissioner.investigate(processed_source, offset: offset, original: original)
end

# @return [Array<cop>]
Expand All @@ -175,18 +176,22 @@ def support_target_rails_version?(cop)
cop.class.support_target_rails_version?(cop.target_rails_version)
end

def autocorrect_report(report)
corrector = collate_corrections(report)
def autocorrect_report(report, offset:, original:)
corrector = collate_corrections(report, offset: offset, original: original)

corrector.rewrite unless corrector.empty?
end

def collate_corrections(report)
corrector = Corrector.new(report.processed_source)
def collate_corrections(report, offset:, original:)
corrector = Corrector.new(original)

each_corrector(report) do |to_merge|
suppress_clobbering do
corrector.merge!(to_merge)
if offset.positive?
corrector.import!(to_merge, offset: offset)
else
corrector.merge!(to_merge)
end
end
end

Expand Down
44 changes: 40 additions & 4 deletions lib/rubocop/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,27 @@ def initialize(path, offenses_by_iteration, loop_start: -1)
end
end

class << self
# @return [Array<#call>]
def ruby_extractors
@ruby_extractors ||= [default_ruby_extractor]
end

private

# @return [#call]
def default_ruby_extractor
lambda do |processed_source|
[
{
offset: 0,
processed_source: processed_source
}
]
end
end
end

# @api private
MAX_ITERATIONS = 200

Expand Down Expand Up @@ -319,10 +340,25 @@ def check_for_infinite_loop(processed_source, offenses_by_iteration)
end

def inspect_file(processed_source, team = mobilize_team(processed_source))
report = team.investigate(processed_source)
@errors.concat(team.errors)
@warnings.concat(team.warnings)
[report.offenses, team.updated_source_file?]
extracted_ruby_sources = extract_ruby_sources(processed_source)
offenses = extracted_ruby_sources.flat_map do |extracted_ruby_source|
report = team.investigate(
extracted_ruby_source[:processed_source],
offset: extracted_ruby_source[:offset],
original: processed_source
)
@errors.concat(team.errors)
@warnings.concat(team.warnings)
report.offenses
end
[offenses, team.updated_source_file?]
end

def extract_ruby_sources(processed_source)
self.class.ruby_extractors.find do |ruby_extractor|
result = ruby_extractor.call(processed_source)
break result if result
end
end

def mobilize_team(processed_source)
Expand Down
96 changes: 96 additions & 0 deletions spec/rubocop/runner_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,102 @@ def INVALID_CODE; end
end
end

context 'with available custom ruby extractor' do
before do
described_class.ruby_extractors.unshift(custom_ruby_extractor)

# Make Style/EndOfLine give same output regardless of platform.
create_file('.rubocop.yml', <<~YAML)
Layout/EndOfLine:
EnforcedStyle: lf
YAML
end

after do
described_class.ruby_extractors.shift
end

let(:custom_ruby_extractor) do
lambda do |_processed_source|
[
{
offset: 1,
processed_source: RuboCop::ProcessedSource.new(<<~RUBY, 3.1, 'dummy.rb')
# frozen_string_literal: true
def valid_code; end
RUBY
},
{
offset: 2,
processed_source: RuboCop::ProcessedSource.new(source, 3.1, 'dummy.rb')
}
]
end
end

let(:source) do
<<~RUBY
# frozen_string_literal: true
def INVALID_CODE; end
RUBY
end

it 'sends the offense to a formatter' do
runner.run([])
expect(formatter_output).to eq <<~RESULT
Inspecting 1 file
C
Offenses:
example.rb:3:7: C: Naming/MethodName: Use snake_case for method names.
def INVALID_CODE; end
^^^^^^^^^^^^
1 file inspected, 1 offense detected
RESULT
end
end

context 'with unavailable custom ruby extractor' do
before do
described_class.ruby_extractors.unshift(custom_ruby_extractor)
end

after do
described_class.ruby_extractors.shift
end

let(:custom_ruby_extractor) do
lambda do |_processed_source|
end
end

let(:source) { <<~RUBY }
# frozen_string_literal: true
def INVALID_CODE; end
RUBY

it 'sends the offense to a formatter' do
runner.run([])
expect(formatter_output).to eq <<~RESULT
Inspecting 1 file
C
Offenses:
example.rb:3:5: C: Naming/MethodName: Use snake_case for method names.
def INVALID_CODE; end
^^^^^^^^^^^^
1 file inspected, 1 offense detected
RESULT
end
end

context 'if a cop crashes' do
before do
# The cache responds that it's not valid, which means that new results
Expand Down

0 comments on commit 3c9c494

Please sign in to comment.