haml_lint 0.53.0 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +17 -5
  3. data/config/forced_rubocop_config.yml +25 -13
  4. data/lib/haml_lint/adapter.rb +5 -4
  5. data/lib/haml_lint/cli.rb +7 -3
  6. data/lib/haml_lint/constants.rb +1 -1
  7. data/lib/haml_lint/directive.rb +2 -2
  8. data/lib/haml_lint/document.rb +14 -5
  9. data/lib/haml_lint/exceptions.rb +2 -2
  10. data/lib/haml_lint/extensions/haml_util_unescape_interpolation_tracking.rb +2 -2
  11. data/lib/haml_lint/haml_visitor.rb +1 -1
  12. data/lib/haml_lint/lint.rb +8 -3
  13. data/lib/haml_lint/linter/alignment_tabs.rb +21 -4
  14. data/lib/haml_lint/linter/class_attribute_with_static_value.rb +52 -12
  15. data/lib/haml_lint/linter/classes_before_ids.rb +15 -6
  16. data/lib/haml_lint/linter/consecutive_comments.rb +20 -1
  17. data/lib/haml_lint/linter/consecutive_silent_scripts.rb +68 -1
  18. data/lib/haml_lint/linter/empty_object_reference.rb +16 -1
  19. data/lib/haml_lint/linter/empty_script.rb +32 -2
  20. data/lib/haml_lint/linter/final_newline.rb +31 -7
  21. data/lib/haml_lint/linter/html_attributes.rb +71 -2
  22. data/lib/haml_lint/linter/id_names.rb +1 -1
  23. data/lib/haml_lint/linter/implicit_div.rb +14 -1
  24. data/lib/haml_lint/linter/indentation.rb +2 -2
  25. data/lib/haml_lint/linter/instance_variables.rb +28 -3
  26. data/lib/haml_lint/linter/leading_comment_space.rb +14 -2
  27. data/lib/haml_lint/linter/multiline_pipe.rb +3 -3
  28. data/lib/haml_lint/linter/multiline_script.rb +65 -5
  29. data/lib/haml_lint/linter/no_placeholders.rb +2 -2
  30. data/lib/haml_lint/linter/rubocop.rb +87 -76
  31. data/lib/haml_lint/linter/ruby_comments.rb +14 -4
  32. data/lib/haml_lint/linter/space_before_script.rb +37 -4
  33. data/lib/haml_lint/linter/space_inside_hash_attributes.rb +41 -3
  34. data/lib/haml_lint/linter/strict_locals.rb +69 -0
  35. data/lib/haml_lint/linter/tag_name.rb +15 -2
  36. data/lib/haml_lint/linter/trailing_empty_lines.rb +13 -1
  37. data/lib/haml_lint/linter/trailing_whitespace.rb +16 -3
  38. data/lib/haml_lint/linter/unescaped_html.rb +27 -0
  39. data/lib/haml_lint/linter/unnecessary_interpolation.rb +30 -3
  40. data/lib/haml_lint/linter/unnecessary_string_output.rb +108 -12
  41. data/lib/haml_lint/linter.rb +119 -5
  42. data/lib/haml_lint/node_transformer.rb +2 -2
  43. data/lib/haml_lint/options.rb +17 -3
  44. data/lib/haml_lint/rake_task.rb +3 -2
  45. data/lib/haml_lint/reporter/default_reporter.rb +1 -1
  46. data/lib/haml_lint/reporter/disabled_config_reporter.rb +29 -8
  47. data/lib/haml_lint/reporter/github_reporter.rb +50 -0
  48. data/lib/haml_lint/reporter/hash_reporter.rb +2 -0
  49. data/lib/haml_lint/reporter/json_reporter.rb +1 -1
  50. data/lib/haml_lint/reporter/progress_reporter.rb +2 -1
  51. data/lib/haml_lint/reporter/utils.rb +4 -2
  52. data/lib/haml_lint/reporter.rb +1 -1
  53. data/lib/haml_lint/ruby_extraction/base_chunk.rb +4 -4
  54. data/lib/haml_lint/ruby_extraction/chunk_extractor.rb +73 -32
  55. data/lib/haml_lint/ruby_extraction/coordinator.rb +9 -4
  56. data/lib/haml_lint/ruby_extraction/implicit_end_chunk.rb +1 -1
  57. data/lib/haml_lint/ruby_extraction/non_ruby_filter_chunk.rb +2 -2
  58. data/lib/haml_lint/ruby_extraction/placeholder_marker_chunk.rb +1 -1
  59. data/lib/haml_lint/ruby_extraction/ruby_filter_chunk.rb +1 -1
  60. data/lib/haml_lint/ruby_extraction/script_chunk.rb +2 -2
  61. data/lib/haml_lint/runner.rb +70 -30
  62. data/lib/haml_lint/source.rb +25 -0
  63. data/lib/haml_lint/spec/matchers/report_lint.rb +17 -6
  64. data/lib/haml_lint/spec/shared_rubocop_autocorrect_context.rb +34 -4
  65. data/lib/haml_lint/spec.rb +4 -4
  66. data/lib/haml_lint/tree/comment_node.rb +1 -1
  67. data/lib/haml_lint/tree/doctype_node.rb +1 -1
  68. data/lib/haml_lint/tree/filter_node.rb +14 -1
  69. data/lib/haml_lint/tree/haml_comment_node.rb +9 -2
  70. data/lib/haml_lint/tree/node.rb +4 -4
  71. data/lib/haml_lint/tree/root_node.rb +4 -4
  72. data/lib/haml_lint/tree/script_node.rb +1 -1
  73. data/lib/haml_lint/tree/silent_script_node.rb +1 -1
  74. data/lib/haml_lint/tree/tag_node.rb +69 -25
  75. data/lib/haml_lint/utils.rb +4 -31
  76. data/lib/haml_lint/version.rb +1 -1
  77. data/lib/haml_lint.rb +23 -23
  78. metadata +14 -10
