haml_lint 0.40.0 → 0.51.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/bin/haml-lint +1 -1
  3. data/config/default.yml +9 -27
  4. data/config/forced_rubocop_config.yml +180 -0
  5. data/lib/haml_lint/adapter/haml_4.rb +20 -0
  6. data/lib/haml_lint/adapter/haml_5.rb +11 -0
  7. data/lib/haml_lint/adapter/haml_6.rb +59 -0
  8. data/lib/haml_lint/adapter.rb +2 -0
  9. data/lib/haml_lint/cli.rb +8 -3
  10. data/lib/haml_lint/configuration_loader.rb +49 -13
  11. data/lib/haml_lint/document.rb +89 -8
  12. data/lib/haml_lint/exceptions.rb +6 -0
  13. data/lib/haml_lint/extensions/haml_util_unescape_interpolation_tracking.rb +35 -0
  14. data/lib/haml_lint/file_finder.rb +2 -2
  15. data/lib/haml_lint/lint.rb +10 -1
  16. data/lib/haml_lint/linter/final_newline.rb +4 -3
  17. data/lib/haml_lint/linter/implicit_div.rb +1 -1
  18. data/lib/haml_lint/linter/indentation.rb +3 -3
  19. data/lib/haml_lint/linter/no_placeholders.rb +18 -0
  20. data/lib/haml_lint/linter/repeated_id.rb +2 -1
  21. data/lib/haml_lint/linter/rubocop.rb +353 -59
  22. data/lib/haml_lint/linter/space_before_script.rb +8 -10
  23. data/lib/haml_lint/linter/trailing_empty_lines.rb +22 -0
  24. data/lib/haml_lint/linter/unnecessary_string_output.rb +1 -1
  25. data/lib/haml_lint/linter/view_length.rb +1 -1
  26. data/lib/haml_lint/linter.rb +60 -10
  27. data/lib/haml_lint/linter_registry.rb +3 -5
  28. data/lib/haml_lint/logger.rb +2 -2
  29. data/lib/haml_lint/options.rb +26 -2
  30. data/lib/haml_lint/rake_task.rb +2 -2
  31. data/lib/haml_lint/reporter/hash_reporter.rb +1 -3
  32. data/lib/haml_lint/reporter/offense_count_reporter.rb +1 -1
  33. data/lib/haml_lint/reporter/utils.rb +33 -4
  34. data/lib/haml_lint/ruby_extraction/ad_hoc_chunk.rb +24 -0
  35. data/lib/haml_lint/ruby_extraction/base_chunk.rb +114 -0
  36. data/lib/haml_lint/ruby_extraction/chunk_extractor.rb +672 -0
  37. data/lib/haml_lint/ruby_extraction/coordinator.rb +181 -0
  38. data/lib/haml_lint/ruby_extraction/haml_comment_chunk.rb +34 -0
  39. data/lib/haml_lint/ruby_extraction/implicit_end_chunk.rb +17 -0
  40. data/lib/haml_lint/ruby_extraction/interpolation_chunk.rb +26 -0
  41. data/lib/haml_lint/ruby_extraction/non_ruby_filter_chunk.rb +32 -0
  42. data/lib/haml_lint/ruby_extraction/placeholder_marker_chunk.rb +40 -0
  43. data/lib/haml_lint/ruby_extraction/ruby_filter_chunk.rb +33 -0
  44. data/lib/haml_lint/ruby_extraction/ruby_source.rb +5 -0
  45. data/lib/haml_lint/ruby_extraction/script_chunk.rb +251 -0
  46. data/lib/haml_lint/ruby_extraction/tag_attributes_chunk.rb +63 -0
  47. data/lib/haml_lint/ruby_extraction/tag_script_chunk.rb +30 -0
  48. data/lib/haml_lint/ruby_parser.rb +11 -1
  49. data/lib/haml_lint/runner.rb +35 -3
  50. data/lib/haml_lint/spec/matchers/report_lint.rb +22 -7
  51. data/lib/haml_lint/spec/normalize_indent.rb +2 -2
  52. data/lib/haml_lint/spec/shared_linter_context.rb +9 -1
  53. data/lib/haml_lint/spec/shared_rubocop_autocorrect_context.rb +158 -0
  54. data/lib/haml_lint/spec.rb +1 -0
  55. data/lib/haml_lint/tree/filter_node.rb +10 -0
  56. data/lib/haml_lint/tree/node.rb +13 -4
  57. data/lib/haml_lint/tree/script_node.rb +7 -1
  58. data/lib/haml_lint/tree/silent_script_node.rb +16 -1
  59. data/lib/haml_lint/tree/tag_node.rb +5 -9
  60. data/lib/haml_lint/utils.rb +135 -5
  61. data/lib/haml_lint/version.rb +1 -1
  62. data/lib/haml_lint/version_comparer.rb +25 -0
  63. data/lib/haml_lint.rb +12 -0
  64. metadata +29 -15
  65. data/lib/haml_lint/ruby_extractor.rb +0 -222
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Haml does heavy transformations to strings that contain interpolation without a way
4
+ # of perfectly inverting that transformation.
5
+ #
6
+ # We need this monkey patch to have a way of recovering the original strings as they
7
+ # are in the haml files, so that we can use them and then autocorrect them.
8
+ #
9
+ # The HamlLint::Document carries over a hash of interpolation to original string. The
10
+ # below patches are there to extract said information from Haml's parsing.
11
+ module Haml::Util
12
+ # The cache for the current Thread (technically Fiber)
13
+ def self.unescape_interpolation_to_original_cache
14
+ Thread.current[:haml_lint_unescape_interpolation_to_original_cache] ||= {}
15
+ end
16
+
17
+ # As soon as a HamlLint::Document has finished processing a HAML souce, this gets called to
18
+ # get a copy of this cache and clear up for the next HAML processing
19
+ def self.unescape_interpolation_to_original_cache_take_and_wipe
20
+ value = unescape_interpolation_to_original_cache.dup
21
+ unescape_interpolation_to_original_cache.clear
22
+ value
23
+ end
24
+
25
+ # Overriding the unescape_interpolation method to store the return and original string
26
+ # in the cache.
27
+ def unescape_interpolation_with_original_tracking(str, escape_html = nil)
28
+ value = unescape_interpolation_without_original_tracking(str, escape_html)
29
+ Haml::Util.unescape_interpolation_to_original_cache[value] = str
30
+ value
31
+ end
32
+
33
+ alias unescape_interpolation_without_original_tracking unescape_interpolation
34
+ alias unescape_interpolation unescape_interpolation_with_original_tracking
35
+ end
@@ -40,7 +40,7 @@ module HamlLint
40
40
  #
