haml_lint 0.69.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 (34) 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 +6 -18
  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 +41 -5
  28. data/lib/haml_lint/ruby_extraction/coordinator.rb +7 -2
  29. data/lib/haml_lint/runner.rb +17 -3
  30. data/lib/haml_lint/source.rb +2 -6
  31. data/lib/haml_lint/tree/filter_node.rb +13 -0
  32. data/lib/haml_lint/tree/tag_node.rb +55 -23
  33. data/lib/haml_lint/version.rb +1 -1
  34. metadata +5 -4
@@ -5,6 +5,8 @@ module HamlLint
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&.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
@@ -9,7 +9,7 @@ module HamlLint::RubyExtraction
9
9
  class ChunkExtractor
10
10
  include HamlLint::HamlVisitor
11
11
 
12
- attr_reader :script_output_prefix
12
+ attr_reader :script_output_prefix, :autocorrect
13
13
 
14
14
  HAML_PARSER_INSTANCE = if Haml::VERSION >= '5.0.0'
15
15
  ::Haml::Parser.new({})
@@ -21,9 +21,10 @@ module HamlLint::RubyExtraction
21
21
  # We don't. So the regex must be fixed to correctly detect the start of the string.
22
22
  BLOCK_KEYWORD_REGEX = Regexp.new(Haml::Parser::BLOCK_KEYWORD_REGEX.source.sub('^', '\A'))
23
23
 
24
- def initialize(document, script_output_prefix:)
24
+ def initialize(document, script_output_prefix:, autocorrect: nil)
25
25
  @document = document
26
26
  @script_output_prefix = script_output_prefix
27
+ @autocorrect = autocorrect
27
28
  end
28
29
 
29
30
  def extract
@@ -299,7 +300,13 @@ module HamlLint::RubyExtraction
299
300
  end
300
301
 
301
302
  if attributes_code&.start_with?('{')
302
- # 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)
303
310
  attributes_code = nil
304
311
  end
305
312
 
@@ -344,6 +351,22 @@ module HamlLint::RubyExtraction
344
351
  final_line_index
345
352
  end
346
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
+
347
370
  # Visiting the script besides tag. The part to the right of the equal sign of
348
371
  # lines looking like ` %div= foo(bar)`
349
372
  def visit_tag_script(node, line_index:, indent:)
@@ -391,8 +414,10 @@ module HamlLint::RubyExtraction
391
414
  filter_name_indent = @original_haml_lines[node.line - 1].index(/\S/)
392
415
  if node.filter_type == 'ruby'
393
416
  # The indentation in node.text is normalized, so that at least one line
394
- # is indented by 0.
395
- 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
396
421
  lines.map! do |line|
397
422
  if !/\S/.match?(line)
398
423
  # whitespace or empty
@@ -637,8 +662,19 @@ module HamlLint::RubyExtraction
637
662
  def wrap_lines(lines, wrap_depth)
638
663
  lines = lines.dup
639
664
  wrapping_prefix = 'W' * (wrap_depth - 1) + '('
665
+
666
+ # Strip leading/trailing space only when linting (not autocorrecting) to avoid SpaceInsideParens violations.
667
+ # Preserve spaces for fully nested attribute hashes that need no adjustment.
668
+ # Preserve spaces during autocorrect, so RuboCop can fix them and map corrections back correctly.
669
+ unless lines[0] =~ /^\s*$/ || autocorrect
670
+ leading_spaces = lines[0][/^\s*/].length
671
+ lines = lines.map { |line| line.gsub(/^\s{,#{leading_spaces}}/, '') }
672
+ lines[-1] = lines[-1].gsub(/\s+\z/, '')
673
+ end
674
+
640
675
  lines[0] = wrapping_prefix + lines[0]
641
676
  lines[-1] = lines[-1] + ')'
677
+
642
678
  lines
643
679
  end
644
680