@@ -4,18 +4,46 @@ require 'rubocop'
4
4
  require 'tempfile'
5
5
 
6
6
  module HamlLint
7
- # Runs RuboCop on the Ruby code contained within HAML templates.
7
+ # Runs RuboCop on the Ruby code contained within Haml templates.
8
8
  #
9
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.
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
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
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
15
  # to each type of Chunk.
16
16
  #
17
17
  # The work is spread across the classes in the HamlLint::RubyExtraction module.
18
18
  class Linter::RuboCop < Linter
19
+ # Processes a ruby file and reports RuboCop offenses
20
+ class Runner < ::RuboCop::Runner
21
+ attr_reader :offenses
22
+
23
+ def run(haml_path, ruby_code, config:)
24
+ @offenses = []
25
+ @config_store.instance_variable_set(:@options_config, config)
26
+ # Using stdin also disables RuboCop's result cache, which is intentional:
27
+ # it reconstructs offense positions from the on-disk Haml, not the Ruby we
28
+ # inspected, so reusing it misreports lines (sds/haml-lint#593).
29
+ @options[:stdin] = ruby_code
30
+ super([haml_path])
31
+ end
32
+
33
+ def corrected_code
34
+ @options[:stdin]
35
+ end
36
+
37
+ # Executed when a file has been scanned by RuboCop, adding the reported
38
+ # offenses to our collection.
39
+ #
40
+ # @param _file [String]
41
+ # @param offenses [Array<RuboCop::Cop::Offense>]
42
+ def file_finished(_file, offenses)
43
+ @offenses = offenses
44
+ end
45
+ end
46
+
19
47
  include LinterRegistry
20
48
 
21
49
  supports_autocorrect(true)
@@ -54,7 +82,7 @@ module HamlLint
54
82
  @last_extracted_source = nil
55
83
  @last_new_ruby_source = nil
56
84
 
57
- coordinator = HamlLint::RubyExtraction::Coordinator.new(document)
85
+ coordinator = HamlLint::RubyExtraction::Coordinator.new(document, autocorrect: @autocorrect)
58
86
 
59
87
  extracted_source = coordinator.extract_ruby_source
60
88
  if ENV['HAML_LINT_INTERNAL_DEBUG'] == 'true'