41
41
  # @param patterns [Array<String>]
42
42
  # @return [Array<String>]
43
- def extract_files_from(patterns) # rubocop:disable Metrics/MethodLength
43
+ def extract_files_from(patterns) # rubocop:disable Metrics
44
44
  files = []
45
45
 
46
46
  patterns.each do |pattern|
@@ -74,7 +74,7 @@ module HamlLint
74
74
  # @param path [String]
75
75
  # @return [String]
76
76
  def normalize_path(path)
77
- path.start_with?(".#{File::SEPARATOR}") ? path[2..-1] : path
77
+ path.start_with?(".#{File::SEPARATOR}") ? path[2..] : path
78
78
  end
79
79
 
80
80
  # Whether the given file should be treated as a Haml file.
@@ -3,6 +3,9 @@
3
3
  module HamlLint
4
4
  # Contains information about a problem or issue with a HAML document.
5
5
  class Lint
6
+ # @return [Boolean] If the error was corrected by auto-correct
7
+ attr_reader :corrected
8
+
6
9
  # @return [String] file path to which the lint applies
7
10
  attr_reader :filename
8
11
 
@@ -25,12 +28,13 @@ module HamlLint
25
28
  # @param line [Fixnum]
26
29
  # @param message [String]
27
30
  # @param severity [Symbol]
28
- def initialize(linter, filename, line, message, severity = :warning)
31
+ def initialize(linter, filename, line, message, severity = :warning, corrected: false) # rubocop:disable Metrics/ParameterLists
29
32
  @linter = linter
30
33
  @filename = filename
31
34
  @line = line || 0
32
35
  @message = message
33
36
  @severity = Severity.new(severity)
37
+ @corrected = corrected
34
38
  end
35
39
 
36
40
  # Return whether this lint has a severity of error.
@@ -39,5 +43,10 @@ module HamlLint
39
43
  def error?
40
44
  @severity.error?
41
45
  end
