haml_lint 0.73.0 → 0.74.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +9 -4
  3. data/lib/haml_lint/cli.rb +5 -1
  4. data/lib/haml_lint/document.rb +0 -5
  5. data/lib/haml_lint/linter/class_attribute_with_static_value.rb +51 -11
  6. data/lib/haml_lint/linter/classes_before_ids.rb +16 -1
  7. data/lib/haml_lint/linter/consecutive_comments.rb +20 -1
  8. data/lib/haml_lint/linter/empty_object_reference.rb +16 -1
  9. data/lib/haml_lint/linter/empty_script.rb +31 -1
  10. data/lib/haml_lint/linter/final_newline.rb +31 -7
  11. data/lib/haml_lint/linter/implicit_div.rb +14 -1
  12. data/lib/haml_lint/linter/leading_comment_space.rb +13 -1
  13. data/lib/haml_lint/linter/multiline_script.rb +65 -5
  14. data/lib/haml_lint/linter/rubocop.rb +5 -17
  15. data/lib/haml_lint/linter/ruby_comments.rb +13 -3
  16. data/lib/haml_lint/linter/space_before_script.rb +36 -3
  17. data/lib/haml_lint/linter/space_inside_hash_attributes.rb +41 -3
  18. data/lib/haml_lint/linter/tag_name.rb +14 -1
  19. data/lib/haml_lint/linter/trailing_empty_lines.rb +13 -1
  20. data/lib/haml_lint/linter/trailing_whitespace.rb +15 -2
  21. data/lib/haml_lint/linter/unescaped_html.rb +27 -0
  22. data/lib/haml_lint/linter/unnecessary_interpolation.rb +30 -3
  23. data/lib/haml_lint/linter/unnecessary_string_output.rb +34 -2
  24. data/lib/haml_lint/linter.rb +111 -0
  25. data/lib/haml_lint/reporter/disabled_config_reporter.rb +26 -5
  26. data/lib/haml_lint/reporter/github_reporter.rb +26 -8
  27. data/lib/haml_lint/ruby_extraction/chunk_extractor.rb +27 -3
  28. data/lib/haml_lint/runner.rb +17 -3
  29. data/lib/haml_lint/source.rb +2 -6
  30. data/lib/haml_lint/tree/filter_node.rb +13 -0
  31. data/lib/haml_lint/tree/tag_node.rb +55 -23
  32. data/lib/haml_lint/version.rb +1 -1
  33. metadata +3 -2