@@ -98,9 +126,9 @@ module HamlLint
98
126
 
99
127
  def rubocop_config_for(path)
100
128
  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)
129
+ user_config_path ||= rubocop_config_store.user_rubocop_config_path_for(path)
102
130
  user_config_path = File.absolute_path(user_config_path)
103
- self.class.rubocop_config_store.config_object_pointing_to(user_config_path)
131
+ rubocop_config_store.config_object_pointing_to(user_config_path)
104
132
  end
105
133
 
106
134
  # Extracted here so that tests can stub this to always return true
@@ -117,7 +145,7 @@ module HamlLint
117
145
  # so the lints will be recorded then.
118
146
  @lints = []
119
147
 
120
- msg = "Corrections couldn't be transfered: #{e.message} - Consider linting the file " \
148
+ msg = "Corrections couldn't be transferred: #{e.message} - Consider linting the file " \
121
149
  'without auto-correct and doing the changes manually.'
122
150
  if ENV['HAML_LINT_DEBUG'] == 'true'
123
151
  msg = "#{msg} DEBUG: Rubocop corrected Ruby code follows:\n#{new_ruby_code}\n------"
@@ -168,15 +196,13 @@ module HamlLint
168
196
  false
169
197
  end
170
198
 
171
- # A single CLI instance is shared between files to avoid RuboCop
199
+ # A single RuboCop runner is shared between files to avoid RuboCop
172
200
  # having to repeatedly reload .rubocop.yml.
173
- def self.rubocop_cli # rubocop:disable Lint/IneffectiveAccessModifier
174
- # The ivar is stored on the class singleton rather than the Linter instance
175
- # because it can't be Marshal.dump'd (as used by Parallel.map)
176
- @rubocop_cli ||= ::RuboCop::CLI.new
201
+ def rubocop_runner
202
+ @rubocop_runner ||= Runner.new(rubocop_options, ::RuboCop::ConfigStore.new)
177
203
  end
178
204
 
179
- def self.rubocop_config_store # rubocop:disable Lint/IneffectiveAccessModifier
205
+ def rubocop_config_store
180
206
  @rubocop_config_store ||= RubocopConfigStore.new
181
207
  end
182
208
 
@@ -190,7 +216,7 @@ module HamlLint
190
216
  def process_ruby_source(ruby_code, source_map)
191
217
  filename = document.file || 'ruby_script.rb'
192
218
 
193
- offenses, corrected_ruby = run_rubocop(self.class.rubocop_cli, ruby_code, filename)
219
+ offenses, corrected_ruby = run_rubocop(rubocop_runner, ruby_code, filename)
194
220
 
195
221
  extract_lints_from_offenses(offenses, source_map)
196
222
  corrected_ruby
@@ -199,55 +225,35 @@ module HamlLint
199
225
  # Runs RuboCop, returning the offenses and corrected code. Raises when RuboCop
200
226
  # fails to run correctly.
201
227
  #
202
- # @param rubocop_cli [RuboCop::CLI] There to simplify tests by using a stub
228
+ # @param rubocop_runner [HamlLint::Linter::RuboCop::Runner] There to simplify tests by using a stub
203
229
  # @param ruby_code [String] The ruby code to run through RuboCop
204
230
  # @param path [String] the path to tell RuboCop we are running
205
231
  # @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
232
+ def run_rubocop(rubocop_runner, ruby_code, path) # rubocop:disable Metrics
233
+ rubocop_runner.run(path, ruby_code, config: rubocop_config_for(path))
212
234
 
213
235
  if ENV['HAML_LINT_INTERNAL_DEBUG'] == 'true'
214
- if OffenseCollector.offenses.empty?
236
+ if rubocop_runner.offenses.empty?
215
237
  puts "------ No lints found by RuboCop in #{@document.file}"
216
238
  else
217
239
  puts "------ Raw lints found by RuboCop in #{@document.file}"
218
- OffenseCollector.offenses.each do |offense|
240
+ rubocop_runner.offenses.each do |offense|
219
241
  puts offense
220
242
  end
221
243
  puts '------'
222
244
  end