46
+
47
+ def inspect
48
+ "#{self.class.name}(corrected=#{corrected}, filename=#{filename}, line=#{line}, " \
49
+ "linter=#{linter.class.name}, message=#{message}, severity=#{severity})"
50
+ end
42
51
  end
43
52
  end
@@ -7,18 +7,19 @@ module HamlLint
7
7
 
8
8
  def visit_root(root)
9
9
  return if document.source.empty?
10
+ line_number = document.last_non_empty_line
10
11
 
11
- node = root.node_for_line(document.source_lines.count)
12
+ node = root.node_for_line(line_number)
12
13
  return if node.disabled?(self)
13
14
 
14
15
  ends_with_newline = document.source.end_with?("\n")
15
16
 
16
17
  if config['present']
17
18
  unless ends_with_newline
18
- record_lint(node, 'Files should end with a trailing newline')
19
+ record_lint(line_number, 'Files should end with a trailing newline')
19
20
  end
20
21
  elsif ends_with_newline
21
- record_lint(node, 'Files should not end with a trailing newline')
22
+ record_lint(line_number, 'Files should not end with a trailing newline')
22
23
  end
23
24
  end
24
25
  end
@@ -11,7 +11,7 @@ module HamlLint
11
11
 
12
12
  return unless node.static_classes.any? || node.static_ids.any?
13
13
 