@@ -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&.match?(style[:start_regex])
31
- record_lint(node, style[:end_message]) unless source&.match?(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
@@ -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
12
  return unless /[A-Z]/.match?(tag)
11
13
 
12
- record_lint(node, "`#{tag}` should be written in lowercase as `#{tag.downcase}`")
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
20
+
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
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
@@ -33,8 +33,11 @@ module HamlLint
33
33
 
34
34
  def outputs_string_literal?(script_node)
35
35
  return unless tree = parse_ruby(script_node.script)
36
- %i[str dstr].include?(tree.type) &&
37
- !starts_with_reserved_character?(tree.children.first)
36
+ return unless %i[str dstr].include?(tree.type)
37
+
38
+ !starts_with_reserved_character?(tree.children.first) &&
39
+ !contains_escape_sequence?(tree) &&
40
+ !contains_significant_whitespace?(tree)
38
41
  rescue ::Parser::SyntaxError
39
42
  # Gracefully ignore syntax errors, as that's managed by a different linter
40
43
  end
@@ -45,5 +48,34 @@ module HamlLint
45
48
  string = stringish.respond_to?(:children) ? stringish.children.first : stringish
46
49
  string =~ %r{\A\s*[/#-=%~]} if string.is_a?(String)
47
50
  end
51
+
52
+ # The ordered segments of a string literal, including any interpolation.
53
+ # A plain `str` node has no interpolation, so it is its own only segment.
54
+ def string_segments(tree)
55
+ tree.type == :dstr ? tree.children : [tree]
56
+ end
57
+
58
+ # Returns whether any literal portion of the string contains a backslash
59
+ # escape (e.g. `\n`, `\t`, `\u202F`). Such escapes are interpreted inside
60
+ # a Ruby string but would be emitted verbatim as HAML plain text, so the
61
+ # `= "..."` form is not equivalent to the unwrapped plain text.
62
+ def contains_escape_sequence?(tree)
63
+ string_segments(tree).any? do |segment|
64
+ segment.type == :str && segment.location.expression.source.include?('\\')
65
+ end
66
+ end
67
+
68
+ # Returns whether the string begins or ends with whitespace. HAML strips
69
+ # trailing whitespace from plain text (and leading whitespace denotes
70
+ # indentation), so unwrapping such a string would change the output.
71
+ def contains_significant_whitespace?(tree)
72
+ segments = string_segments(tree)
73
+ bounded_by_whitespace?(segments.first, /\A\s/) ||
74
+ bounded_by_whitespace?(segments.last, /\s\z/)
75
+ end
76
+
77
+ def bounded_by_whitespace?(segment, pattern)
78
+ segment.type == :str && segment.children.first.to_s.match?(pattern)
79
+ end
48
80
  end
49
81
  end
@@ -63,6 +63,7 @@ module HamlLint
63
63
  @document = document
64
64
  @lints = []
65
65
  @autocorrect = autocorrect
66
+ reset_autocorrect_state
66
67
  visit(document.tree)
67
68
  @lints
68
69
  end
@@ -85,6 +86,30 @@ module HamlLint
85
86
  self.class.supports_autocorrect?
86
87
  end
87
88
 
89
+ # Returns whether this linter's autocorrect is safe. Defaults to true (safe)
90
+ # unless the linter declares otherwise via `autocorrect_safe(false)`.
91
+ #
92
+ # @return [Boolean]
93
+ def self.autocorrect_safe?
94
+ @autocorrect_safe != false
95
+ end
96
+
97
+ # The autocorrect ordering priority for this linter. During autocorrect,
98
+ # linters with a lower priority run first and higher priority run later,
99
+ # against the same (already mutated) document. Linters with the same priority
100
+ # keep their default (alphabetical) order.
101
+ #
102
+ # Called with an argument (e.g. `autocorrect_priority(1)`) in a linter's
103
+ # top-level scope, it sets the priority; called with no argument it reads it.
104
+ # The default, when never set, is 0.
105
+ #
106
+ # @param value [Integer, nil] the new priority, or nil to read the current one
107
+ # @return [Integer]
108
+ def self.autocorrect_priority(value = nil)
109
+ @autocorrect_priority = value unless value.nil?
110
+ @autocorrect_priority || 0
111
+ end
112
+
88
113
  private
89
114
 
90
115
  attr_reader :config, :document
@@ -97,6 +122,92 @@ module HamlLint
97
122
  @supports_autocorrect = value
98
123
  end
99
124
 
125
+ # Linters can call autocorrect_safe(false) in their top-level scope to declare that their
126
+ # autocorrect is unsafe, meaning it only runs under `--auto-correct-all` (`:all`).
127
+ # The default, when not called, is safe.
128
+ #
129
+ # @params value [Boolean] The new value for autocorrect_safe
130
+ private_class_method def self.autocorrect_safe(value)
131
+ @autocorrect_safe = value
132
+ end
133
+
134
+ # Resets the per-run autocorrect bookkeeping. Linter instances are reused
135
+ # across files, so subclasses that accumulate extra autocorrect state during
136
+ # a traversal (e.g. a list of lines to merge or delete) must override this,
137
+ # calling `super`, to clear that state between files. Otherwise corrections
138
+ # from one file leak into the next.
139
+ def reset_autocorrect_state
140
+ @autocorrected_lines = nil
141
+ @autocorrect_changed = false
142
+ end
143
+
144
+ # Whether the linter is currently allowed to apply autocorrections, given the active
145
+ # autocorrect mode and this linter's declared safety:
146
+ #
147
+ # * safe linters correct under both `:safe` and `:all`;
148
+ # * unsafe linters correct only under `:all`;
149
+ # * with no mode (`nil`) nothing is corrected (detection only).
150
+ #
151
+ # @return [Boolean]
152
+ def autocorrect?
153
+ case @autocorrect
154
+ when :all
155
+ true
156
+ when :safe
157
+ self.class.autocorrect_safe?
158
+ else
159
+ false
160
+ end
161
+ end
162
+
163
+ # Applies a corrected, full-document source to the document through the single
164
+ # mutation path (`Document#change_source`), but only when the safety gate permits.
165
+ # No-ops otherwise; `change_source` itself also no-ops when the source is unchanged.
166
+ #
167
+ # @param new_source [String] the corrected HAML source
168
+ def apply_autocorrect(new_source)
169
+ return unless autocorrect?
170
+ document.change_source(new_source)
171
+ end
172
+
173
+ # Lazily-initialized working copy of the document's source lines, used to
174
+ # accumulate line-level corrections during a single tree traversal. Editing
175
+ # this copy (rather than calling `apply_autocorrect` per node) avoids
176
+ # reparsing the document mid-walk, which would invalidate the tree being
177
+ # visited.
178
+ #
179
+ # @return [Array<String>]
180
+ def autocorrected_lines
181
+ @autocorrected_lines ||= document.source_lines.dup
182
+ end
183
+
184
+ # Replaces a single source line (0-indexed) in the working copy, but only
185
+ # when autocorrect is permitted and the new text actually differs.
186
+ #
187
+ # @param index [Integer] the 0-indexed line to replace
188
+ # @param new_text [String] the corrected line
189
+ # @return [Boolean] true if a change was recorded, false otherwise
190
+ def correct_line(index, new_text)
191
+ return false unless autocorrect?
192
+ return false if autocorrected_lines[index] == new_text
193
+
194
+ autocorrected_lines[index] = new_text
195
+ @autocorrect_changed = true
196
+ end
197
+
198
+ # Flushes any corrections accumulated via `correct_line` through the single
199
+ # mutation path, once, after the whole tree has been visited. Defined on the
200
+ # base so per-node linters need no boilerplate; no-ops for linters that never
201
+ # accumulated a change (including those that apply within `visit_root`).
202
+ #
203
+ # NOTE: a linter that overrides `after_visit_root` must call `super`, or
204
+ # accumulated corrections will not be applied.
205
+ def after_visit_root(_node)
206
+ return unless @autocorrect_changed
207
+
208
+ apply_autocorrect(autocorrected_lines.join("\n"))
209
+ end
210
+
100
211
  # Record a lint for reporting back to the user.
101
212
  #
102
213
  # @param node_or_line [#line] line number or node to extract the line number from
@@ -5,10 +5,12 @@ require_relative 'progress_reporter'
5
5
  module HamlLint
6
6
  # Outputs a YAML configuration file based on existing violations.
7
7
  class Reporter::DisabledConfigReporter < Reporter::ProgressReporter
8
- HEADING =
8
+ DEFAULT_EXCLUDE_LIMIT = 15
9
+
10
+ HEADING_TEMPLATE =
9
11
  ['# This configuration was generated by',
10
- '# `haml-lint --auto-gen-config`',
11
- "# on #{Time.now} using Haml-Lint version #{HamlLint::VERSION}.",
12
+ '# `%<command>s`',
13
+ '# on %<timestamp>s using Haml-Lint version %<version>s.',
12
14
  '# The point is for the user to remove these configuration records',
13
15
  '# one by one as the lints are removed from the code base.',
14
16
  '# Note that changes in the inspected code, or installation of new',
@@ -25,11 +27,12 @@ module HamlLint
25
27
  # Create the reporter that will display the report and write the config.
26
28
  #
27
29
  # @param _log [HamlLint::Logger]
28
- def initialize(log, limit: 15)
30
+ def initialize(log, limit: DEFAULT_EXCLUDE_LIMIT, options: {})
29
31
  super(log)
30
32
  @linters_with_lints = Hash.new { |hash, key| hash[key] = [] }
31
33
  @linters_lint_count = Hash.new(0)
32
34
  @exclude_limit = limit
35
+ @options = options
33
36
  end
34
37
 
35
38
  # A hash of linters with the files that have that type of lint.
@@ -81,12 +84,30 @@ module HamlLint
81
84
 
82
85
  private
83
86
 
87
+ # Reconstructs the CLI command used to generate this config.
88
+ #
89
+ # @return [String] the command string
90
+ def command
91
+ cmd = "#{HamlLint::APP_NAME} --auto-gen-config"
92
+ if @options[:auto_gen_exclude_limit]
93
+ cmd += " --auto-gen-exclude-limit #{@options[:auto_gen_exclude_limit]}"
94
+ end
95
+ cmd
96
+ end
97
+
98
+ # The heading comment for the generated config file.
99
+ #
100
+ # @return [String]
101
+ def heading
102
+ format(HEADING_TEMPLATE, command: command, timestamp: Time.now, version: HamlLint::VERSION)
103
+ end
104
+
84
105
  # The contents of the generated configuration file based on captured lint.
85
106
  #
86
107
  # @return [String] a Yaml-formatted configuration file's contents
87
108
  def config_file_contents
88
109
  output = []
89
- output << HEADING
110
+ output << heading
90
111
  output << 'linters:' if linters_with_lints.any?
91
112
  linters_with_lints.sort.each do |linter, files|
92
113
  output << generate_config_for_linter(linter, files)
@@ -3,16 +3,16 @@
3
3
  module HamlLint
4
4
  # Outputs GitHub workflow commands for GitHub check annotations when run within GitHub actions.
5
5
  class Reporter::GithubReporter < Reporter
6
+ # Characters to escape within a command message.
6
7
  ESCAPE_MAP = { '%' => '%25', "\n" => '%0A', "\r" => '%0D' }.freeze
8
+ # Property values also escape the `:` and `,` that delimit the command.
9
+ PROPERTY_ESCAPE_MAP = ESCAPE_MAP.merge(':' => '%3A', ',' => '%2C').freeze
7
10
 
8
11
  include Reporter::Utils
9
12
 
10
13
  def added_lint(lint, report)
11
- if lint.severity >= report.fail_level
12
- print_workflow_command(lint: lint)
13
- else
14
- print_workflow_command(severity: 'warning', lint: lint)
15
- end
14
+ severity = lint.severity >= report.fail_level ? 'error' : 'warning'
15
+ print_workflow_command(lint, severity)
16
16
  end
17
17
 
18
18
  def display_report(report)
@@ -21,12 +21,30 @@ module HamlLint
21
21
 
22
22
  private
23
23
 
24
- def print_workflow_command(lint:, severity: 'error')
25
- log.log "::#{severity} file=#{lint.filename},line=#{lint.line}::#{github_escape(lint.message)}"
24
+ def print_workflow_command(lint, severity)
25
+ log.log "::#{severity} file=#{github_escape_property(lint.filename)}," \
26
+ "line=#{lint.line},title=#{github_escape_property(annotation_title(lint))}" \
27
+ "::#{github_escape(annotation_message(lint))}"
28
+ end
29
+
30
+ # Annotation title, naming the linter that produced the lint when known.
31
+ def annotation_title(lint)
32
+ lint.linter ? "haml-lint #{lint.linter.name}" : 'haml-lint'
33
+ end
34
+
35
+ # Message body, prefixed with the location and linter so it stays useful in the plain log.
36
+ def annotation_message(lint)
37
+ location = "#{lint.filename}:#{lint.line}"
38
+ location += " #{lint.linter.name}:" if lint.linter
39
+ "#{location} #{lint.message}"
26
40
  end
27
41
 
28
42
  def github_escape(string)
29
- string.gsub(Regexp.union(ESCAPE_MAP.keys), ESCAPE_MAP)
43
+ string.to_s.gsub(Regexp.union(ESCAPE_MAP.keys), ESCAPE_MAP)
44
+ end
45
+
46
+ def github_escape_property(string)
47
+ string.to_s.gsub(Regexp.union(PROPERTY_ESCAPE_MAP.keys), PROPERTY_ESCAPE_MAP)
30
48
  end
31
49
  end
32
50
  end
@@ -300,7 +300,13 @@ module HamlLint::RubyExtraction
300
300
  end
301
301
 
302
302
  if attributes_code&.start_with?('{')
303
- # Looks like the .foo(bar = 123) case. Ignoring.
303
+ # HTML-style (parens) attributes, e.g. %div(foo=foo), arrive as a synthesized hash string
304
+ # like '{"foo" => foo,}'. That text isn't present verbatim in the HAML, so we can't map
305
+ # RuboCop corrections back and intentionally don't autocorrect it. We still extract the
306
+ # attribute *values* as a non-correctable chunk so RuboCop sees variables used here;
307
+ # otherwise they look unused (false Lint/UselessAssignment, plus unsafe autocorrect that
308
+ # removes the assignment). See issue #648.
309
+ add_html_attributes_value_usage(node, attributes_code, indent: indent)
304
310
  attributes_code = nil
305
311
  end
306
312
 
@@ -345,6 +351,22 @@ module HamlLint::RubyExtraction
345
351
  final_line_index
346
352
  end
347
353
 
354
+ # For HTML-style (parens) attributes, adds the attribute value expressions as a
355
+ # non-autocorrectable chunk purely so RuboCop counts the variables/methods used there.
356
+ def add_html_attributes_value_usage(node, attributes_code, indent:)
357
+ ast = parse_ruby(attributes_code)
358
+ return unless ast&.type == :hash
359
+
360
+ value_sources = ast.children.filter_map do |pair|
361
+ next unless pair.type == :pair
362
+ pair.children[1]&.source
363
+ end
364
+ return if value_sources.empty?
365
+
366
+ lines = value_sources.map { |value| "#{' ' * indent}#{script_output_prefix}#{value}" }
367
+ @ruby_chunks << AdHocChunk.new(node, lines)
368
+ end
369
+
348
370
  # Visiting the script besides tag. The part to the right of the equal sign of
349
371
  # lines looking like ` %div= foo(bar)`
350
372
  def visit_tag_script(node, line_index:, indent:)
@@ -392,8 +414,10 @@ module HamlLint::RubyExtraction
392
414
  filter_name_indent = @original_haml_lines[node.line - 1].index(/\S/)
393
415
  if node.filter_type == 'ruby'
394
416
  # The indentation in node.text is normalized, so that at least one line
395
- # is indented by 0.
396
- lines = node.text.split("\n")
417
+ # is indented by 0. Split("\n", -1) + pop keeps trailing blank lines (plain split drops them),
418
+ # which the end marker needs so it doesn't land right after the last code line.
419
+ lines = node.text.split("\n", -1)
420
+ lines.pop
397
421
  lines.map! do |line|
398
422
  if !/\S/.match?(line)
399
423
  # whitespace or empty
@@ -23,7 +23,8 @@ module HamlLint
23
23
  def run(options = {})
24
24
  @config = load_applicable_config(options)
25
25
  @sources = extract_applicable_sources(config, options)
26
- @linter_selector = HamlLint::LinterSelector.new(config, options)
26
+ @options = options
27
+ @linter_selector = build_linter_selector
27
28
  @fail_fast = options.fetch(:fail_fast, false)
28
29
  @cache = {}
29
30
  @autocorrect = options[:autocorrect]
@@ -61,6 +62,17 @@ module HamlLint
61
62
  # @return [HamlLint::LinterSelector]
62
63
  attr_reader :linter_selector
63
64
 
65
+ # Returns a fresh selector for this run.
66
+ #
67
+ # LinterSelector memoizes linter instances, and linters mutate instance
68
+ # state while processing a document. JRuby runs Parallel.map in threads, so
69
+ # parallel jobs must not share a selector.
70
+ #
71
+ # @return [HamlLint::LinterSelector]
72
+ def build_linter_selector
73
+ HamlLint::LinterSelector.new(config, @options)
74
+ end
75
+
64
76
  # Returns the {HamlLint::Configuration} that should be used given the
65
77
  # specified options.
66
78
  #
@@ -89,7 +101,6 @@ module HamlLint
89
101
  begin
90
102
  document = HamlLint::Document.new source.contents, file: source.path,
91
103
  config: config,
92
- file_on_disk: !source.stdin?,
93
104
  write_to_stdout: @autocorrect_stdout
94
105
  rescue HamlLint::Exceptions::ParseError => e
95
106
  return [HamlLint::Lint.new(HamlLint::Linter::Syntax.new(config), source.path,
@@ -122,6 +133,9 @@ module HamlLint
122
133
  lint_arrays = []
123
134
 
124
135
  autocorrecting_linters = linters.select(&:supports_autocorrect?)
136
+ .each_with_index
137
+ .sort_by { |linter, index| [linter.class.autocorrect_priority, index] }
138
+ .map(&:first)
125
139
  lint_arrays << autocorrecting_linters.map do |linter|
126
140
  linter.run(document, autocorrect: @autocorrect)
127
141
  end
@@ -191,7 +205,7 @@ module HamlLint
191
205
  # @return [void]
192
206
  def warm_cache
193
207
  results = Parallel.map(sources) do |source|
194
- lints = collect_lints(source, linter_selector, config)
208
+ lints = collect_lints(source, build_linter_selector, config)
195
209
  [source.path, lints]
196
210
  end
197
211
  @cache = results.to_h
@@ -13,17 +13,13 @@ module HamlLint
13
13
  # @param [IO] io
14
14
  def initialize(path: nil, io: nil)
15
15
  @path = path
16
+ @file_path = File.expand_path(path) if path
16
17
  @io = io
17
18
  end
18
19
 
19
20
  # @return [String] Contents of the given IO object.
20
21
  def contents
21
- @contents ||= @io&.read || File.read(path)
22
- end
23
-
24
- # @return [boolean] true if we're reading from stdin rather than a file path
25
- def stdin?
26
- !@io.nil?
22
+ @contents ||= @io&.read || File.read(@file_path)
27
23
  end
28
24
  end
29
25
  end
@@ -17,5 +17,18 @@ module HamlLint::Tree
17
17
 
18
18
  "#{"\n" * nb_blank_lines}#{super}"
19
19
  end
20
+
21
+ # The line numbers that are contained within the node.
22
+ #
23
+ # Unlike most nodes, a filter's content begins on the line *after* the
24
+ # `:filtername` declaration, so the span runs from the declaration line
25
+ # through all of the indented content lines.
26
+ #
27
+ # @return [Range]
28
+ def line_numbers
29
+ return super if lines.empty?
30
+
31
+ (line..(line + lines.count))
32
+ end
20
33
  end
21
34
  end