223
245
  end
224
246
 
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
-
240
- raise HamlLint::Exceptions::ConfigurationError,
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}"
244
- end
245
-
246
247
  if @autocorrect
247
- corrected_ruby = stdout_str.partition("#{'=' * 20}\n").last
248
+ corrected_ruby = rubocop_runner.corrected_code
248
249
  end
249
250
 
250
- [OffenseCollector.offenses, corrected_ruby]
251
+ [rubocop_runner.offenses, corrected_ruby]
252
+ rescue ::RuboCop::Error => e
253
+ raise HamlLint::Exceptions::ConfigurationError,
254
+ "RuboCop raised #{e}." \
255
+ ' Here is its output to check the stack trace or see if there was' \
256
+ " a misconfiguration:\n#{e.message}\n#{e.backtrace}"
251
257
  end
252
258
 
253
259
  # Aggregates RuboCop offenses and converts them to {HamlLint::Lint}s
@@ -276,7 +282,7 @@ module HamlLint
276
282
  end
277
283
  end
278
284
  record_lint(line, offense.message, offense.severity.name,
279
- corrected: autocorrected)
285
+ corrected: autocorrected, correctable: offense.correctable?)
280
286
  end
281
287
  end
282
288
 
@@ -285,23 +291,38 @@ module HamlLint
285
291
  # @param line [#line] line number of the lint
286
292
  # @param message [String] error/warning to display to the user
287
293
  # @param severity [Symbol] RuboCop severity level for the offense
288
- def record_lint(line, message, severity, corrected:)
294
+ # @param corrected [Boolean] whether RuboCop auto-corrected the offense
295
+ # @param correctable [Boolean] whether RuboCop is able to auto-correct the offense
296
+ def record_lint(line, message, severity, corrected:, correctable: false)
289
297
  # TODO: actual handling for RuboCop's new :info severity
290
298
  return if severity == :info
291
299
 
292
300
  @lints << HamlLint::Lint.new(self, @document.file, line, message,
293
301
  SEVERITY_MAP.fetch(severity, :warning),
294
- corrected: corrected)
302
+ corrected: corrected, correctable: correctable)
303
+ end
304
+
305
+ # rubocop:disable Style/MutableConstant
306
+ # Using BaseFormatter suppresses any default output
307
+ DEFAULT_FLAGS = %w[--format RuboCop::Formatter::BaseFormatter]
308
+ begin
309
+ ::RuboCop::Options.new.parse(['--raise-cop-error'])
310
+ DEFAULT_FLAGS << '--raise-cop-error'
311
+ rescue OptionParser::InvalidOption
312
+ # older versions of RuboCop don't support this flag
295
313
  end
314
+ DEFAULT_FLAGS.freeze
315
+ # rubocop:enable Style/MutableConstant
296
316
 
297
- # Returns flags that will be passed to RuboCop CLI.
317
+ # Returns options that will be passed to the RuboCop runner.
298
318
  #
299
- # @return [Array<String>]
300
- def rubocop_flags
301
- flags = %w[--format HamlLint::OffenseCollector]
319
+ # @return [Hash]
320
+ def rubocop_options
321
+ flags = DEFAULT_FLAGS
302
322
  flags += ignored_cops_flags
303
323
  flags += rubocop_autocorrect_flags
304
- flags
324
+ options, _args = ::RuboCop::Options.new.parse(flags)
325
+ options
305
326
  end
306
327
 
307
328
  def rubocop_autocorrect_flags
@@ -342,29 +363,19 @@ module HamlLint
342
363
  return [] if ignored_cops.empty?
343
364
  ['--except', ignored_cops.uniq.join(',')]
344
365
  end
345
- end
346
-
347
- # Collects offenses detected by RuboCop.
348
- class OffenseCollector < ::RuboCop::Formatter::BaseFormatter
349
- class << self
350
- # List of offenses reported by RuboCop.
351
- attr_accessor :offenses
352
- end
353
366
 