14
- tag = node.source_code[/\s*([^\s={\(\[]+)/, 1]
14
+ tag = node.source_code[/\s*([^\s={(\[]+)/, 1]
15
15
  return unless tag.start_with?('%div')
16
16
 
17
17
  record_lint(node,
@@ -7,8 +7,8 @@ module HamlLint
7
7
 
8
8
  # Allowed leading indentation for each character type.
9
9
  INDENT_REGEX = {
10
- space: /^[ ]*(?!\t)/,
11
- tab: /^\t*(?![ ])/,
10
+ space: /^ *(?!\t)/,
11
+ tab: /^\t*(?! )/,
12
12
  }.freeze
13
13
 
14
14
  LEADING_SPACES_REGEX = /^( +)(?! )/.freeze
@@ -45,7 +45,7 @@ module HamlLint
45
45
  root.children.each do |top_node|
46
46
  # once we've found one line with leading space, there's no need to check any more lines
47
47
  # `haml` will check indenting_at_start, deeper_indenting, inconsistent_indentation
48
- break if top_node.children.find do |node|
48
+ break if top_node.children.find do |node| # rubocop:disable Lint/UnreachableLoop
49
49
  line = node.source_code
50
50
  leading_space = LEADING_SPACES_REGEX.match(line)
51
51
 
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HamlLint
4
+ # Checks that placeholder attributes are not used.
5
+ class Linter::NoPlaceholders < Linter
6
+ include LinterRegistry
7
+
8
+ MSG = 'Placeholders attributes should not be used.'
9
+ HASH_REGEXP = /:?['"]?placeholder['"]?(?::| *=>)/.freeze
10
+ HTML_REGEXP = /placeholder=/.freeze
11
+
12
+ def visit_tag(node)
13
+ return unless node.hash_attributes_source =~ HASH_REGEXP || node.html_attributes_source =~ HTML_REGEXP
14
+
15
+ record_lint(node, MSG)
16
+ end
17
+ end
18
+ end
@@ -8,13 +8,14 @@ module HamlLint
8
8
  MESSAGE_FORMAT = %{Do not repeat id "#%s" on the page}
9
9
 
10
10
  def visit_root(_node)
11
- @id_map = Hash.new { |hash, key| hash[key] = [] }
11
+ @id_map = {}
12
12
  end
13
13
 
14
14
  def visit_tag(node)
15
15
  id = node.tag_id
16
16
  return unless id && !id.empty?
17
17
 
18
+ id_map[id] ||= []
18
19
  nodes = (id_map[id] << node)
19
20
  case nodes.size
20
21
  when 1 then nil
@@ -1,74 +1,253 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'haml_lint/ruby_extractor'
4
3
  require 'rubocop'
4
+ require 'tempfile'
5
5
 
6
6
  module HamlLint
7
- # Runs RuboCop on Ruby code contained within HAML templates.
7
+ # Runs RuboCop on the Ruby code contained within HAML templates.
8
+ #
9
+ # The processing is done by extracting a Ruby file that matches the content, including
10
+ # the indentation, of the HAML file. This way, we can run RuboCop with autocorrect
11
+ # and get new Ruby code which should be HAML compatible.
12
+ #
13
+ # The ruby extraction makes "Chunks" which wrap each HAML constructs. The Chunks can then
14
+ # use the corrected Ruby code to apply the corrections back in the HAML using logic specific
15
+ # to each type of Chunk.
16
+ #
17
+ # The work is spread across the classes in the HamlLint::RubyExtraction module.
8
18
  class Linter::RuboCop < Linter
9
19
  include LinterRegistry
10
20
 
21
+ supports_autocorrect(true)
22
+
11
23
  # Maps the ::RuboCop::Cop::Severity levels to our own levels.
12
24
  SEVERITY_MAP = {
13
25
  error: :error,
14
26
  fatal: :error,
15
-
16
27
  convention: :warning,
17
28
  refactor: :warning,
18
29
  warning: :warning,
30
+ info: :info,
19
31
  }.freeze
20
32
 
21
- def visit_root(_node)
22
- extractor = HamlLint::RubyExtractor.new
23
- extracted_source = extractor.extract(document)
33
+ # Debug fields, also used in tests
34
+ attr_accessor :last_extracted_source
35
+ attr_accessor :last_new_ruby_source
36
+
37
+ def visit_root(_node) # rubocop:disable Metrics
38
+ # Need to call the received block to avoid Linter automatically visiting children
39
+ # Only important thing is that the argument is not ":children"
40
+ yield :skip_children
41
+
42
+ if document.indentation && document.indentation != ' '
43
+ @lints <<
44
+ HamlLint::Lint.new(
45
+ self,
46
+ document.file,
47
+ nil,
48
+ "Only supported indentation is 2 spaces, got: #{document.indentation.dump}",
49
+ :error
50
+ )
51
+ return
52
+ end
53
+
54
+ @last_extracted_source = nil
55
+ @last_new_ruby_source = nil
56
+
57
+ coordinator = HamlLint::RubyExtraction::Coordinator.new(document)
58
+
59
+ extracted_source = coordinator.extract_ruby_source
60
+ if ENV['HAML_LINT_INTERNAL_DEBUG'] == 'true'
61
+ puts "------ Extracted ruby from #{@document.file}:"
62
+ puts extracted_source.source
63
+ puts '------'
64
+ end
65
+
66
+ @last_extracted_source = extracted_source
67
+
68
+ if extracted_source.source.empty?
69
+ @last_new_ruby_source = ''
70
+ return
71
+ end
72
+
73
+ new_ruby_code = process_ruby_source(extracted_source.source, extracted_source.source_map)
74
+
75
+ if @autocorrect && ENV['HAML_LINT_INTERNAL_DEBUG'] == 'true'
76
+ puts "------ Autocorrected extracted ruby from #{@document.file}:"
77
+ puts new_ruby_code
78
+ puts '------'
79
+ end
80
+
81
+ if @autocorrect && transfer_corrections?(extracted_source.source, new_ruby_code)
82
+ @last_new_ruby_source = new_ruby_code
83
+ transfer_corrections(coordinator, new_ruby_code)
84
+ end
85
+ end
86
+
87
+ def self.cops_names_not_supporting_autocorrect
88
+ return @cops_names_not_supporting_autocorrect if @cops_names_not_supporting_autocorrect
89
+ return [] unless ::RuboCop::Cop::Registry.respond_to?(:all)
90
+
91
+ cops_without_autocorrect = ::RuboCop::Cop::Registry.all.reject(&:support_autocorrect?)
92
+ # This cop cannot be disabled
93
+ cops_without_autocorrect.delete(::RuboCop::Cop::Lint::Syntax)
94
+ @cops_names_not_supporting_autocorrect = cops_without_autocorrect.map { |cop| cop.badge.to_s }.freeze
95
+ end
96
+
97
+ private
98
+
99
+ def rubocop_config_for(path)
100
+ user_config_path = ENV['HAML_LINT_RUBOCOP_CONF'] || config['config_file']
101
+ user_config_path ||= self.class.rubocop_config_store.user_rubocop_config_path_for(path)
102
+ user_config_path = File.absolute_path(user_config_path)
103
+ self.class.rubocop_config_store.config_object_pointing_to(user_config_path)
104
+ end
105
+
106
+ # Extracted here so that tests can stub this to always return true
107
+ def transfer_corrections?(initial_ruby_code, new_ruby_code)
108
+ initial_ruby_code != new_ruby_code
109
+ end
110
+
111
+ def transfer_corrections(coordinator, new_ruby_code)
112
+ begin
113
+ new_haml_lines = coordinator.haml_lines_with_corrections_applied(new_ruby_code)
114
+ rescue HamlLint::RubyExtraction::UnableToTransferCorrections => e
115
+ # Those are lints we couldn't correct. If haml-lint was called without the
116
+ # --auto-correct-only, then this linter will be called again without autocorrect,
117
+ # so the lints will be recorded then.
118
+ @lints = []
119
+
120
+ msg = "Corrections couldn't be transfered: #{e.message} - Consider linting the file " \
121
+ 'without auto-correct and doing the changes manually.'
122
+ if ENV['HAML_LINT_DEBUG'] == 'true'
123
+ msg = "#{msg} DEBUG: Rubocop corrected Ruby code follows:\n#{new_ruby_code}\n------"
124
+ end
125
+
126
+ @lints << HamlLint::Lint.new(self, document.file, nil, msg, :error)
127
+ return
128
+ end
24
129
 
25
- return if extracted_source.source.empty?
130
+ new_haml_string = new_haml_lines.join("\n")
26
131
 
27
- find_lints(extracted_source.source, extracted_source.source_map)
132
+ if new_haml_validity_checks(new_haml_string)
133
+ document.change_source(new_haml_string)
134
+ true
135
+ else
136
+ false
137
+ end
138
+ end
139
+
140
+ def new_haml_validity_checks(new_haml_string)
141
+ new_haml_error = HamlLint::Utils.check_error_when_compiling_haml(new_haml_string)
142
+ return true unless new_haml_error
143
+
144
+ error_message = if new_haml_error.is_a?(::SyntaxError)
145
+ 'Corrections by haml-lint generate Haml that will have Ruby syntax error. Skipping.'
146
+ else
147
+ 'Corrections by haml-lint generate invalid Haml. Skipping.'
148
+ end
149
+
150
+ if ENV['HAML_LINT_DEBUG'] == 'true'
151
+ error_message = error_message.dup
152
+ error_message << "\nDEBUG: Here is the exception:\n#{new_haml_error.full_message}"
153
+
154
+ error_message << "DEBUG: This is the (wrong) HAML after the corrections:\n"
155
+ if new_haml_error.respond_to?(:line)
156
+ error_message << "(DEBUG: Line number of error in the HAML: #{new_haml_error.line})\n"
157
+ end
158
+ error_message << new_haml_string
159
+ else
160
+ # Those are lints we couldn't correct. If haml-lint was called without the
161
+ # --auto-correct-only, then this linter will be called again without autocorrect,
162
+ # so the lints will be recorded then. If it was called with --auto-correct-only,
163
+ # then we did nothing so it makes sense not to show the lints.
164
+ @lints = []
165
+ end
166
+
167
+ @lints << HamlLint::Lint.new(self, document.file, nil, error_message, :error)
168
+ false
28
169
  end
29
170
 
30
171
  # A single CLI instance is shared between files to avoid RuboCop
31
172
  # having to repeatedly reload .rubocop.yml.
32
- def self.rubocop_cli
173
+ def self.rubocop_cli # rubocop:disable Lint/IneffectiveAccessModifier
33
174
  # The ivar is stored on the class singleton rather than the Linter instance
34
175
  # because it can't be Marshal.dump'd (as used by Parallel.map)
35
176
  @rubocop_cli ||= ::RuboCop::CLI.new
36
177
  end
37
178
 
38
- private
179
+ def self.rubocop_config_store # rubocop:disable Lint/IneffectiveAccessModifier
180
+ @rubocop_config_store ||= RubocopConfigStore.new
181
+ end
39
182
 
40
- # Executes RuboCop against the given Ruby code and records the offenses as
41
- # lints.
183
+ # Executes RuboCop against the given Ruby code, records the offenses as
184
+ # lints, runs autocorrect if requested and returns the corrected ruby.
42
185
  #
43
- # @param ruby [String] Ruby code
186
+ # @param ruby_code [String] Ruby code
44
187
  # @param source_map [Hash] map of Ruby code line numbers to original line
45
188
  # numbers in the template
46
- def find_lints(ruby, source_map)
47
- filename =
48
- if document.file
49
- "#{document.file}.rb"
50
- else
51
- 'ruby_script.rb'
52
- end
189
+ # @return [String] The autocorrected Ruby source code
190
+ def process_ruby_source(ruby_code, source_map)
191
+ filename = document.file || 'ruby_script.rb'
53
192
 
54
- with_ruby_from_stdin(ruby) do
55
- extract_lints_from_offenses(lint_file(self.class.rubocop_cli, filename), source_map)
56
- end
193
+ offenses, corrected_ruby = run_rubocop(self.class.rubocop_cli, ruby_code, filename)
194
+
195
+ extract_lints_from_offenses(offenses, source_map)
196
+ corrected_ruby
57
197
  end
58
198
 
59
- # Defined so we can stub the results in tests
199
+ # Runs RuboCop, returning the offenses and corrected code. Raises when RuboCop
200
+ # fails to run correctly.
60
201
  #
61
- # @param rubocop [RuboCop::CLI]
62
- # @param file [String]
63
- # @return [Array<RuboCop::Cop::Offense>]
64
- def lint_file(rubocop, file)
65
- status = rubocop.run(rubocop_flags << file)
66
- unless [::RuboCop::CLI::STATUS_SUCCESS, ::RuboCop::CLI::STATUS_OFFENSES].include?(status)
202
+ # @param rubocop_cli [RuboCop::CLI] There to simplify tests by using a stub
203
+ # @param ruby_code [String] The ruby code to run through RuboCop
204
+ # @param path [String] the path to tell RuboCop we are running
205
+ # @return [Array<RuboCop::Cop::Offense>, String]
206
+ def run_rubocop(rubocop_cli, ruby_code, path) # rubocop:disable Metrics
207
+ rubocop_status = nil
208
+ stdout_str, stderr_str = HamlLint::Utils.with_captured_streams(ruby_code) do
209
+ rubocop_cli.config_store.instance_variable_set(:@options_config, rubocop_config_for(path))
210
+ rubocop_status = rubocop_cli.run(rubocop_flags + ['--stdin', path])
211
+ end
212
+
213
+ if ENV['HAML_LINT_INTERNAL_DEBUG'] == 'true'
214
+ if OffenseCollector.offenses.empty?
215
+ puts "------ No lints found by RuboCop in #{@document.file}"
216
+ else
217
+ puts "------ Raw lints found by RuboCop in #{@document.file}"
218
+ OffenseCollector.offenses.each do |offense|
219
+ puts offense
220
+ end
221
+ puts '------'
222
+ end
223
+ end
224
+
225
+ unless [::RuboCop::CLI::STATUS_SUCCESS, ::RuboCop::CLI::STATUS_OFFENSES].include?(rubocop_status)
226
+ if stderr_str.start_with?('Infinite loop')
227
+ msg = "RuboCop exited unsuccessfully with status #{rubocop_status}." \
228
+ ' This appears to be due to an autocorrection infinite loop.'
229
+ if ENV['HAML_LINT_DEBUG'] == 'true'
230
+ msg += " DEBUG: RuboCop's output:\n"
231
+ msg += stderr_str.strip
232
+ else
233
+ msg += " First line of RuboCop's output (Use --debug mode to see more):\n"
234
+ msg += stderr_str.each_line.first.strip
235
+ end
236
+
237
+ raise HamlLint::Exceptions::InfiniteLoopError, msg
238
+ end
239
+
67
240
  raise HamlLint::Exceptions::ConfigurationError,
68
- "RuboCop exited unsuccessfully with status #{status}." \
69
- ' Check the stack trace to see if there was a misconfiguration.'
241
+ "RuboCop exited unsuccessfully with status #{rubocop_status}." \
242
+ ' Here is its output to check the stack trace or see if there was' \
243
+ " a misconfiguration:\n#{stderr_str}"
70
244
  end
71
- OffenseCollector.offenses
245
+
246
+ if @autocorrect
247
+ corrected_ruby = stdout_str.partition("#{'=' * 20}\n").last
248
+ end
249
+
250
+ [OffenseCollector.offenses, corrected_ruby]
72
251
  end
73
252
 
74
253
  # Aggregates RuboCop offenses and converts them to {HamlLint::Lint}s
@@ -76,24 +255,43 @@ module HamlLint
76
255
  #
77
256
  # @param offenses [Array<RuboCop::Cop::Offense>]
78
257
  # @param source_map [Hash]
79
- def extract_lints_from_offenses(offenses, source_map)
80
- dummy_node = Struct.new(:line)
258
+ def extract_lints_from_offenses(offenses, source_map) # rubocop:disable Metrics
259
+ offenses.each do |offense|
260
+ next if Array(config['ignored_cops']).include?(offense.cop_name)
261
+ autocorrected = offense.status == :corrected
262
+
263
+ # There will be another execution to deal with not auto-corrected stuff unless
264
+ # we are in autocorrect-only mode, where we don't want not auto-corrected stuff.
265
+ next if @autocorrect && !autocorrected && offense.cop_name != 'Lint/Syntax'
81
266
 
82
- offenses.reject { |offense| Array(config['ignored_cops']).include?(offense.cop_name) }
83
- .each do |offense|
84
- record_lint(dummy_node.new(source_map[offense.line]), offense.message,
85
- offense.severity.name)
267
+ if ENV['HAML_LINT_INTERNAL_DEBUG']
268
+ line = offense.line
269
+ else
270
+ line = source_map[offense.line]
271
+
272
+ if line.nil? && offense.line == source_map.keys.max + 1
273
+ # The sourcemap doesn't include an entry for the line just after the last line,
274
+ # but rubocop sometimes does place offenses there.
275
+ line = source_map[offense.line - 1]
276
+ end
277
+ end
278
+ record_lint(line, offense.message, offense.severity.name,
279
+ corrected: autocorrected)
86
280
  end
87
281
  end
88
282
 
89
283
  # Record a lint for reporting back to the user.
90
284
  #
91
- # @param node [#line] node to extract the line number from
285
+ # @param line [#line] line number of the lint
92
286
  # @param message [String] error/warning to display to the user
93
287
  # @param severity [Symbol] RuboCop severity level for the offense
94
- def record_lint(node, message, severity)
95
- @lints << HamlLint::Lint.new(self, @document.file, node.line, message,
96
- SEVERITY_MAP.fetch(severity, :warning))
288
+ def record_lint(line, message, severity, corrected:)
289
+ # TODO: actual handling for RuboCop's new :info severity
290
+ return if severity == :info
291
+
292
+ @lints << HamlLint::Lint.new(self, @document.file, line, message,
293
+ SEVERITY_MAP.fetch(severity, :warning),
294
+ corrected: corrected)
97
295
  end
98
296
 
99
297
  # Returns flags that will be passed to RuboCop CLI.
@@ -101,25 +299,48 @@ module HamlLint
101
299
  # @return [Array<String>]
102
300
  def rubocop_flags
103
301
  flags = %w[--format HamlLint::OffenseCollector]
104
- flags += ['--config', ENV['HAML_LINT_RUBOCOP_CONF']] if ENV['HAML_LINT_RUBOCOP_CONF']
105
- flags += ['--stdin']
302
+ flags += ignored_cops_flags
303
+ flags += rubocop_autocorrect_flags
106
304
  flags
107
305
  end
108
306
 
109
- # Overrides the global stdin to allow RuboCop to read Ruby code from it.
110
- #
111
- # @param ruby [String] the Ruby code to write to the overridden stdin
112
- # @param _block [Block] the block to perform with the overridden stdin
113
- # @return [void]
114
- def with_ruby_from_stdin(ruby, &_block)
115
- original_stdin = $stdin
116
- stdin = StringIO.new
117
- stdin.write(ruby)
118
- stdin.rewind
119
- $stdin = stdin
120
- yield
121
- ensure
122
- $stdin = original_stdin
307
+ def rubocop_autocorrect_flags
308
+ return [] unless @autocorrect
309
+
310
+ rubocop_version = Gem::Version.new(::RuboCop::Version::STRING)
311
+
312
+ case @autocorrect
313
+ when :safe
314
+ if rubocop_version >= Gem::Version.new('1.30')
315
+ ['--autocorrect']
316
+ else
317
+ ['--auto-correct']
318
+ end
319
+ when :all
320
+ if rubocop_version >= Gem::Version.new('1.30')
321
+ ['--autocorrect-all']
322
+ else
323
+ ['--auto-correct-all']
324
+ end
325
+ else
326
+ raise "Unexpected autocorrect option: #{@autocorrect.inspect}"
327
+ end
328
+ end
329
+
330
+ # Because of autocorrect, we need to pass the ignored cops to RuboCop to
331
+ # prevent it from doing fixes we don't want.
332
+ # Because cop names changed names over time, we cleanup those that don't exist
333
+ # anymore or don't exist yet.
334
+ # This is not exhaustive, it's only for the cops that are in config/default.yml
335
+ def ignored_cops_flags
336
+ ignored_cops = config.fetch('ignored_cops', [])
337
+
338
+ if @autocorrect
339
+ ignored_cops += self.class.cops_names_not_supporting_autocorrect
340
+ end
341
+
342
+ return [] if ignored_cops.empty?
343
+ ['--except', ignored_cops.uniq.join(',')]
123
344
  end
124
345
  end
125
346
 
@@ -146,4 +367,77 @@ module HamlLint
146
367
  self.class.offenses += offenses
147
368
  end
148
369
  end
370
+
371
+ # To handle our need to force some configurations on RuboCop, while still allowing users
372
+ # to customize most of RuboCop using their own rubocop.yml config(s), we need to detect
373
+ # the effective RuboCop configuration for a specific file, and generate a new configuration
374
+ # containing our own "forced configuration" with a `inherit_from` that points on the
375
+ # user's configuration.
376
+ #
377
+ # This class handles all of this logic.
378
+ class RubocopConfigStore
379
+ def initialize
380
+ @dir_path_to_user_config_path = {}
381
+ @user_config_path_to_config_object = {}
382
+ end
383
+
384
+ # Build a RuboCop::Config from config/forced_rubocop_config.yml which inherits from the given
385
+ # user_config_path and return it's path.
386
+ def config_object_pointing_to(user_config_path)
387
+ if @user_config_path_to_config_object[user_config_path]
388
+ return @user_config_path_to_config_object[user_config_path]
389
+ end
390
+
391
+ final_config_hash = forced_rubocop_config_hash.dup
392
+
393
+ if user_config_path != ::RuboCop::ConfigLoader::DEFAULT_FILE
394
+ # If we manually inherit from the default RuboCop config, we may get warnings
395
+ # for deprecated stuff that is in it. We don't when we automatically
396
+ # inherit from it (which always happens)
397
+ final_config_hash['inherit_from'] = user_config_path
398
+ end
399
+
400
+ config_object = Tempfile.create(['.haml-lint-rubocop', '.yml']) do |tempfile|
401
+ tempfile.write(final_config_hash.to_yaml)
402
+ tempfile.close
403
+ ::RuboCop::ConfigLoader.configuration_from_file(tempfile.path)
404
+ end
405
+
406
+ @user_config_path_to_config_object[user_config_path] = config_object
407
+ end
408
+
409
+ # Find the path to the effective RuboCop configuration for a path (file or dir)
410
+ def user_rubocop_config_path_for(path)
411
+ dir = if File.directory?(path)
412
+ path
413
+ else
414
+ File.dirname(path)
415
+ end
416
+
417
+ @dir_path_to_user_config_path[dir] ||= ::RuboCop::ConfigLoader.configuration_file_for(dir)
418
+ end
419
+
420
+ # Returns the content (Hash) of config/forced_rubocop_config.yml after processing it's ERB content.
421
+ # Cached since it doesn't change between files
422
+ def forced_rubocop_config_hash
423
+ return @forced_rubocop_config_hash if @forced_rubocop_config_hash
424
+
425
+ content = File.read(File.join(HamlLint::HOME, 'config', 'forced_rubocop_config.yml'))
426
+ processed_content = HamlLint::Utils.process_erb(content)
427
+ hash = YAML.safe_load(processed_content)
428
+
429
+ if ENV['HAML_LINT_TESTING']
430
+ # In newer RuboCop versions, new cops are not enabled by default, and instead
431
+ # show a message until they are used. We just want a default for them
432
+ # to avoid spamming STDOUT. Making it "disable" reduces the chances of having
433
+ # the test suite start failing after a new cop gets added.
434
+ hash['AllCops'] ||= {}
435
+ if Gem::Version.new(::RuboCop::Version::STRING) >= Gem::Version.new('1')
436
+ hash['AllCops']['NewCops'] = 'disable'
437
+ end
438
+ end
439
+
440
+ @forced_rubocop_config_hash = hash.freeze
441
+ end
442
+ end
149
443
  end