haml_lint 0.75.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8bb347cd92b5e23b37b8beb59d2c67f23eecd1e5778fdc45a7c4fbd73d14f854
4
- data.tar.gz: 93836aaa95ab3bde6932969c81fb9e409c9806ab9057a1162580e1771f1edff7
3
+ metadata.gz: 188328ae96f24b3a739665a5d3ccc7271a753afbe0ae63a1fbe3f33897492caa
4
+ data.tar.gz: 52b39c0992433545f610c7c18af3d06b224027edea3a9cdb97f71c7c1cf4f8c5
5
5
  SHA512:
6
- metadata.gz: 5c5789d9ca6de3d2cae48ef91277b374ed84b477323389ccc7049d1d4e690ef1cb2252781074c902c7b82fa9dd332a36ace0d25652723841e170c89c9c805e15
7
- data.tar.gz: 325df8db407db7567bf88db824dc2a071af3457c827b5268e96e6e2e17f0a63b6192974082ed8e4bab9ca0b645aacfbec981d7a25e6e9e7330d28f8c60df2123
6
+ metadata.gz: 31c53c1177acc7679c9f3c5b633a2b14278252f447ddea3b03c9d49e82d6e3d4587d44803e7b2b5ca677b726702c1a716cb02c848b8d2b71ed7bd402b78d8519
7
+ data.tar.gz: 253e971535ea2d8c38340e2e13a882fbe215cded72f5faa96b26dd88bb39024efe5b03d8334cc7692de4edbab2a7e49db9d4bb55a6a55e10319b52e3770a9ea4
@@ -6,6 +6,9 @@ module HamlLint
6
6
  # @return [Boolean] If the error was corrected by auto-correct
7
7
  attr_reader :corrected
8
8
 
9
+ # @return [Boolean] If the error could be corrected by auto-correct
10
+ attr_reader :correctable
11
+
9
12
  # @return [String] file path to which the lint applies
10
13
  attr_reader :filename
11
14
 
@@ -28,13 +31,14 @@ module HamlLint
28
31
  # @param line [Fixnum]
29
32
  # @param message [String]
30
33
  # @param severity [Symbol]
31
- def initialize(linter, filename, line, message, severity = :warning, corrected: false) # rubocop:disable Metrics/ParameterLists
34
+ def initialize(linter, filename, line, message, severity = :warning, corrected: false, correctable: false) # rubocop:disable Metrics/ParameterLists
32
35
  @linter = linter
33
36
  @filename = filename
34
37
  @line = line || 0
35
38
  @message = message
36
39
  @severity = Severity.new(severity)
37
40
  @corrected = corrected
41
+ @correctable = correctable
38
42
  end
39
43
 
40
44
  # Return whether this lint has a severity of error.
@@ -45,7 +49,8 @@ module HamlLint
45
49
  end
46
50
 
47
51
  def inspect
48
- "#{self.class.name}(corrected=#{corrected}, filename=#{filename}, line=#{line}, " \
52
+ "#{self.class.name}(corrected=#{corrected}, correctable=#{correctable}, " \
53
+ "filename=#{filename}, line=#{line}, " \
49
54
  "linter=#{linter.class.name}, message=#{message}, severity=#{severity})"
50
55
  end
51
56
  end
@@ -3,12 +3,29 @@
3
3
  module HamlLint
4
4
  # Checks for tabs that are placed for alignment of tag content
5
5
  class Linter::AlignmentTabs < Linter
6
+ include LinterRegistry
7
+
8
+ supports_autocorrect(true)
9
+ autocorrect_safe(false)
10
+
6
11
  REGEX = /[^\s*]\t+/
12
+ # Same as +REGEX+, but captures the character preceding the alignment tabs
13
+ # so it can be kept while the tabs themselves are collapsed to a space.
14
+ CORRECTION_REGEX = /([^\s*])\t+/
7
15
 
8
16
  def visit_tag(node)
9
- if REGEX.match?(node.source_code)
10
- record_lint(node, 'Avoid using tabs for alignment')
11
- end
17
+ return unless REGEX.match?(node.source_code)
18
+
19
+ record_lint(node, 'Avoid using tabs for alignment', corrected: correct(node))
20
+ end
21
+
22
+ private
23
+
24
+ # @return [Boolean] whether a correction was applied
25
+ def correct(node)
26
+ index = node.line - 1
27
+ line = autocorrected_lines[index]
28
+ correct_line(index, line.gsub(CORRECTION_REGEX) { "#{::Regexp.last_match(1)} " })
12
29
  end
13
30
  end
