haml_lint 0.40.0 → 0.51.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/bin/haml-lint +1 -1
  3. data/config/default.yml +9 -27
  4. data/config/forced_rubocop_config.yml +180 -0
  5. data/lib/haml_lint/adapter/haml_4.rb +20 -0
  6. data/lib/haml_lint/adapter/haml_5.rb +11 -0
  7. data/lib/haml_lint/adapter/haml_6.rb +59 -0
  8. data/lib/haml_lint/adapter.rb +2 -0
  9. data/lib/haml_lint/cli.rb +8 -3
  10. data/lib/haml_lint/configuration_loader.rb +49 -13
  11. data/lib/haml_lint/document.rb +89 -8
  12. data/lib/haml_lint/exceptions.rb +6 -0
  13. data/lib/haml_lint/extensions/haml_util_unescape_interpolation_tracking.rb +35 -0
  14. data/lib/haml_lint/file_finder.rb +2 -2
  15. data/lib/haml_lint/lint.rb +10 -1
  16. data/lib/haml_lint/linter/final_newline.rb +4 -3
  17. data/lib/haml_lint/linter/implicit_div.rb +1 -1
  18. data/lib/haml_lint/linter/indentation.rb +3 -3
  19. data/lib/haml_lint/linter/no_placeholders.rb +18 -0
  20. data/lib/haml_lint/linter/repeated_id.rb +2 -1
  21. data/lib/haml_lint/linter/rubocop.rb +353 -59
  22. data/lib/haml_lint/linter/space_before_script.rb +8 -10
  23. data/lib/haml_lint/linter/trailing_empty_lines.rb +22 -0
  24. data/lib/haml_lint/linter/unnecessary_string_output.rb +1 -1
  25. data/lib/haml_lint/linter/view_length.rb +1 -1
  26. data/lib/haml_lint/linter.rb +60 -10
  27. data/lib/haml_lint/linter_registry.rb +3 -5
  28. data/lib/haml_lint/logger.rb +2 -2
  29. data/lib/haml_lint/options.rb +26 -2
  30. data/lib/haml_lint/rake_task.rb +2 -2
  31. data/lib/haml_lint/reporter/hash_reporter.rb +1 -3
  32. data/lib/haml_lint/reporter/offense_count_reporter.rb +1 -1
  33. data/lib/haml_lint/reporter/utils.rb +33 -4
  34. data/lib/haml_lint/ruby_extraction/ad_hoc_chunk.rb +24 -0
  35. data/lib/haml_lint/ruby_extraction/base_chunk.rb +114 -0
  36. data/lib/haml_lint/ruby_extraction/chunk_extractor.rb +672 -0
  37. data/lib/haml_lint/ruby_extraction/coordinator.rb +181 -0
  38. data/lib/haml_lint/ruby_extraction/haml_comment_chunk.rb +34 -0
  39. data/lib/haml_lint/ruby_extraction/implicit_end_chunk.rb +17 -0
  40. data/lib/haml_lint/ruby_extraction/interpolation_chunk.rb +26 -0
  41. data/lib/haml_lint/ruby_extraction/non_ruby_filter_chunk.rb +32 -0
  42. data/lib/haml_lint/ruby_extraction/placeholder_marker_chunk.rb +40 -0
  43. data/lib/haml_lint/ruby_extraction/ruby_filter_chunk.rb +33 -0
  44. data/lib/haml_lint/ruby_extraction/ruby_source.rb +5 -0
  45. data/lib/haml_lint/ruby_extraction/script_chunk.rb +251 -0
  46. data/lib/haml_lint/ruby_extraction/tag_attributes_chunk.rb +63 -0
  47. data/lib/haml_lint/ruby_extraction/tag_script_chunk.rb +30 -0
  48. data/lib/haml_lint/ruby_parser.rb +11 -1
  49. data/lib/haml_lint/runner.rb +35 -3
  50. data/lib/haml_lint/spec/matchers/report_lint.rb +22 -7
  51. data/lib/haml_lint/spec/normalize_indent.rb +2 -2
  52. data/lib/haml_lint/spec/shared_linter_context.rb +9 -1
  53. data/lib/haml_lint/spec/shared_rubocop_autocorrect_context.rb +158 -0
  54. data/lib/haml_lint/spec.rb +1 -0
  55. data/lib/haml_lint/tree/filter_node.rb +10 -0
  56. data/lib/haml_lint/tree/node.rb +13 -4
  57. data/lib/haml_lint/tree/script_node.rb +7 -1
  58. data/lib/haml_lint/tree/silent_script_node.rb +16 -1
  59. data/lib/haml_lint/tree/tag_node.rb +5 -9
  60. data/lib/haml_lint/utils.rb +135 -5
  61. data/lib/haml_lint/version.rb +1 -1
  62. data/lib/haml_lint/version_comparer.rb +25 -0
  63. data/lib/haml_lint.rb +12 -0
  64. metadata +29 -15
  65. data/lib/haml_lint/ruby_extractor.rb +0 -222
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HamlLint::RubyExtraction
4
+ class UnableToTransferCorrections < StandardError; end
5
+
6
+ # Coordinates the entire RubyExtraction system.
7
+ #
8
+ # * Uses the extractor to generate chunks.
9
+ # * Preprocess the chunks to cleanup/fuse some of them.
10
+ # * Generates the extracted ruby code from the Chunks.
11
+ # * Handles the markers (see below)
12
+ # * Use the chunks to transfer corrections from corrected Ruby code back to HAML
13
+ #
14
+ # The generated Ruby code uses markers to wrap around the Ruby code from the chunks.
15
+ # Those markers look like function calls, like: `haml_lint_marker_1`, so are valid ruby.
16
+ # After RuboCop does it's auto-correction, the markers are used to find the pieces of the
17
+ # corrected Ruby code that correspond to each Chunk.
18
+ class Coordinator
19
+ # @return [String] The prefix used for to handle `= foo` script's in the extracted Ruby code.
20
+ attr_reader :script_output_prefix
21
+
22
+ # @return [String] The prefix used for markers in the Ruby code
23
+ attr_reader :marker_prefix
24
+
25
+ # @return [Array<String>] The ruby lines after extraction from HAML (before RuboCop)
26
+ attr_reader :assembled_ruby_lines
27
+
28
+ # @return [Array<String>] The ruby lines after correction by RuboCop
29
+ attr_reader :corrected_ruby_lines
30
+
31
+ def initialize(document)
32
+ @document = document
33
+ @ruby_chunks = nil
34
+ @assembled_ruby_lines = nil
35
+ @corrected_ruby_lines = nil
36
+ @source_map = {}
37
+ @script_output_prefix = nil
38
+
39
+ @haml_lines = nil
40
+ end
41
+
42
+ def extract_ruby_source
43
+ return @ruby_source if @ruby_source
44
+
45
+ pick_a_marker_prefix
46
+ pick_a_script_output_prefix
47
+
48
+ @ruby_chunks = HamlLint::RubyExtraction::ChunkExtractor.new(@document,
49
+ script_output_prefix: @script_output_prefix).extract
50
+ preprocess_chunks
51
+
52
+ @assembled_ruby_lines = []
53
+ @ruby_chunks.each do |ruby_chunk|
54
+ ruby_chunk.full_assemble(self)
55
+ end
56
+
57
+ # Making sure the generated source has a final newline
58
+ @assembled_ruby_lines << '' if @assembled_ruby_lines.last && !@assembled_ruby_lines.last.empty?
59
+
60
+ @ruby_source = RubySource.new(@assembled_ruby_lines.join("\n"), @source_map, @ruby_chunks)
61
+ end
62
+
63
+ def preprocess_chunks
64
+ return if @ruby_chunks.size < 2
65
+
66
+ new_chunks = [@ruby_chunks.first]
67
+ @ruby_chunks[1..].each do |ruby_chunk|
68
+ fused_chunk = new_chunks.last.fuse(ruby_chunk)
69
+ if fused_chunk
70
+ new_chunks[-1] = fused_chunk
71
+ else
72
+ new_chunks << ruby_chunk
73
+ end
74
+ end
75
+ @ruby_chunks = new_chunks
76
+ end
77
+
78
+ def haml_lines_with_corrections_applied(corrected_ruby_source)
79
+ @corrected_ruby_lines = corrected_ruby_source.split("\n")
80
+
81
+ @haml_lines = @document.source_lines.dup
82
+
83
+ if markers_conflict?(@assembled_ruby_lines, @corrected_ruby_lines)
84
+ raise UnableToTransferCorrections, 'The changes in the corrected ruby are not supported'
85
+ end
86
+
87
+ finished_with_empty_line = @haml_lines.last.empty?
88
+
89
+ # Going in reverse order, so that if we change the number of lines then the
90
+ # rest of the file will not be offset, which would make things harder
91
+ @ruby_chunks.reverse_each do |ruby_chunk|
92
+ ruby_chunk.transfer_correction(self, @corrected_ruby_lines, @haml_lines)
93
+ end
94
+
95
+ if finished_with_empty_line && !@haml_lines.last.empty?
96
+ @haml_lines << ''
97
+ end
98
+ @haml_lines
99
+ end
100
+
101
+ def add_lines(lines, haml_line_index:, skip_indexes_in_source_map: [])
102
+ nb_skipped_source_map_lines = 0
103
+ lines.size.times do |i|
104
+ if skip_indexes_in_source_map.include?(i)
105
+ nb_skipped_source_map_lines += 1
106
+ end
107
+
108
+ line_number = haml_line_index + 1
109
+ # If we skip the first line, we want to them to have the number of the following line
110
+ line_number = [line_number, line_number + i - nb_skipped_source_map_lines].max
111
+ @source_map[@assembled_ruby_lines.size + i + 1] = line_number
112
+ end
113
+ @assembled_ruby_lines.concat(lines)
114
+ end
115
+
116
+ def line_count
117
+ @assembled_ruby_lines.size
118
+ end
119
+
120
+ def add_marker(indent, haml_line_index:, name: 'marker')
121
+ add_lines(["#{' ' * indent}#{marker_prefix}_#{name}_#{@assembled_ruby_lines.size + 1}"],
122
+ haml_line_index: haml_line_index)
123
+ line_count
124
+ end
125
+
126
+ # If the ruby_lines have different markers in them, or are in a different order,
127
+ # then RuboCop did not alter them in a way that is compatible with this system.
128
+ def markers_conflict?(from_ruby_lines, to_ruby_lines)
129
+ from_markers = from_ruby_lines.grep(/#{marker_prefix}/, &:strip)
130
+ to_markers = to_ruby_lines.grep(/#{marker_prefix}/, &:strip)
131
+ from_markers != to_markers
132
+ end
133
+
134
+ def find_line_index_of_marker_in_corrections(line, name: 'marker')
135
+ marker = "#{marker_prefix}_#{name}_#{line}"
136
+
137
+ # In the best cases, the line didn't move
138
+ # Using end_with? because indentation may have been added
139
+ return line - 1 if @corrected_ruby_lines[line - 1]&.end_with?(marker)
140
+
141
+ @corrected_ruby_lines.index { |l| l.end_with?(marker) }
142
+ end
143
+
144
+ def extract_from_corrected_lines(start_marker_line_number, nb_lines)
145
+ cur_start_marker_index = find_line_index_of_marker_in_corrections(start_marker_line_number)
146
+ return if cur_start_marker_index.nil?
147
+
148
+ end_marker_line_number = start_marker_line_number + nb_lines + 1
149
+ cur_end_marker_index = find_line_index_of_marker_in_corrections(end_marker_line_number)
150
+ return if cur_end_marker_index.nil?
151
+
152
+ @corrected_ruby_lines[(cur_start_marker_index + 1)..(cur_end_marker_index - 1)]
153
+ end
154
+
155
+ def pick_a_marker_prefix
156
+ if @document.source.match?(/\bhaml_lint_/)
157
+ 100.times do
158
+ suffix = SecureRandom.hex(10)
159
+ next if @document.source.include?(suffix)
160
+ @marker_prefix = "haml_lint#{suffix}"
161
+ return
162
+ end
163
+ else
164
+ @marker_prefix = 'haml_lint'
165
+ end
166
+ end
167
+
168
+ def pick_a_script_output_prefix
169
+ if @document.source.match?(/\bHL\.out\b/)
170
+ 100.times do
171
+ suffix = SecureRandom.hex(10)
172
+ next if @document.source.include?(suffix)
173
+ @script_output_prefix = "HL.out#{suffix} = "
174
+ return
175
+ end
176
+ else
177
+ @script_output_prefix = 'HL.out = '
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HamlLint::RubyExtraction
4
+ # Chunk for haml comments. Lines like ` -# Some commenting!`.
5
+ # Only deals with indentation while correcting, but can also be fused to a ScriptChunk.
6
+ class HamlCommentChunk < BaseChunk
7
+ def fuse(following_chunk)
8
+ return unless following_chunk.is_a?(HamlCommentChunk)
9
+
10
+ # We only merge consecutive comments
11
+ # The main reason to want to at least merge those is
12
+ # so that an empty comment doesn't get removed by rubocop by mistake
13
+ return if @haml_line_index + 1 != following_chunk.haml_line_index
14
+
15
+ HamlCommentChunk.new(node, @ruby_lines + following_chunk.ruby_lines, end_marker_indent: end_marker_indent)
16
+ end
17
+
18
+ def transfer_correction_logic(_coordinator, to_ruby_lines, haml_lines)
19
+ if to_ruby_lines.empty?
20
+ haml_lines.slice!(@haml_line_index..haml_end_line_index)
21
+ return
22
+ end
23
+ delta_indent = min_indent_of(to_ruby_lines) - min_indent_of(@ruby_lines)
24
+
25
+ HamlLint::Utils.map_subset!(haml_lines, @haml_line_index..haml_end_line_index) do |l|
26
+ HamlLint::Utils.indent(l, delta_indent)
27
+ end
28
+ end
29
+
30
+ def min_indent_of(lines)
31
+ lines.map { |l| l.index(/\S/) }.compact.min
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HamlLint::RubyExtraction
4
+ # HAML adds a `end` when code gets outdented. We need to add that to the Ruby too, this
5
+ # is the chunk for it.
6
+ # However:
7
+ # * we can't apply fixes to it, so there are no markers
8
+ # * this is a distinct class so that a ScriptChunk can fuse this ImplicitEnd into itself,
9
+ # So that we can generate bigger chunks of uninterrupted Ruby.
10
+ class ImplicitEndChunk < BaseChunk
11
+ def wrap_in_markers
12
+ false
13
+ end
14
+
15
+ def transfer_correction(coordinator, all_corrected_ruby_lines, haml_lines); end
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HamlLint::RubyExtraction
4
+ # Deals with interpolation within a plain text, filter, etc.
5
+ # Can only handling single line interpolation, so will be skipped if it takes
6
+ # more than one line or if the correction takes more than one line.
7
+ #
8
+ # Stores the char index to know where in the line to do the replacements.
9
+ class InterpolationChunk < BaseChunk
10
+ def initialize(*args, start_char_index:, **kwargs)
11
+ super(*args, **kwargs)
12
+ @start_char_index = start_char_index
13
+ end
14
+
15
+ def transfer_correction_logic(coordinator, to_ruby_lines, haml_lines)
16
+ return if @ruby_lines.size != 1
17
+ return if to_ruby_lines.size != 1
18
+
19
+ from_ruby_line = @ruby_lines.first.partition(coordinator.script_output_prefix).last
20
+ to_ruby_line = to_ruby_lines.first.partition(coordinator.script_output_prefix).last
21
+
22
+ haml_line = haml_lines[@haml_line_index]
23
+ haml_line[@start_char_index...(@start_char_index + from_ruby_line.size)] = to_ruby_line
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HamlLint::RubyExtraction
4
+ # Chunk for dealing with every HAML filter other than `:ruby`
5
+ # The generated Ruby for these is just a HEREDOC, so interpolation is corrected at
6
+ # the same time by RuboCop.
7
+ class NonRubyFilterChunk < BaseChunk
8
+ def transfer_correction_logic(_coordinator, to_ruby_lines, haml_lines)
9
+ delta_indent = to_ruby_lines.first.index(/\S/) - @ruby_lines.first.index(/\S/)
10
+
11
+ haml_lines[@haml_line_index] = HamlLint::Utils.indent(haml_lines[@haml_line_index], delta_indent)
12
+
13
+ # Ignoring the starting <<~HAML_LINT_FILTER and ending end
14
+ to_content_lines = to_ruby_lines[1...-1]
15
+
16
+ to_haml_lines = to_content_lines.map do |line|
17
+ if line !~ /\S/
18
+ # whitespace or empty
19
+ ''
20
+ else
21
+ line
22
+ end
23
+ end
24
+
25
+ haml_lines[(@haml_line_index + 1)..haml_end_line_index] = to_haml_lines
26
+ end
27
+
28
+ def skip_line_indexes_in_source_map
29
+ [@ruby_lines.size - 1]
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HamlLint::RubyExtraction
4
+ # This chunk just adds a marker (with a custom name) to the generated Ruby and only attempts to
5
+ # transfer the corrections it receives to the indentation of the associated lines.
6
+ #
7
+ # Also used so that Rubocop doesn't think that there is nothing in `if` and other such structures,
8
+ # so that it does corrections that make sense for the HAML.
9
+ class PlaceholderMarkerChunk < BaseChunk
10
+ def initialize(node, marker_name, indent:, nb_lines: 1, **kwargs)
11
+ @marker_name = marker_name
12
+ @indent = indent
13
+ @nb_lines = nb_lines
14
+ super(node, nil, **kwargs.merge(end_marker_indent: @indent))
15
+ end
16
+
17
+ def full_assemble(coordinator)
18
+ @start_marker_line_number = coordinator.add_marker(@indent, name: @marker_name,
19
+ haml_line_index: haml_line_index)
20
+ end
21
+
22
+ def transfer_correction(coordinator, all_corrected_ruby_lines, haml_lines)
23
+ marker_index = coordinator.find_line_index_of_marker_in_corrections(@start_marker_line_number,
24
+ name: @marker_name)
25
+ new_indent = all_corrected_ruby_lines[marker_index].index(/\S/)
26
+ return if new_indent == @indent
27
+ (haml_line_index..haml_end_line_index).each do |i|
28
+ haml_lines[i] = HamlLint::Utils.indent(haml_lines[i], new_indent - @indent)
29
+ end
30
+ end
31
+
32
+ def haml_end_line_index
33
+ haml_line_index + @nb_lines - 1
34
+ end
35
+
36
+ def end_marker_indent
37
+ @indent
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HamlLint::RubyExtraction
4
+ # Chunk for dealing with `:ruby` filter.
5
+ class RubyFilterChunk < BaseChunk
6
+ attr_reader :start_marker_indent
7
+
8
+ def initialize(*args, start_marker_indent:, **kwargs)
9
+ super(*args, **kwargs)
10
+ @start_marker_indent = start_marker_indent
11
+ end
12
+
13
+ def transfer_correction_logic(coordinator, to_ruby_lines, haml_lines)
14
+ marker_index = coordinator.find_line_index_of_marker_in_corrections(@start_marker_line_number)
15
+
16
+ new_name_indent = coordinator.corrected_ruby_lines[marker_index].index(/\S/)
17
+
18
+ delta_indent = new_name_indent - @start_marker_indent
19
+ haml_lines[@haml_line_index - 1] = HamlLint::Utils.indent(haml_lines[@haml_line_index - 1], delta_indent)
20
+
21
+ to_haml_lines = to_ruby_lines.map do |line|
22
+ if line !~ /\S/
23
+ # whitespace or empty
24
+ ''
25
+ else
26
+ " #{line}"
27
+ end
28
+ end
29
+
30
+ haml_lines[@haml_line_index..haml_end_line_index] = to_haml_lines
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HamlLint::RubyExtraction
4
+ RubySource = Struct.new(:source, :source_map, :ruby_chunks)
5
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ripper'
4
+
5
+ module HamlLint::RubyExtraction
6
+ # Chunk for handling outputting and silent scripts, so ` = foo` and ` - bar`
7
+ # Does NOT handle a script beside a tag (ex: `%div= spam`)
8
+ class ScriptChunk < BaseChunk
9
+ MID_BLOCK_KEYWORDS = %w[else elsif when rescue ensure].freeze
10
+
11
+ # @return [String] The prefix for the first outputting string of this script. (One of = != &=)
12
+ # The outputting scripts after the first are always with =
13
+ attr_reader :first_output_haml_prefix
14
+
15
+ # @return [Boolean] true if this ScriptChunk must be at the beginning of a chunk.
16
+ # This blocks this ScriptChunk from being fused to a ScriptChunk that is before it.
17
+ # Needed to handle some patterns of outputting script.
18
+ attr_reader :must_start_chunk
19
+
20
+ # @return [Array<Integer>] Line indexes to ignore when building the source_map. For examples,
21
+ # implicit `end` are on their own line in the Ruby file, but in the HAML, they are absent.
22
+ attr_reader :skip_line_indexes_in_source_map
23
+
24
+ # @return [HamlLint::RubyExtraction::BaseChunk] The previous chunk can affect how
25
+ # our starting marker must be indented.
26
+ attr_reader :previous_chunk
27
+
28
+ def initialize(*args, previous_chunk:, must_start_chunk: false, # rubocop:disable Metrics/ParameterLists
29
+ skip_line_indexes_in_source_map: [], first_output_haml_prefix: '=', **kwargs)
30
+ super(*args, **kwargs)
31
+ @must_start_chunk = must_start_chunk
32
+ @skip_line_indexes_in_source_map = skip_line_indexes_in_source_map
33
+ @previous_chunk = previous_chunk
34
+ @first_output_haml_prefix = first_output_haml_prefix
35
+ end
36
+
37
+ def fuse(following_chunk)
38
+ case following_chunk
39
+ when ScriptChunk
40
+ fuse_script_chunk(following_chunk)
41
+ when ImplicitEndChunk
42
+ fuse_implicit_end(following_chunk)
43
+ end
44
+ end
45
+
46
+ def fuse_script_chunk(following_chunk)
47
+ return if following_chunk.end_marker_indent.nil?
48
+ return if following_chunk.must_start_chunk
49
+
50
+ nb_blank_lines_between = following_chunk.haml_line_index - haml_line_index - nb_haml_lines
51
+ blank_lines = nb_blank_lines_between > 0 ? [''] * nb_blank_lines_between : []
52
+ new_lines = @ruby_lines + blank_lines + following_chunk.ruby_lines
53
+
54
+ source_map_skips = @skip_line_indexes_in_source_map
55
+ source_map_skips.concat(following_chunk.skip_line_indexes_in_source_map
56
+ .map { |i| i + @ruby_lines.size })
57
+
58
+ ScriptChunk.new(node,
59
+ new_lines,
60
+ haml_line_index: haml_line_index,
61
+ skip_line_indexes_in_source_map: source_map_skips,
62
+ end_marker_indent: following_chunk.end_marker_indent,
63
+ previous_chunk: previous_chunk,
64
+ first_output_haml_prefix: @first_output_haml_prefix)
65
+ end
66
+
67
+ def fuse_implicit_end(following_chunk)
68
+ new_lines = @ruby_lines.dup
69
+ last_non_empty_line_index = new_lines.rindex { |line| line =~ /\S/ }
70
+
71
+ # There is only one line in ImplicitEndChunk
72
+ new_end_index = last_non_empty_line_index + 1
73
+ new_lines.insert(new_end_index, following_chunk.ruby_lines.first)
74
+ source_map_skips = @skip_line_indexes_in_source_map + [new_end_index]
75
+
76
+ ScriptChunk.new(node,
77
+ new_lines,
78
+ haml_line_index: haml_line_index,
79
+ skip_line_indexes_in_source_map: source_map_skips,
80
+ end_marker_indent: following_chunk.end_marker_indent,
81
+ previous_chunk: previous_chunk,
82
+ first_output_haml_prefix: @first_output_haml_prefix)
83
+ end
84
+
85
+ def start_marker_indent
86
+ default_indent = super
87
+ default_indent += 2 if MID_BLOCK_KEYWORDS.include?(ChunkExtractor.block_keyword(ruby_lines.first))
88
+ [default_indent, previous_chunk&.end_marker_indent || previous_chunk&.start_marker_indent].compact.max
89
+ end
90
+
91
+ def transfer_correction_logic(coordinator, to_ruby_lines, haml_lines)
92
+ to_haml_lines = self.class.format_ruby_lines_to_haml_lines(
93
+ to_ruby_lines,
94
+ script_output_ruby_prefix: coordinator.script_output_prefix,
95
+ first_output_haml_prefix: @first_output_haml_prefix
96
+ )
97
+
98
+ haml_lines[@haml_line_index..haml_end_line_index] = to_haml_lines
99
+ end
100
+
101
+ ALLOW_EXPRESSION_AFTER_LINE_ENDING_WITH = %w[else begin ensure].freeze
102
+
103
+ def self.format_ruby_lines_to_haml_lines(to_ruby_lines, script_output_ruby_prefix:, first_output_haml_prefix: '=') # rubocop:disable Metrics
104
+ to_ruby_lines.reject! { |l| l.strip == 'end' }
105
+ return [] if to_ruby_lines.empty?
106
+
107
+ statement_start_line_indexes = find_statement_start_line_indexes(to_ruby_lines)
108
+
109
+ continued_line_indent_delta = 2
110
+ continued_line_min_indent = 2
111
+
112
+ cur_line_start_index = nil
113
+ line_start_indexes_that_need_pipes = []
114
+ haml_output_prefix = first_output_haml_prefix
115
+ to_haml_lines = to_ruby_lines.map.with_index do |line, i| # rubocop:disable Metrics/BlockLength
116
+ if line !~ /\S/
117
+ # whitespace or empty lines, we don't want any indentation
118
+ ''
119
+ elsif statement_start_line_indexes.include?(i)
120
+ cur_line_start_index = i
121
+ code_start = line.index(/\S/)
122
+ continued_line_min_indent = code_start + 2
123
+ if line[code_start..].start_with?(script_output_ruby_prefix)
124
+ line = line.sub(script_output_ruby_prefix, '')
125
+ # The next lines may have been too indented because of the "HL.out = " prefix
126
+ continued_line_indent_delta = 2 - script_output_ruby_prefix.size
127
+ new_line = "#{line[0...code_start]}#{haml_output_prefix} #{line[code_start..]}"
128
+ haml_output_prefix = '='
129
+ new_line
130
+ else
131
+ continued_line_indent_delta = 2
132
+ "#{line[0...code_start]}- #{line[code_start..]}"
133
+ end
134
+ else
135
+ unless to_ruby_lines[i - 1].end_with?(',')
136
+ line_start_indexes_that_need_pipes << cur_line_start_index
137
+ end
138
+
139
+ line = HamlLint::Utils.indent(line, continued_line_indent_delta)
140
+ cur_indent = line[/^ */].size
141
+ if cur_indent < continued_line_min_indent
142
+ line = HamlLint::Utils.indent(line, continued_line_min_indent - cur_indent)
143
+ end
144
+ line
145
+ end
146
+ end
147
+
148
+ # Starting from the end because we need to add newlines when 2 groups of lines need pipes, so that they are
149
+ # separate.
150
+ line_start_indexes_that_need_pipes.reverse_each do |cur_line_i|
151
+ loop do
152
+ cur_line = to_haml_lines[cur_line_i]
153
+ break if cur_line.nil? || cur_line.empty?
154
+ to_haml_lines[cur_line_i] = cur_line + ' |'
155
+ cur_line_i += 1
156
+
157
+ break if statement_start_line_indexes.include?(cur_line_i)
158
+ end
159
+
160
+ next_line = to_haml_lines[cur_line_i]
161
+ if next_line && HamlLint::RubyExtraction::ChunkExtractor::HAML_PARSER_INSTANCE.send(:is_multiline?, next_line)
162
+ to_haml_lines.insert(cur_line_i, '')
163
+ end
164
+ end
165
+
166
+ to_haml_lines
167
+ end
168
+
169
+ def self.find_statement_start_line_indexes(to_ruby_lines) # rubocop:disable Metrics
170
+ if to_ruby_lines.size == 1
171
+ if to_ruby_lines.first[/\S/]
172
+ return [0]
173
+ else
174
+ return []
175
+ end
176
+ end
177
+ statement_start_line_indexes = [] # 0-indexed
178
+ allow_expression_after_line_number = 0 # 1-indexed
179
+ last_do_keyword_line_number = nil # 1-indexed, like Ripper.lex
180
+
181
+ to_ruby_string = to_ruby_lines.join("\n")
182
+ if RUBY_VERSION < '3.1'
183
+ # Ruby 2.6's Ripper has issues when it encounters a else, when, elsif without a matching if/case before.
184
+ # It literally stop lexing at that point without any error.
185
+ # Ex from 2.7.8:
186
+ # require 'ripper'
187
+ # Ripper.lex("a\nelse\nb")
188
+ # #=> [[[1, 0], :on_ident, "a", CMDARG], [[1, 1], :on_nl, "\n", BEG], [[2, 0], :on_kw, "else", BEG]]
189
+ # So we add enough ifs to last quite a few layer. Hopefully enough for all needs. To clarify, there would need
190
+ # as many "end" keyword in a single ScriptChunk followed by one of the problematic keyword for the problem
191
+ # to show up.
192
+ # Considering that a `end` without anything else on the line is removed from to_ruby_lines before getting here
193
+ # (in format_ruby_lines_to_haml_lines), 10 ifs should be plenty.
194
+ to_ruby_string = ('if a;' * 10) + to_ruby_string
195
+ end
196
+
197
+ last_line_number_seen = nil
198
+ Ripper.lex(to_ruby_string).each do |start_loc, token, str|
199
+ last_line_number_seen = start_loc[0]
200
+ if token == :on_nl
201
+ # :on_nl happens when we have a meaningful line change.
202
+ allow_expression_after_line_number = start_loc[0]
203
+ next
204
+ elsif token == :on_ignored_nl
205
+ # :on_ignored_nl happens for newlines within an expression, or consecutive newlines..
206
+ # and some cases we care about such as a newline after the pipes after arguments of a block
207
+ if last_do_keyword_line_number == start_loc[0]
208
+ # When starting a block, Ripper.lex gives :on_ignored_nl
209
+ allow_expression_after_line_number = start_loc[0]
210
+ end
211
+ next
212
+ end
213
+
214
+ if allow_expression_after_line_number && str[/\S/]
215
+ if allow_expression_after_line_number < start_loc[0]
216
+ # Ripper.lex returns line numbers 1-indexed, we want 0-indexed
217
+ statement_start_line_indexes << start_loc[0] - 1
218
+ end
219
+ allow_expression_after_line_number = nil
220
+ end
221
+
222
+ if token == :on_comment
223
+ # :on_comment contain its own newline at the end of the content
224
+ allow_expression_after_line_number = start_loc[0]
225
+ elsif token == :on_kw
226
+ if str == 'do'
227
+ # Because of the possible arguments for the block, we can't simply set is_between_expressions to true
228
+ last_do_keyword_line_number = start_loc[0]
229
+ elsif ALLOW_EXPRESSION_AFTER_LINE_ENDING_WITH.include?(str)
230
+ allow_expression_after_line_number = start_loc[0]
231
+ end
232
+ end
233
+ end
234
+
235
+ # number is 1-indexed, and we want the line after it, so that's great
236
+ if last_line_number_seen < to_ruby_lines.size && to_ruby_lines[last_line_number_seen..].any? { |l| l[/\S/] }
237
+ # There are non-empty lines after the last line Ripper showed us, that's a problem!
238
+ msg = +'It seems Ripper did not properly process some source code. Please make sure you are on the '
239
+ msg << 'latest Haml-Lint version, then create an issue at '
240
+ msg << "https://github.com/sds/haml-lint/issues and include the following information:\n"
241
+ msg << "Ruby version: #{RUBY_VERSION}\n"
242
+ msg << "Haml-Lint version: #{HamlLint::VERSION}\n"
243
+ msg << "HAML version: #{Haml::VERSION}\n"
244
+ msg << "problematic source code:\n```\n#{to_ruby_lines.join("\n")}\n```"
245
+ raise msg
246
+ end
247
+
248
+ statement_start_line_indexes
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HamlLint::RubyExtraction
4
+ # Chunk for handling the a tag attributes, such as `%div{style: 'yes_please'}`
5
+ class TagAttributesChunk < BaseChunk
6
+ def initialize(*args, indent_to_remove:, **kwargs)
7
+ super(*args, **kwargs)
8
+ @indent_to_remove = indent_to_remove
9
+ end
10
+
11
+ def transfer_correction_logic(_coordinator, to_ruby_lines, haml_lines) # rubocop:disable Metrics
12
+ return if @ruby_lines == to_ruby_lines
13
+
14
+ affected_haml_lines = haml_lines[@haml_line_index..haml_end_line_index]
15
+
16
+ affected_haml = affected_haml_lines.join("\n")
17
+
18
+ from_ruby = unwrap(@ruby_lines).join("\n")
19
+
20
+ if to_ruby_lines.size > 1
21
+ min_indent = to_ruby_lines.first[/^\s*/]
22
+ to_ruby_lines.each.with_index do |line, i|
23
+ next if i == 0
24
+ next if line.start_with?(min_indent)
25
+ to_ruby_lines[i] = "#{min_indent}#{line.lstrip}"
26
+ end
27
+ end
28
+
29
+ to_ruby = unwrap(to_ruby_lines).join("\n")
30
+
31
+ affected_start_index = affected_haml.index(from_ruby)
32
+ if affected_start_index
33
+ affected_end_index = affected_start_index + from_ruby.size
34
+ else
35
+ regexp = HamlLint::Utils.regexp_for_parts(from_ruby.split("\n"), "(?:\s*\\|?\n)")
36
+ mo = affected_haml.match(regexp)
37
+ affected_start_index = mo.begin(0)
38
+ affected_end_index = mo.end(0)
39
+ end
40
+
41
+ affected_haml[affected_start_index...affected_end_index] = to_ruby
42
+
43
+ haml_lines[@haml_line_index..haml_end_line_index] = affected_haml.split("\n")
44
+
45
+ if haml_lines[haml_end_line_index].end_with?(' |')
46
+ haml_lines[haml_end_line_index].chop!.rstrip!
47
+ end
48
+ end
49
+
50
+ def unwrap(lines)
51
+ lines = lines.dup
52
+ lines[0] = lines[0].sub(/^\s*/, '').sub(/W+\(/, '')
53
+ lines[-1] = lines[-1].sub(/\)\s*\Z/, '')
54
+
55
+ if @indent_to_remove
56
+ HamlLint::Utils.map_after_first!(lines) do |line|
57
+ line.sub(/^ {1,#{@indent_to_remove}}/, '')
58
+ end
59
+ end
60
+ lines
61
+ end
62
+ end
63
+ end