354
- # Executed when RuboCop begins linting.
355
- #
356
- # @param _target_files [Array<String>]
357
- def started(_target_files)
358
- self.class.offenses = []
367
+ # Exclude ivars that don't marshal properly
368
+ def marshal_dump
369
+ excluded_ivars = %i[@rubocop_runner @rubocop_config_store @user_config_path_to_config_object]
370
+ (instance_variables - excluded_ivars).to_h do |ivar|
371
+ [ivar, instance_variable_get(ivar)]
372
+ end
359
373
  end
360
374
 
361
- # Executed when a file has been scanned by RuboCop, adding the reported
362
- # offenses to our collection.
363
- #
364
- # @param _file [String]
365
- # @param offenses [Array<RuboCop::Cop::Offense>]
366
- def file_finished(_file, offenses)
367
- self.class.offenses += offenses
375
+ def marshal_load(ivars)
376
+ ivars.each do |k, v|
377
+ instance_variable_set(k, v)
378
+ end
368
379
  end
369
380
  end
370
381
 
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HamlLint
4
- # Checks for Ruby comments that can be written as HAML comments.
4
+ # Checks for Ruby comments that can be written as Haml comments.
5
5
  class Linter::RubyComments < Linter
6
6
  include LinterRegistry
7
7
 
8
+ supports_autocorrect(true)
9
+
8
10
  def visit_silent_script(node)
9
- if code_comment?(node)
10
- record_lint(node, 'Use `-#` for comments instead of `- #`')
11
- end
11
+ return unless code_comment?(node)
12
+
13
+ corrected = correct_comment(node)
14
+ record_lint(node, 'Use `-#` for comments instead of `- #`', corrected: corrected)
12
15
  end
13
16
 
14
17
  private
@@ -16,5 +19,12 @@ module HamlLint
16
19
  def code_comment?(node)
17
20
  node.script =~ /\A\s+#/
18
21
  end