14
31
  end
@@ -6,6 +6,9 @@ module HamlLint
6
6
  class Linter::ConsecutiveSilentScripts < Linter
7
7
  include LinterRegistry
8
8
 
9
+ supports_autocorrect(true)
10
+ autocorrect_safe(false)
11
+
9
12
  SILENT_SCRIPT_DETECTOR = ->(child) do
10
13
  child.type == :silent_script && child.children.empty?
11
14
  end
@@ -18,12 +21,21 @@ module HamlLint
18
21
  SILENT_SCRIPT_DETECTOR,
19
22
  config['max_consecutive'] + 1,
20
23
  ) do |group|
24
+ reported_nodes.concat(group)
21
25
  record_lint(group.first,
22
26
  "#{group.count} consecutive Ruby scripts can be merged " \
23
- 'into a single `:ruby` filter')
27
+ 'into a single `:ruby` filter',
28
+ corrected: collect_merge(group))
24
29
  end
25
30
  end
26
31
 
32
+ def after_visit_root(_node)
33
+ super
34
+ return if merges.empty?
35
+
36
+ apply_autocorrect(merged_source)
37
+ end
38
+
27
39
  private
28
40
 
29
41
  def possible_group(node)
@@ -37,5 +49,60 @@ module HamlLint
37
49
  def reported_nodes
38
50
  @reported_nodes ||= []
39
51
  end
52
+
53
+ def reset_autocorrect_state
54
+ super
55
+ @merges = []
56
+ end
57
+
58
+ # Queues a group of consecutive silent scripts to be replaced by an
59
+ # equivalent `:ruby` filter block. Skips (and reports without correcting)
60
+ # groups that aren't a run of single-line scripts on contiguous lines.
61
+ #
62
+ # @return [Boolean] whether a correction was queued
63
+ def collect_merge(group)
64
+ return false unless autocorrect?
65
+ return false unless mergeable?(group)
66
+
67
+ merges << {
68
+ start: group.first.line - 1,
69
+ finish: group.last.line - 1,
70
+ lines: ruby_filter_lines(group),
71
+ }
72
+ true
73
+ end
74
+
75
+ # @return [Boolean] whether every script is a single line and the group
76
+ # occupies a contiguous run of lines
77
+ def mergeable?(group)
78
+ group.none? { |node| node.script.include?("\n") } &&
79
+ group.each_cons(2).all? { |a, b| b.line == a.line + 1 }
80
+ end
81
+
82
+ # Builds the replacement lines: a `:ruby` filter marker at the group's
83
+ # indentation, followed by each script's body indented one level deeper.
84
+ #
85
+ # @return [Array<String>]
86
+ def ruby_filter_lines(group)
87
+ indent = document.source_lines[group.first.line - 1][/\A\s*/]
88
+ inner = indent + (indent.include?("\t") ? "\t" : ' ')
89
+ ["#{indent}:ruby", *group.map { |node| "#{inner}#{node.script.strip}" }]
90
+ end
91
+
92
+ # Applies the queued range replacements from the bottom up (so earlier line
93
+ # indices stay valid) and returns the rebuilt source.
94
+ #
95
+ # @return [String]
96
+ def merged_source
97
+ lines = document.source_lines.dup
98
+ merges.sort_by { |merge| -merge[:start] }.each do |merge|
99
+ lines[merge[:start]..merge[:finish]] = merge[:lines]
100
+ end
101
+ lines.join("\n")
102
+ end
103
+
104
+ def merges
105
+ @merges ||= []
106
+ end
40
107
  end
41
108
  end
@@ -6,11 +6,80 @@ module HamlLint
6
6
  class Linter::HtmlAttributes < Linter
7
7
  include LinterRegistry
8
8
 
9
+ supports_autocorrect(true)
10
+ autocorrect_safe(false)
11
+
12
+ MESSAGE = "Prefer the hash attributes syntax (%tag{ lang: 'en' }) " \
13
+ 'over HTML attributes syntax (%tag(lang=en))'
14
+
9
15
  def visit_tag(node)
10
16
  return unless node.html_attributes?
11
17
 
