class HamlLint::Linter::RuboCop
Runs RuboCop
on the Ruby code contained within HAML templates.
The processing is done by extracting a Ruby file that matches the content, including the indentation, of the HAML file. This way, we can run RuboCop
with autocorrect and get new Ruby code which should be HAML compatible.
The ruby extraction makes “Chunks” which wrap each HAML constructs. The Chunks can then use the corrected Ruby code to apply the corrections back in the HAML using logic specific to each type of Chunk.
The work is spread across the classes in the HamlLint::RubyExtraction
module.
Constants
- SEVERITY_MAP
Maps the ::RuboCop::Cop::Severity levels to our own levels.
Attributes
Debug fields, also used in tests
Public Class Methods
# File lib/haml_lint/linter/rubocop.rb, line 87 def self.cops_names_not_supporting_autocorrect return @cops_names_not_supporting_autocorrect if @cops_names_not_supporting_autocorrect return [] unless ::RuboCop::Cop::Registry.respond_to?(:all) cops_without_autocorrect = ::RuboCop::Cop::Registry.all.reject(&:support_autocorrect?) # This cop cannot be disabled cops_without_autocorrect.delete(::RuboCop::Cop::Lint::Syntax) @cops_names_not_supporting_autocorrect = cops_without_autocorrect.map { |cop| cop.badge.to_s }.freeze end
A single CLI
instance is shared between files to avoid RuboCop
having to repeatedly reload .rubocop.yml.
# File lib/haml_lint/linter/rubocop.rb, line 173 def self.rubocop_cli # rubocop:disable Lint/IneffectiveAccessModifier # The ivar is stored on the class singleton rather than the Linter instance # because it can't be Marshal.dump'd (as used by Parallel.map) @rubocop_cli ||= ::RuboCop::CLI.new end
# File lib/haml_lint/linter/rubocop.rb, line 179 def self.rubocop_config_store # rubocop:disable Lint/IneffectiveAccessModifier @rubocop_config_store ||= RubocopConfigStore.new end
Public Instance Methods
# File lib/haml_lint/linter/rubocop.rb, line 37 def visit_root(_node) # rubocop:disable Metrics # Need to call the received block to avoid Linter automatically visiting children # Only important thing is that the argument is not ":children" yield :skip_children if document.indentation && document.indentation != ' ' @lints << HamlLint::Lint.new( self, document.file, nil, "Only supported indentation is 2 spaces, got: #{document.indentation.dump}", :error ) return end @last_extracted_source = nil @last_new_ruby_source = nil coordinator = HamlLint::RubyExtraction::Coordinator.new(document) extracted_source = coordinator.extract_ruby_source if ENV['HAML_LINT_INTERNAL_DEBUG'] == 'true' puts "------ Extracted ruby from #{@document.file}:" puts extracted_source.source puts '------' end @last_extracted_source = extracted_source if extracted_source.source.empty? @last_new_ruby_source = '' return end new_ruby_code = process_ruby_source(extracted_source.source, extracted_source.source_map) if @autocorrect && ENV['HAML_LINT_INTERNAL_DEBUG'] == 'true' puts "------ Autocorrected extracted ruby from #{@document.file}:" puts new_ruby_code puts '------' end if @autocorrect && transfer_corrections?(extracted_source.source, new_ruby_code) @last_new_ruby_source = new_ruby_code transfer_corrections(coordinator, new_ruby_code) end end
Private Instance Methods
Aggregates RuboCop
offenses and converts them to {HamlLint::Lint}s suitable for reporting.
@param offenses [Array<RuboCop::Cop::Offense>] @param source_map [Hash]
# File lib/haml_lint/linter/rubocop.rb, line 258 def extract_lints_from_offenses(offenses, source_map) # rubocop:disable Metrics offenses.each do |offense| next if Array(config['ignored_cops']).include?(offense.cop_name) autocorrected = offense.status == :corrected # There will be another execution to deal with not auto-corrected stuff unless # we are in autocorrect-only mode, where we don't want not auto-corrected stuff. next if @autocorrect && !autocorrected && offense.cop_name != 'Lint/Syntax' if ENV['HAML_LINT_INTERNAL_DEBUG'] line = offense.line else line = source_map[offense.line] if line.nil? && offense.line == source_map.keys.max + 1 # The sourcemap doesn't include an entry for the line just after the last line, # but rubocop sometimes does place offenses there. line = source_map[offense.line - 1] end end record_lint(line, offense.message, offense.severity.name, corrected: autocorrected) end end
Because of autocorrect, we need to pass the ignored cops to RuboCop
to prevent it from doing fixes we don’t want. Because cop names changed names over time, we cleanup those that don’t exist anymore or don’t exist yet. This is not exhaustive, it’s only for the cops that are in config/default.yml
# File lib/haml_lint/linter/rubocop.rb, line 335 def ignored_cops_flags ignored_cops = config.fetch('ignored_cops', []) if @autocorrect ignored_cops += self.class.cops_names_not_supporting_autocorrect end return [] if ignored_cops.empty? ['--except', ignored_cops.uniq.join(',')] end
# File lib/haml_lint/linter/rubocop.rb, line 140 def new_haml_validity_checks(new_haml_string) new_haml_error = HamlLint::Utils.check_error_when_compiling_haml(new_haml_string) return true unless new_haml_error error_message = if new_haml_error.is_a?(::SyntaxError) 'Corrections by haml-lint generate Haml that will have Ruby syntax error. Skipping.' else 'Corrections by haml-lint generate invalid Haml. Skipping.' end if ENV['HAML_LINT_DEBUG'] == 'true' error_message = error_message.dup error_message << "\nDEBUG: Here is the exception:\n#{new_haml_error.full_message}" error_message << "DEBUG: This is the (wrong) HAML after the corrections:\n" if new_haml_error.respond_to?(:line) error_message << "(DEBUG: Line number of error in the HAML: #{new_haml_error.line})\n" end error_message << new_haml_string else # Those are lints we couldn't correct. If haml-lint was called without the # --auto-correct-only, then this linter will be called again without autocorrect, # so the lints will be recorded then. If it was called with --auto-correct-only, # then we did nothing so it makes sense not to show the lints. @lints = [] end @lints << HamlLint::Lint.new(self, document.file, nil, error_message, :error) false end
Executes RuboCop
against the given Ruby code, records the offenses as lints, runs autocorrect if requested and returns the corrected ruby.
@param ruby_code [String] Ruby code @param source_map [Hash] map of Ruby code line numbers to original line
numbers in the template
@return [String] The autocorrected Ruby source code
# File lib/haml_lint/linter/rubocop.rb, line 190 def process_ruby_source(ruby_code, source_map) filename = document.file || 'ruby_script.rb' offenses, corrected_ruby = run_rubocop(self.class.rubocop_cli, ruby_code, filename) extract_lints_from_offenses(offenses, source_map) corrected_ruby end
Record a lint for reporting back to the user.
@param line [#line] line number of the lint @param message [String] error/warning to display to the user @param severity [Symbol] RuboCop
severity level for the offense
# File lib/haml_lint/linter/rubocop.rb, line 288 def record_lint(line, message, severity, corrected:) # TODO: actual handling for RuboCop's new :info severity return if severity == :info @lints << HamlLint::Lint.new(self, @document.file, line, message, SEVERITY_MAP.fetch(severity, :warning), corrected: corrected) end
# File lib/haml_lint/linter/rubocop.rb, line 307 def rubocop_autocorrect_flags return [] unless @autocorrect rubocop_version = Gem::Version.new(::RuboCop::Version::STRING) case @autocorrect when :safe if rubocop_version >= Gem::Version.new('1.30') ['--autocorrect'] else ['--auto-correct'] end when :all if rubocop_version >= Gem::Version.new('1.30') ['--autocorrect-all'] else ['--auto-correct-all'] end else raise "Unexpected autocorrect option: #{@autocorrect.inspect}" end end
# File lib/haml_lint/linter/rubocop.rb, line 99 def rubocop_config_for(path) user_config_path = ENV['HAML_LINT_RUBOCOP_CONF'] || config['config_file'] user_config_path ||= self.class.rubocop_config_store.user_rubocop_config_path_for(path) user_config_path = File.absolute_path(user_config_path) self.class.rubocop_config_store.config_object_pointing_to(user_config_path) end
Runs RuboCop
, returning the offenses and corrected code. Raises when RuboCop
fails to run correctly.
@param rubocop_cli
[RuboCop::CLI] There to simplify tests by using a stub @param ruby_code [String] The ruby code to run through RuboCop
@param path [String] the path to tell RuboCop
we are running @return [Array<RuboCop::Cop::Offense>, String]
# File lib/haml_lint/linter/rubocop.rb, line 206 def run_rubocop(rubocop_cli, ruby_code, path) # rubocop:disable Metrics rubocop_status = nil stdout_str, stderr_str = HamlLint::Utils.with_captured_streams(ruby_code) do rubocop_cli.config_store.instance_variable_set(:@options_config, rubocop_config_for(path)) rubocop_status = rubocop_cli.run(rubocop_flags + ['--stdin', path]) end if ENV['HAML_LINT_INTERNAL_DEBUG'] == 'true' if OffenseCollector.offenses.empty? puts "------ No lints found by RuboCop in #{@document.file}" else puts "------ Raw lints found by RuboCop in #{@document.file}" OffenseCollector.offenses.each do |offense| puts offense end puts '------' end end unless [::RuboCop::CLI::STATUS_SUCCESS, ::RuboCop::CLI::STATUS_OFFENSES].include?(rubocop_status) if stderr_str.start_with?('Infinite loop') msg = "RuboCop exited unsuccessfully with status #{rubocop_status}." \ ' This appears to be due to an autocorrection infinite loop.' if ENV['HAML_LINT_DEBUG'] == 'true' msg += " DEBUG: RuboCop's output:\n" msg += stderr_str.strip else msg += " First line of RuboCop's output (Use --debug mode to see more):\n" msg += stderr_str.each_line.first.strip end raise HamlLint::Exceptions::InfiniteLoopError, msg end raise HamlLint::Exceptions::ConfigurationError, "RuboCop exited unsuccessfully with status #{rubocop_status}." \ ' Here is its output to check the stack trace or see if there was' \ " a misconfiguration:\n#{stderr_str}" end if @autocorrect corrected_ruby = stdout_str.partition("#{'=' * 20}\n").last end [OffenseCollector.offenses, corrected_ruby] end
# File lib/haml_lint/linter/rubocop.rb, line 111 def transfer_corrections(coordinator, new_ruby_code) begin new_haml_lines = coordinator.haml_lines_with_corrections_applied(new_ruby_code) rescue HamlLint::RubyExtraction::UnableToTransferCorrections => e # Those are lints we couldn't correct. If haml-lint was called without the # --auto-correct-only, then this linter will be called again without autocorrect, # so the lints will be recorded then. @lints = [] msg = "Corrections couldn't be transferred: #{e.message} - Consider linting the file " \ 'without auto-correct and doing the changes manually.' if ENV['HAML_LINT_DEBUG'] == 'true' msg = "#{msg} DEBUG: Rubocop corrected Ruby code follows:\n#{new_ruby_code}\n------" end @lints << HamlLint::Lint.new(self, document.file, nil, msg, :error) return end new_haml_string = new_haml_lines.join("\n") if new_haml_validity_checks(new_haml_string) document.change_source(new_haml_string) true else false end end
Extracted here so that tests can stub this to always return true
# File lib/haml_lint/linter/rubocop.rb, line 107 def transfer_corrections?(initial_ruby_code, new_ruby_code) initial_ruby_code != new_ruby_code end