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
@@ -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
@@ -5,11 +5,26 @@ module HamlLint
5
5
  class Linter::EmptyObjectReference < Linter
6
6
  include LinterRegistry
7
7
 
8
+ supports_autocorrect(true)
9
+
8
10
  def visit_tag(node)
9
11
  return unless node.object_reference? &&
10
12
  node.object_reference_source.strip.empty?
11
13
 
12
- record_lint(node, 'Empty object reference should be removed')
14
+ corrected = correct_object_reference(node)
15
+ record_lint(node, 'Empty object reference should be removed', corrected: corrected)
16
+ end
17
+
18
+ private
19
+
20
+ # @return [Boolean]
21
+ def correct_object_reference(node)
22
+ index = node.line - 1
23
+ line = autocorrected_lines[index]
24
+ # `static_attributes_source` is just the `.class`/`#id` part (e.g. `.foo`),
25
+ # so the optional `%tag` group captures an explicit tag name when present.
26
+ static = Regexp.escape(node.static_attributes_source)
27
+ correct_line(index, line.sub(/\A(\s*(?:%[-:\w]+)?#{static})\[\s*\]/, '\1'))
13
28
  end
14
29
  end
15
30
  end
@@ -5,10 +5,40 @@ module HamlLint
5
5
  class Linter::EmptyScript < Linter
6
6
  include LinterRegistry
7
7
 
8
+ supports_autocorrect(true)
9
+ autocorrect_safe(false)
10
+
8
11
  def visit_silent_script(node)
9
- return unless node.script =~ /\A\s*\Z/
12
+ return unless /\A\s*\Z/.match?(node.script)
13
+
14
+ # Only a childless `-` can be deleted; a `-` with children is degenerate
15
+ # but is still reported.
16
+ deletable = node.children.empty?
17
+ deleted_lines << (node.line - 1) if autocorrect? && deletable
18
+ record_lint(node, 'Empty script should be removed',
19
+ corrected: autocorrect? && deletable)
20
+ end
21
+
22
+ def after_visit_root(node)
23
+ super
24
+ return if deleted_lines.empty?
25
+
26
+ kept = document.source_lines.reject.with_index do |_, index|
27
+ deleted_lines.include?(index)
28
+ end
29
+ apply_autocorrect(kept.join("\n"))
30
+ end
31
+
32
+ private
33
+
34
+ def reset_autocorrect_state
35
+ super
36
+ @deleted_lines = []
37
+ end
10
38
 
11
- record_lint(node, 'Empty script should be removed')
39
+ # @return [Array<Integer>]
40
+ def deleted_lines
41
+ @deleted_lines ||= []
12
42
  end
13
43
  end
14
44
  end
@@ -5,6 +5,12 @@ module HamlLint
5
5
  class Linter::FinalNewline < Linter
6
6
  include LinterRegistry
7
7
 
8
+ supports_autocorrect(true)
9
+
10
+ # Run last during autocorrect, so any line-level corrections from other
11
+ # linters are applied before the trailing newline is normalized.
12
+ autocorrect_priority(1)
13
+
8
14
  def visit_root(root)
9
15
  return if document.source.empty?
10
16
  line_number = document.last_non_empty_line
@@ -12,14 +18,32 @@ module HamlLint
12
18
  node = root.node_for_line(line_number)
13
19
  return if node.disabled?(self)
14
20
 
15
- ends_with_newline = document.source.end_with?("\n")
21
+ present = config['present'] ? true : false
22
+ corrected = corrected_source(present)
23
+ return if document.source == corrected
24
+
25
+ record_lint(line_number, message_for(present), corrected: autocorrect?)
26
+ apply_autocorrect(corrected)
27
+ end
28
+
29
+ private
30
+
31
+ def message_for(present)
32
+ if present
33
+ 'Files should end with a trailing newline'
34
+ else
35
+ 'Files should not end with a trailing newline'
36
+ end
37
+ end
16
38
 
17
- if config['present']
18
- unless ends_with_newline
19
- record_lint(line_number, 'Files should end with a trailing newline')
20
- end
21
- elsif ends_with_newline
22
- record_lint(line_number, 'Files should not end with a trailing newline')
39
+ # Normalizes only the single final newline. Collapsing multiple trailing
40
+ # newlines is `TrailingEmptyLines`' job, so a file that already ends with a
41
+ # newline is left untouched here under `present`.
42
+ def corrected_source(present)
43
+ if present
44
+ document.source.end_with?("\n") ? document.source : "#{document.source}\n"
45
+ else
46
+ document.source.sub(/\n+\z/, '')
23
47
  end
24
48
  end
25
49
  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
@@ -24,7 +24,7 @@ module HamlLint
24
24
 
25
25
  style = config['style'] || 'lisp_case'
26
26
  matcher = STYLES[style]
27
- record_lint(node, "`id` attribute must be in #{STYLIZED_NAMES[style]}") unless id =~ matcher
27
+ record_lint(node, "`id` attribute must be in #{STYLIZED_NAMES[style]}") unless id&.match?(matcher)
28
28
  end
29
29
  end
30
30
  end
@@ -6,6 +6,8 @@ module HamlLint
6
6
  class Linter::ImplicitDiv < Linter
7
7
  include LinterRegistry
8
8
 
9
+ supports_autocorrect(true)
10
+
9
11
  def visit_tag(node)
10
12
  return unless node.tag_name == 'div'
11
13
 
@@ -14,9 +16,20 @@ module HamlLint
14
16
  tag = node.source_code[/\s*([^\s={(\[]+)/, 1]
15
17
  return unless tag.start_with?('%div')
16
18
 
19
+ corrected = correct_implicit_div(node)
17
20
  record_lint(node,
18
21
  "`#{tag}` can be written as `#{node.static_attributes_source}` " \
19
- 'since `%div` is implicit')
22
+ 'since `%div` is implicit',
23
+ corrected: corrected)
24
+ end
25
+
26
+ private
27
+
28
+ # @return [Boolean]
29
+ def correct_implicit_div(node)
30
+ index = node.line - 1
31
+ line = autocorrected_lines[index]
32
+ correct_line(index, line.sub(/\A(\s*)%div(?=[.#])/, '\1'))
20
33
  end
21
34
  end
22
35
  end
@@ -11,7 +11,7 @@ module HamlLint
11
11
  tab: /^\t*(?! )/,
12
12
  }.freeze
13
13
 
14
- LEADING_SPACES_REGEX = /^( +)(?! )/.freeze
14
+ LEADING_SPACES_REGEX = /^( +)(?! )/
15
15
 
16
16
  def visit_root(root)
17
17
  character = config['character'].to_sym
@@ -30,7 +30,7 @@ module HamlLint
30
30
  dummy_node = Struct.new(:line)
31
31
 
32
32
  document.source_lines.each_with_index do |line, index|
33
- next if line =~ regex
33
+ next if line&.match?(regex)
34
34
 
35
35
  unless root.node_for_line(index).disabled?(self)
36
36
  record_lint dummy_node.new(index + 1), "Line contains #{wrong_characters} in indentation"
@@ -10,7 +10,7 @@ module HamlLint
10
10
  # @param [HamlLint::Tree::RootNode] the root of a syntax tree
11
11
  # @return [true, false] whether the linter is enabled for the tree
12
12
  def visit_root(node)
13
- @enabled = matcher.match(File.basename(node.file)) ? true : false
13
+ @enabled = matcher.match?(File.basename(node.file))
14
14
  end
15
15
 
16
16
  # Checks for instance variables in script nodes when the linter is enabled.
@@ -21,7 +21,7 @@ module HamlLint
21
21
  return unless enabled?
22
22
 
23
23
  if node.parsed_script.contains_instance_variables?
24
- record_lint(node, "Avoid using instance variables in #{file_types} views")
24
+ record_lint(node, failure_message)
25
25
  end
26
26
  end
27
27
 
@@ -41,10 +41,27 @@ module HamlLint
41
41
 
42
42
  visit_script(node) ||
43
43
  if node.parsed_attributes.contains_instance_variables?
44
- record_lint(node, "Avoid using instance variables in #{file_types} views")
44
+ record_lint(node, failure_message)
45
45
  end
46
46
  end
47
47
 
48
+ # Checks for instance variables in :ruby filters when the linter is enabled.
49
+ #
50
+ # @param [HamlLint::Tree::FilterNode]
51
+ # @return [void]
52
+ def visit_filter(node)
53
+ return unless enabled?
54
+ return unless node.filter_type == 'ruby'
55
+ return unless ast = parse_ruby(node.text)
56
+
57
+ ast.each_node do |i|
58
+ if i.type == :ivar
59
+ record_lint(node, failure_message)
60
+ break
61
+ end
62
+ end
63
+ end
64
+
48
65
  private
49
66
 
50
67
  # Tracks whether the linter is enabled for the file.
@@ -75,5 +92,13 @@ module HamlLint
75
92
  def matcher
76
93
  @matcher ||= Regexp.new(config['matchers'][file_types] || '\A_.*\.haml\z')
77
94
  end
95
+
96
+ # The error message when an ivar is found
97
+ #
98
+ # @api private
99
+ # @return [String]
100
+ def failure_message
101
+ "Avoid using instance variables in #{file_types} views"
102
+ end
78
103
  end
79
104
  end
@@ -5,12 +5,24 @@ module HamlLint
5
5
  class Linter::LeadingCommentSpace < Linter
6
6
  include LinterRegistry
7
7
 
8
+ supports_autocorrect(true)
9
+
8
10
  def visit_haml_comment(node)
9
11
  # Skip if the node spans multiple lines starting on the second line,
10
12
  # or starts with a space
11
- return if node.text =~ /\A#*(\s*|\s+\S.*)$/
13
+ return if /\A#*(\s*|\s+\S.*)$/.match?(node.text)
14
+
15
+ corrected = correct_leading_space(node)
16
+ record_lint(node, 'Comment should have a space after the `#`', corrected: corrected)
17
+ end
18
+
19
+ private
12
20
 
13
- record_lint(node, 'Comment should have a space after the `#`')
21
+ # @return [Boolean]
22
+ def correct_leading_space(node)
23
+ index = node.line - 1
24
+ line = autocorrected_lines[index]
25
+ correct_line(index, line.sub(/\A(\s*-#+)(?=\S)/, '\1 '))
14
26
  end
15
27
  end
16
28
  end
@@ -26,12 +26,12 @@ module HamlLint
26
26
  # Plain text nodes are allowed to consist of a single pipe
27
27
  return if line.strip == '|'
28
28
 
29
- record_lint(node, MESSAGE) if line.match(MULTILINE_PIPE_REGEX)
29
+ record_lint(node, MESSAGE) if line.match?(MULTILINE_PIPE_REGEX)
30
30
  end
31
31
 
32
32
  private
33
33
 
34
- MULTILINE_PIPE_REGEX = /\s+\|\s*$/.freeze
34
+ MULTILINE_PIPE_REGEX = /\s+\|\s*$/
35
35
 
36
36
  def line_text_for_node(node)
37
37
  document.source_lines[node.line - 1]
@@ -39,7 +39,7 @@ module HamlLint
39
39
 
40
40
  def check(node)
41
41
  line = line_text_for_node(node)
42
- record_lint(node, MESSAGE) if line.match(MULTILINE_PIPE_REGEX)
42
+ record_lint(node, MESSAGE) if line.match?(MULTILINE_PIPE_REGEX)
43
43
  end
44
44
  end
45
45
  end
@@ -5,6 +5,9 @@ module HamlLint
5
5
  class Linter::MultilineScript < Linter
6
6
  include LinterRegistry
7
7
 
8
+ supports_autocorrect(true)
9
+ autocorrect_safe(false)
10
+
8
11
  # List of operators that can split a script into two lines that we want to
9
12
  # alert on.
10
13
  SPLIT_OPERATORS = %w[
@@ -31,8 +34,40 @@ module HamlLint
31
34
  check(node)
32
35
  end
33
36
 
37
+ def after_visit_root(node)
38
+ super
39
+ return if merges.empty?
40
+
41
+ apply_autocorrect(merged_source)
42
+ end
43
+
34
44
  private
35
45
 
46
+ def reset_autocorrect_state
47
+ super
48
+ @merges = []
49
+ end
50
+
51
+ def merged_source
52
+ lines = document.source_lines.dup
53
+ to_delete = apply_merges(lines)
54
+ lines.reject.with_index { |_, index| to_delete.include?(index) }.join("\n")
55
+ end
56
+
57
+ # Mutates +lines+ in place, folding each continuation script onto its chain
58
+ # root, and returns the line indices that should be deleted.
59
+ #
60
+ # @return [Array<Integer>]
61
+ def apply_merges(lines)
62
+ redirect = {}
63
+ merges.sort_by { |merge| merge[:from] }.map do |merge|
64
+ root = redirect.fetch(merge[:from], merge[:from])
65
+ redirect[merge[:succ_line]] = root
66
+ lines[root] = "#{lines[root].rstrip} #{merge[:succ_script]}"
67
+ merge[:succ_line]
68
+ end
69
+ end
70
+
36
71
  def check(node)
37
72
  # Condition occurs when scripts do not contain nested content, e.g.
38
73
  #
@@ -48,11 +83,36 @@ module HamlLint
48
83
  return unless node.children.empty?
49
84
 
50
85
  operator = node.script[/\s+(\S+)\z/, 1]
51
- if SPLIT_OPERATORS.include?(operator)
52
- record_lint(node,
53
- "Script with trailing operator `#{operator}` should be " \
54
- 'merged with the script on the following line')
55
- end
86
+ return unless SPLIT_OPERATORS.include?(operator)
87
+
88
+ corrected = collect_merge(node)
89
+ record_lint(node,
90
+ "Script with trailing operator `#{operator}` should be " \
91
+ 'merged with the script on the following line',
92
+ corrected: corrected)
93
+ end
94
+
95
+ def collect_merge(node)
96
+ return false unless autocorrect?
97
+
98
+ # Only merge with the immediately following sibling on the next line.
99
+ # `node.successor` would climb to an ancestor's sibling when this script is
100
+ # the last child of its block, which would pull a line from outside the
101
+ # block into it and change the semantics.
102
+ succ = node.subsequents.first
103
+ return false unless succ && %i[script silent_script].include?(succ.type)
104
+ return false unless succ.line == node.line + 1
105
+
106
+ merges << {
107
+ from: node.line - 1,
108
+ succ_line: succ.line - 1,
109
+ succ_script: succ.script.strip,
110
+ }
111
+ true
112
+ end
113
+
114
+ def merges
115
+ @merges ||= []
56
116
  end
57
117
  end
58
118
  end
@@ -6,8 +6,8 @@ module HamlLint
6
6
  include LinterRegistry
7
7
 
8
8
  MSG = 'Placeholders attributes should not be used.'
9
- HASH_REGEXP = /:?['"]?placeholder['"]?(?::| *=>)/.freeze
10
- HTML_REGEXP = /placeholder=/.freeze
9
+ HASH_REGEXP = /:?['"]?placeholder['"]?(?::| *=>)/
10
+ HTML_REGEXP = /placeholder=/
11
11
 
12
12
  def visit_tag(node)
13
13
  return unless node.hash_attributes_source =~ HASH_REGEXP || node.html_attributes_source =~ HTML_REGEXP