12
- record_lint(node, "Prefer the hash attributes syntax (%tag{ lang: 'en' }) " \
13
- 'over HTML attributes syntax (%tag(lang=en))')
18
+ record_lint(node, MESSAGE, corrected: correct(node))
19
+ end
20
+
21
+ private
22
+
23
+ # Rewrites the HTML attribute group `(...)` as a Ruby hash group `{...}`,
24
+ # preserving each attribute's value (static strings, booleans, and Ruby
25
+ # expressions alike).
26
+ #
27
+ # The conversion is skipped (the lint is still reported) when it can't be
28
+ # performed losslessly: when the tag already has a `{...}` hash group, when
29
+ # the `.class`/`#id` shorthand is present (its `class`/`id` are merged into
30
+ # the parsed attributes and can't be told apart from the HTML ones), or when
31
+ # the attribute list spans multiple lines.
32
+ #
33
+ # @return [Boolean] whether a correction was applied
34
+ def correct(node)
35
+ return false unless correctable?(node)
36
+ return false unless pairs = hash_pairs(node)
37
+
38
+ hash_source = "{ #{pairs.join(', ')} }"
39
+ return false unless parse_ruby(hash_source)
40
+
41
+ index = node.line - 1
42
+ line = autocorrected_lines[index]
43
+ html_source = node.dynamic_attributes_source[:html]
44
+ return false unless line.include?(html_source)
45
+
46
+ correct_line(index, line.sub(html_source) { hash_source })
47
+ end
48
+
49
+ # Whether the tag's HTML attributes can be losslessly converted in place.
50
+ #
51
+ # @return [Boolean]
52
+ def correctable?(node)
53
+ return false if node.hash_attributes?
54
+ return false unless node.static_attributes_source.empty?
55
+
56
+ html_source = node.dynamic_attributes_source[:html]
57
+ !html_source.nil? && !html_source.include?("\n")
58
+ end
59
+
60
+ # Builds the `key => value` fragments for the tag's HTML attributes, or +nil+
61
+ # when one of the static values can't be safely serialized.
62
+ #
63
+ # @return [Array<String>, nil]
64
+ def hash_pairs(node)
65
+ pairs = node.static_attributes.map do |key, value|
66
+ return nil unless serializable?(value)
67
+
68
+ "#{key.inspect} => #{value.inspect}"
69
+ end
70
+
71
+ node.dynamic_attributes_sources.each do |literal|
72
+ inner = literal.strip.delete_prefix('{').delete_suffix('}').sub(/,\s*\z/, '').strip
73
+ pairs << inner unless inner.empty?
74
+ end
75
+
76
+ pairs
77
+ end
78
+
79
+ # @return [Boolean] whether the static attribute value round-trips through
80
+ # +#inspect+ as a Ruby literal
81
+ def serializable?(value)
82
+ value.is_a?(String) || [true, false, nil].include?(value) || value.is_a?(Numeric)
14
83
  end
15
84
  end
16
85
  end
@@ -282,7 +282,7 @@ module HamlLint
282
282
  end
283
283
  end
284
284
  record_lint(line, offense.message, offense.severity.name,
285
- corrected: autocorrected)
285
+ corrected: autocorrected, correctable: offense.correctable?)
286
286
  end
287
287
  end
288
288
 
@@ -291,13 +291,15 @@ module HamlLint
291
291
  # @param line [#line] line number of the lint
292
292
  # @param message [String] error/warning to display to the user
293
293
  # @param severity [Symbol] RuboCop severity level for the offense
294
- 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)
295
297
  # TODO: actual handling for RuboCop's new :info severity
296
298
  return if severity == :info
297
299
 
298
300
  @lints << HamlLint::Lint.new(self, @document.file, line, message,
299
301
  SEVERITY_MAP.fetch(severity, :warning),
300
- corrected: corrected)
302
+ corrected: corrected, correctable: correctable)
301
303
  end
302
304
 
303
305
  # rubocop:disable Style/MutableConstant
@@ -11,35 +11,99 @@ module HamlLint
11
11
  class Linter::UnnecessaryStringOutput < Linter
12
12
  include LinterRegistry
13
13
 
14
+ supports_autocorrect(true)
15
+ autocorrect_safe(false)
16
+
14
17
  MESSAGE = '`= "..."` should be rewritten as `...`'
15
18
 
16
19
  def visit_tag(node)
17
- if tag_has_inline_script?(node) && inline_content_is_string?(node)
18
- record_lint(node, MESSAGE)
19
- end
20
+ return unless tag_has_inline_script?(node) && inline_content_is_string?(node)
21
+
22
+ plain = safe_plain_text(node.script)
23
+ corrected = plain ? correct_tag(node, plain) : false
24
+ record_lint(node, MESSAGE, corrected: corrected)
20
25
  end
21
26
 
22
27
  def visit_script(node)
23
28
  # Some script nodes created by the Haml parser aren't actually script
24
29
  # nodes declared via the `=` marker. Check for it.