22
+
23
+ # @return [Boolean]
24
+ def correct_comment(node)
25
+ index = node.line - 1
26
+ line = autocorrected_lines[index]
27
+ correct_line(index, line.sub(/\A(\s*)-\s+#/, '\1-#'))
28
+ end
19
29
  end
20
30
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HamlLint
4
- # Checks for Ruby script in HAML templates with no space after the `=`/`-`.
4
+ # Checks for Ruby script in Haml templates with no space after the `=`/`-`.
5
5
  class Linter::SpaceBeforeScript < Linter
6
6
  include LinterRegistry
7
7
 
8
+ supports_autocorrect(true)
9
+
8
10
  MESSAGE_FORMAT = 'The %s symbol should have one space separating it from code'
9
11
 
10
12
  ALLOWED_SEPARATORS = [' ', '#'].freeze
@@ -33,18 +35,23 @@ module HamlLint
33
35
  # (need to do it this way as the parser strips whitespace from node)
34
36
  return unless tag_with_text[index - 1] != ' '
35
37
 
36
- record_lint(node, MESSAGE_FORMAT % '=')
38
+ corrected = correct_inline_script(node, text)
39
+ record_lint(node, MESSAGE_FORMAT % '=', corrected: corrected)
37
40
  end
38
41
 
39
42
  def visit_script(node)
40
43
  # Plain text nodes with interpolation are converted to script nodes, so we
41
44
  # need to ignore them here.
42
45
  return unless document.source_lines[node.line - 1].lstrip.start_with?('=')
43
- record_lint(node, MESSAGE_FORMAT % '=') if missing_space?(node)
46
+ return unless missing_space?(node)
47
+
48
+ record_lint(node, MESSAGE_FORMAT % '=', corrected: correct_leading_marker(node, '='))
44
49
  end
45
50
 
46
51
  def visit_silent_script(node)
47
- record_lint(node, MESSAGE_FORMAT % '-') if missing_space?(node)
52
+ return unless missing_space?(node)
53
+
54
+ record_lint(node, MESSAGE_FORMAT % '-', corrected: correct_leading_marker(node, '-'))
48
55
  end
49
56
 
50
57
  private
@@ -53,5 +60,31 @@ module HamlLint
53
60
  text = node.script
54
61
  !ALLOWED_SEPARATORS.include?(text[0]) if text
55
62
  end
63
+
64
+ # Inserts one space after a leading `=`/`-` marker.
65
+ #
66
+ # @return [Boolean]
67
+ def correct_leading_marker(node, marker)
68
+ index = node.line - 1
69
+ line = autocorrected_lines[index]
70
+ escaped = Regexp.escape(marker)
71
+ correct_line(index, line.sub(/\A(\s*#{escaped})(?=[^\s#{escaped}])/, '\1 '))
72
+ end
73
+
74
+ # Inserts a space after the `=` marker introducing a tag's inline script.
75
+ #
76
+ # @return [Boolean]
77
+ def correct_inline_script(node, text)
78
+ index = node.line - 1
79
+ line = autocorrected_lines[index]
80
+
81
+ pos = line.rindex("=#{text}")
82
+ if pos.nil? && (unquoted = strip_surrounding_quotes(text))
83
+ pos = line.rindex("=#{unquoted}")
84
+ end
85
+ return false unless pos
86
+
87
+ correct_line(index, "#{line[0..pos]} #{line[(pos + 1)..]}")
88
+ end
56
89
  end
57
90
  end
@@ -6,6 +6,8 @@ module HamlLint
6
6
  class Linter::SpaceInsideHashAttributes < Linter
7
7
  include LinterRegistry
8
8
 
9
+ supports_autocorrect(true)
10
+
9
11
  STYLE = {
10
12
  'no_space' => {
11
13
  start_regex: /\A\{[^ ]/,
@@ -24,11 +26,47 @@ module HamlLint
24
26
  def visit_tag(node)
25
27
  return unless node.hash_attributes?
26
28
 
27
- style = STYLE[config['style'] == 'no_space' ? 'no_space' : 'space']
29
+ style_name = config['style'] == 'no_space' ? 'no_space' : 'space'
30
+ style = STYLE[style_name]
28
31
  source = node.hash_attributes_source
29
32
 
30
- record_lint(node, style[:start_message]) unless source =~ style[:start_regex]
31
- record_lint(node, style[:end_message]) unless source =~ style[:end_regex]
33
+ start_ok = source.match?(style[:start_regex])
34
+ end_ok = source.match?(style[:end_regex])
35
+ return if start_ok && end_ok
36
+
37
+ corrected = correct_hash_spacing(node, source, style_name)
38
+ record_lint(node, style[:start_message], corrected: corrected) unless start_ok
39
+ record_lint(node, style[:end_message], corrected: corrected) unless end_ok
40
+ end
41
+
42
+ private
43
+
44
+ # @return [Boolean]
45
+ def correct_hash_spacing(node, source, style_name)
46
+ return false unless source
47
+ return false if source.include?("\n") # multi-line hash: detection-only
48
+
49
+ index = node.line - 1
50
+ line = autocorrected_lines[index]
51
+ return false unless line.include?(source)
52
+
53
+ fixed = corrected_hash_source(source, style_name)
54
+ return false if fixed == source
55
+
56
+ correct_line(index, line.sub(source) { fixed })
57
+ end
58
+
59
+ # @return [String]
60
+ def corrected_hash_source(source, style_name)
61
+ inner = source[1...-1].strip
62
+
63
+ if style_name == 'no_space'
64
+ "{#{inner}}"
65
+ elsif inner.empty?
66
+ '{ }'
67
+ else
68
+ "{ #{inner} }"
69
+ end
32
70
  end
33
71
  end
34
72
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HamlLint
4
+ # Checks for the presence of a `locals` magic comment at the beginning of a partial.
5
+ class Linter::StrictLocals < Linter
6
+ include LinterRegistry
7
+
8
+ DummyNode = Struct.new(:line)
9
+
10
+ # Enables the linter if the tree is for the right file type.
11
+ #
12
+ # @param [HamlLint::Tree::RootNode] the root of a syntax tree
13
+ # @return [true, false] whether the linter is enabled for the tree
14
+ def visit_root(root)
15
+ return unless enabled?(root)
16
+
17
+ # Rails technically allows the comment to be anywhere in the file,
18
+ # but as a best practice they should be at the top of the file.
19
+ # https://guides.rubyonrails.org/action_view_overview.html#strict-locals
20
+ # https://github.com/rails/rails/blob/v8.0.2/actionview/lib/action_view/template.rb#L368
21
+ found =
22
+ root.children
23
+ .take_while { |child| child.is_a?(HamlLint::Tree::HamlCommentNode) }
24
+ .any?(&:is_strict_locals?)
25
+
26
+ return if found
27
+
28
+ record_lint(DummyNode.new(1), failure_message)
29
+ end
30
+
31
+ private
32
+
33
+ # Checks whether the linter is enabled for the file.
34
+ #
35
+ # @api private
36
+ # @return [true, false]
37
+ def enabled?(root)
38
+ return false unless matcher.match?(File.basename(root.file))
39
+
40
+ # This linter can also be disabled by a comment at the top of the file
41
+ first_child = root.children.first
42
+ first_child.nil? || !first_child.disabled?(self)
43
+ end
44
+
45
+ # The type of files the linter is configured to check.
46
+ #
47
+ # @api private
48
+ # @return [String]
49
+ def file_types
50
+ @file_types ||= config['file_types'] || 'partial'
51
+ end
52
+
53
+ # The matcher to use for testing whether to check a file by file name.
54
+ #
55
+ # @api private
56
+ # @return [Regexp]
57
+ def matcher
58
+ @matcher ||= Regexp.new(config['matchers'][file_types] || '\A_.*\.haml\z')
59
+ end
60
+
61
+ # The error message when an `locals` comment is not found.
62
+ #
63
+ # @api private
64
+ # @return [String]
65
+ def failure_message
66
+ 'Expected a strict `-# locals: ()` comment at the beginning of the file'
67
+ end
68
+ end
69
+ end
@@ -5,11 +5,24 @@ module HamlLint
5
5
  class Linter::TagName < Linter
6
6
  include LinterRegistry
7
7
 
8
+ supports_autocorrect(true)
9
+
8
10
  def visit_tag(node)
9
11
  tag = node.tag_name
10
- return unless tag =~ /[A-Z]/
12
+ return unless /[A-Z]/.match?(tag)
13
+
14
+ corrected = correct_tag_name(node, tag)
15
+ record_lint(node, "`#{tag}` should be written in lowercase as `#{tag.downcase}`",
16
+ corrected: corrected)
17
+ end
18
+
19
+ private
11
20
 
12
- record_lint(node, "`#{tag}` should be written in lowercase as `#{tag.downcase}`")
21
+ # @return [Boolean]
22
+ def correct_tag_name(node, tag)
23
+ index = node.line - 1
24
+ line = autocorrected_lines[index]
25
+ correct_line(index, line.sub(/(%)#{Regexp.escape(tag)}/, "\\1#{tag.downcase}"))
13
26
  end
14
27
  end
15
28
  end
@@ -5,6 +5,8 @@ module HamlLint
5
5
  class Linter::TrailingEmptyLines < Linter
6
6
  include LinterRegistry
7
7
 
8
+ supports_autocorrect(true)
9
+
8
10
  DummyNode = Struct.new(:line)
9
11
 
10
12
  def visit_root(root)
@@ -16,7 +18,17 @@ module HamlLint
16
18
 
17
19
  return unless document.source.end_with?("\n\n")
18
20
 
19
- record_lint(line_number, 'Files should not end with trailing empty lines')
21
+ record_lint(line_number, 'Files should not end with trailing empty lines',
22
+ corrected: autocorrect?)
23
+
24
+ apply_autocorrect(corrected_source)
25
+ end
26
+
27
+ private
28
+
29
+ def corrected_source
30
+ last_non_empty_index = document.source_lines.rindex { |line| !line.empty? } || 0
31
+ "#{document.source_lines[0..last_non_empty_index].join("\n")}\n"
20
32
  end
21
33
  end
22
34
  end
@@ -5,17 +5,30 @@ module HamlLint
5
5
  class Linter::TrailingWhitespace < Linter
6
6
  include LinterRegistry
7
7
 
8
+ supports_autocorrect(true)
9
+
8
10
  DummyNode = Struct.new(:line)
9
11
 
10
12
  def visit_root(root)
13
+ new_lines = document.source_lines.dup
14
+ changed = false
15
+
11
16
  document.source_lines.each_with_index do |line, index|
12
- next unless line =~ /\s+$/
17
+ next unless /\s+$/.match?(line)
13
18
 
14
19
  node = root.node_for_line(index + 1)
15
- unless node.disabled?(self)
16
- record_lint DummyNode.new(index + 1), 'Line contains trailing whitespace'
20
+ next if node.disabled?(self)
21
+
22
+ if autocorrect?
23
+ new_lines[index] = line.sub(/\s+$/, '')
24
+ changed = true
17
25
  end
26
+
27
+ record_lint(DummyNode.new(index + 1), 'Line contains trailing whitespace',
28
+ corrected: autocorrect?)
18
29
  end
30
+
31
+ apply_autocorrect(new_lines.join("\n")) if changed
19
32
  end
20
33
  end
21
34
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HamlLint
4
+ # Flags Haml's unescaped-output markers (`!=`, `!~`, and the unescaped
5
+ # plain-text `!`), which bypass HTML escaping.
6
+ #
7
+ # Like `raw`, `html_safe`, and `h()` in Rails, these make it easy to
8
+ # accidentally introduce XSS vulnerabilities when the output includes
9
+ # user-controlled data, e.g.:
10
+ #
11
+ # != "Username: <strong>#{user.name}</strong>"
12
+ class Linter::UnescapedHtml < Linter
13
+ include LinterRegistry
14
+
15
+ MESSAGE =
16
+ 'Avoid outputting unescaped HTML with `!`; it bypasses HTML escaping and ' \
17
+ 'can introduce XSS vulnerabilities. Sanitize the value instead.'
18
+
19
+ def visit_script(node)
20
+ record_lint(node, MESSAGE) if /\A\s*!/.match?(node.source_code)
21
+ end
22
+
23
+ def visit_tag(node)
24
+ record_lint(node, MESSAGE) if node.unescape_html?
25
+ end
26
+ end
27
+ end
@@ -11,21 +11,48 @@ module HamlLint
11
11
  class Linter::UnnecessaryInterpolation < Linter
12
12
  include LinterRegistry
13
13
 
14
+ supports_autocorrect(true)
15
+
14
16
  def visit_tag(node)
15
17
  return if node.script.length <= 2
16
18
 
17
19
  count = 0
18
20
  chars = 2 # Include surrounding quote chars
19
- HamlLint::Utils.extract_interpolated_values(node.script) do |interpolated_code, _line|
21
+ interpolated_code = nil
22
+ HamlLint::Utils.extract_interpolated_values(node.script) do |code, _line|
20
23
  count += 1
21
24
  return if count > 1 # rubocop:disable Lint/NonLocalExitFromIterator
22
- chars += interpolated_code.length + 3
25
+ chars += code.length + 3
26
+ interpolated_code = code
23
27
  end
24
28
 
25
29
  if chars == node.script.length
30
+ corrected = correct_interpolation(node, interpolated_code)
26
31
  record_lint(node, '`%... \#{expression}` can be written without ' \
27
- 'interpolation as `%...= expression`')
32
+ 'interpolation as `%...= expression`', corrected: corrected)
28
33
  end
29
34
  end
35
+
36
+ private
37
+
38
+ # @return [Boolean]
39
+ def correct_interpolation(node, interpolated_code)
40
+ return false unless interpolated_code
41
+
42
+ index = node.line - 1
43
+ line = autocorrected_lines[index]
44
+ escaped = Regexp.escape(interpolated_code)
45
+
46
+ new_line =
47
+ if inline_content_is_string?(node)
48
+ line.sub(/=\s*"\#\{#{escaped}\}"\s*\z/, "= #{interpolated_code}")
49
+ else
50
+ line.sub(/\s+\#\{#{escaped}\}\s*\z/, "= #{interpolated_code}")
51
+ end
52
+
53
+ return false if new_line == line
54
+
55
+ correct_line(index, new_line)
56
+ end
30
57
  end
31
58
  end