25
30
  return unless /\A\s*=/.match?(node.source_code)
31
+ return unless plain = safe_plain_text(node.script)
26
32
 
27
- if outputs_string_literal?(node)
28
- record_lint(node, MESSAGE)
29
- end
33
+ record_lint(node, MESSAGE, corrected: correct_script(node, plain))
30
34
  end
31
35
 
32
36
  private
33
37
 
34
- def outputs_string_literal?(script_node)
35
- return unless tree = parse_ruby(script_node.script)
38
+ # Rewrites `%tag= "..."` as `%tag ...`, dropping the `=` marker and the
39
+ # surrounding quotes.
40
+ #
41
+ # @return [Boolean] whether a correction was applied
42
+ def correct_tag(node, plain)
43
+ marker = node.inline_marker_source.rstrip
44
+ # Only unwrap plain escaped output (`= "..."`); leave `!=`, `~`, and the
45
+ # whitespace-removal markers (`<`/`>`) untouched, as they change rendering.
46
+ return false unless /\A=\s*["']/.match?(marker)
47
+
48
+ index = node.line - 1
49
+ line = autocorrected_lines[index]
50
+ return false unless line.rstrip.end_with?(marker)
51
+
52
+ prefix = line.rstrip[0...(line.rstrip.length - marker.length)]
53
+ correct_line(index, "#{prefix} #{plain}")
54
+ end
55
+
56
+ # Rewrites a standalone `= "..."` script as plain text, preserving the
57
+ # node's indentation.
58
+ #
59
+ # @return [Boolean] whether a correction was applied
60
+ def correct_script(node, plain)
61
+ index = node.line - 1
62
+ line = autocorrected_lines[index]
63
+ indentation = line[/\A\s*/]
64
+ correct_line(index, "#{indentation}#{plain}")
65
+ end
66
+
67
+ # Returns the Haml plain-text equivalent of a Ruby string literal script, or
68
+ # +nil+ when the script is not a string literal that can be safely unwrapped
69
+ # (i.e. unwrapping would change the rendered output).
70
+ #
71
+ # @return [String, nil]
72
+ def safe_plain_text(script)
73
+ return if script.include?("\n")
74
+ return unless tree = parse_ruby(script)
36
75
  return unless %i[str dstr].include?(tree.type)
76
+ return unless safely_unwrappable?(tree)
77
+
78
+ plain_text_from(tree)
79
+ rescue ::Parser::SyntaxError
80
+ # Gracefully ignore syntax errors, as that's managed by a different linter
81
+ nil
82
+ end
37
83
 
84
+ # Whether unwrapping the string literal into Haml plain text would preserve
85
+ # the rendered output.
86
+ #
87
+ # @return [Boolean]
88
+ def safely_unwrappable?(tree)
38
89
  !starts_with_reserved_character?(tree.children.first) &&
39
90
  !contains_escape_sequence?(tree) &&
40
91
  !contains_significant_whitespace?(tree)
41
- rescue ::Parser::SyntaxError
42
- # Gracefully ignore syntax errors, as that's managed by a different linter
92
+ end
93
+
94
+ # Reconstructs the Haml plain-text form of a string literal. Literal segments
95
+ # keep their text (with any `#{` escaped so Haml won't interpolate it), while
96
+ # interpolation segments are emitted verbatim in their `#{...}` form.
97
+ #
98
+ # @return [String]
99
+ def plain_text_from(tree)
100
+ string_segments(tree).map do |segment|
101
+ if segment.type == :str
102
+ segment.children.first.gsub('#{') { '\#{' }
103
+ else
104
+ segment.location.expression.source
105
+ end
106
+ end.join
43
107
  end
44
108
 
45
109
  # Returns whether a string starts with a character that would otherwise be
@@ -212,11 +212,14 @@ module HamlLint
212
212
  #
213
213
  # @param node_or_line [#line] line number or node to extract the line number from
214
214
  # @param message [String] error/warning to display to the user
215
- def record_lint(node_or_line, message, corrected: false)
215
+ # @param corrected [Boolean] whether the lint was fixed by auto-correct
216
+ # @param correctable [Boolean] whether the lint can be fixed by auto-correct;
217
+ # defaults to whether this linter supports auto-correct at all
218
+ def record_lint(node_or_line, message, corrected: false, correctable: supports_autocorrect?)
216
219
  line = node_or_line.is_a?(Integer) ? node_or_line : node_or_line.line
217
220
  @lints << HamlLint::Lint.new(self, @document.file, line, message,
218
221
  config.fetch('severity', :warning).to_sym,
219
- corrected: corrected)
222
+ corrected: corrected, correctable: correctable)
220
223
  end
221
224
 
222
225
  # Parse Ruby code into an abstract syntax tree.
@@ -47,6 +47,8 @@ module HamlLint
47
47
  {
48
48
  severity: offense.severity,
49
49
  message: offense.message,
50
+ corrected: offense.corrected,
51
+ correctable: offense.correctable,
50
52
  location: {
51
53
  line: offense.line,
52
54
  },
@@ -58,6 +58,8 @@ module HamlLint
58
58
  def print_message(lint)
59
59
  if lint.corrected
60
60
  log.success('[Corrected] ', false)
61
+ elsif lint.correctable
62
+ log.info('[Correctable] ', false)
61
63
  end
62
64
 
63
65
  if lint.linter
@@ -11,10 +11,11 @@ module HamlLint
11
11
  expected_message = options[:message]
12
12
  expected_severity = options[:severity]
13
13
  expected_corrected = options[:corrected]
14
+ expected_correctable = options[:correctable]
14
15
 
15
16
  match do |linter|
16
17
  has_lints?(linter, expected_line, count, expected_message, expected_severity,
17
- expected_corrected)
18
+ expected_corrected, expected_correctable)
18
19
  end
19
20
 
20
21
  failure_message do |linter|
@@ -66,14 +67,15 @@ module HamlLint
66
67
  end
67
68
 
68
69
  def has_lints?(linter, expected_line, count, expected_message, expected_severity, # rubocop:disable Metrics/ParameterLists,Naming
69
- expected_corrected)
70
+ expected_corrected, expected_correctable)
70
71
  if expected_line
71
72
  has_expected_line_lints?(linter,
72
73
  expected_line,
73
74
  count,
74
75
  expected_message,
75
76
  expected_severity,
76
- expected_corrected)
77
+ expected_corrected,
78
+ expected_correctable)
77
79
  elsif count
78
80
  linter.lints.count == count
79
81
  elsif expected_message
@@ -88,7 +90,8 @@ module HamlLint
88
90
  count,
89
91
  expected_message,
90
92
  expected_severity,
91
- expected_corrected)
93
+ expected_corrected,
94
+ expected_correctable)
92
95
  if count
93
96
  multiple_lints_match_line?(linter, expected_line, count)
94
97
  elsif expected_message
@@ -97,6 +100,8 @@ module HamlLint
97
100
  lint_on_line_matches_severity?(linter, expected_line, expected_severity)
98
101
  elsif !expected_corrected.nil?
99
102
  lint_on_line_matches_corrected?(linter, expected_line, expected_corrected)
103
+ elsif !expected_correctable.nil?
104
+ lint_on_line_matches_correctable?(linter, expected_line, expected_correctable)
100
105
  else
101
106
  lint_lines(linter).include?(expected_line)
102
107
  end
@@ -126,6 +131,12 @@ module HamlLint
126
131
  .any? { |lint| lint.line == expected_line && lint.corrected == expected_corrected }
127
132
  end
128
133
 
134
+ def lint_on_line_matches_correctable?(linter, expected_line, expected_correctable)
135
+ linter
136
+ .lints
137
+ .any? { |lint| lint.line == expected_line && lint.correctable == expected_correctable }
138
+ end
139
+
129
140
  def lint_messages_match?(linter, expected_message)
130
141
  # Using === to support regex to match anywhere in the string
131
142
  lint_messages(linter).all? { |message| expected_message === message } # rubocop:disable Style/CaseEquality
@@ -155,6 +155,18 @@ module HamlLint::Tree
155
155
  dynamic_attributes_source[:html][/\A\((.*)\)\z/, 1] if html_attributes?
156
156
  end
157
157
 
158
+ # The tag's static (non-Ruby) attributes parsed into a Ruby hash. Keys are
159
+ # strings and values are their literal parsed values (e.g. `true` for a
160
+ # boolean attribute such as `(required)`).
161
+ #
162
+ # @note When the tag uses the `.class`/`#id` shorthand, the resulting
163
+ # `class`/`id` entries are merged into this hash as well.
164
+ #
165
+ # @return [Hash]
166
+ def static_attributes
167
+ @value[:attributes]
168
+ end
169
+
158
170
  # ID of the HTML tag.
159
171
  #
160
172
  # @return [String]
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Defines the gem version.
4
4
  module HamlLint
5
- VERSION = '0.75.0'
5
+ VERSION = '0.76.0'
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: haml_lint
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.75.0
4
+ version: 0.76.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shane da Silva