haml_lint 0.45.0 → 0.48.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/bin/haml-lint +1 -1
  3. data/config/default.yml +6 -28
  4. data/config/forced_rubocop_config.yml +171 -0
  5. data/lib/haml_lint/adapter/haml_4.rb +18 -0
  6. data/lib/haml_lint/adapter/haml_5.rb +11 -0
  7. data/lib/haml_lint/adapter/haml_6.rb +11 -0
  8. data/lib/haml_lint/cli.rb +8 -3
  9. data/lib/haml_lint/configuration_loader.rb +9 -10
  10. data/lib/haml_lint/document.rb +89 -8
  11. data/lib/haml_lint/exceptions.rb +6 -0
  12. data/lib/haml_lint/extensions/haml_util_unescape_interpolation_tracking.rb +35 -0
  13. data/lib/haml_lint/file_finder.rb +2 -2
  14. data/lib/haml_lint/lint.rb +10 -1
  15. data/lib/haml_lint/linter/final_newline.rb +4 -3
  16. data/lib/haml_lint/linter/implicit_div.rb +1 -1
  17. data/lib/haml_lint/linter/indentation.rb +3 -3
  18. data/lib/haml_lint/linter/no_placeholders.rb +18 -0
  19. data/lib/haml_lint/linter/repeated_id.rb +2 -1
  20. data/lib/haml_lint/linter/rubocop.rb +353 -60
  21. data/lib/haml_lint/linter/space_before_script.rb +8 -10
  22. data/lib/haml_lint/linter/unnecessary_string_output.rb +1 -1
  23. data/lib/haml_lint/linter/view_length.rb +1 -1
  24. data/lib/haml_lint/linter.rb +60 -10
  25. data/lib/haml_lint/linter_registry.rb +3 -5
  26. data/lib/haml_lint/logger.rb +2 -2
  27. data/lib/haml_lint/options.rb +26 -2
  28. data/lib/haml_lint/rake_task.rb +2 -2
  29. data/lib/haml_lint/reporter/hash_reporter.rb +1 -3
  30. data/lib/haml_lint/reporter/offense_count_reporter.rb +1 -1
  31. data/lib/haml_lint/reporter/utils.rb +33 -4
  32. data/lib/haml_lint/ruby_extraction/ad_hoc_chunk.rb +24 -0
  33. data/lib/haml_lint/ruby_extraction/base_chunk.rb +114 -0
  34. data/lib/haml_lint/ruby_extraction/chunk_extractor.rb +630 -0
  35. data/lib/haml_lint/ruby_extraction/coordinator.rb +181 -0
  36. data/lib/haml_lint/ruby_extraction/haml_comment_chunk.rb +34 -0
  37. data/lib/haml_lint/ruby_extraction/implicit_end_chunk.rb +17 -0
  38. data/lib/haml_lint/ruby_extraction/interpolation_chunk.rb +26 -0
  39. data/lib/haml_lint/ruby_extraction/non_ruby_filter_chunk.rb +32 -0
  40. data/lib/haml_lint/ruby_extraction/placeholder_marker_chunk.rb +40 -0
  41. data/lib/haml_lint/ruby_extraction/ruby_filter_chunk.rb +33 -0
  42. data/lib/haml_lint/ruby_extraction/ruby_source.rb +5 -0
  43. data/lib/haml_lint/ruby_extraction/script_chunk.rb +244 -0
  44. data/lib/haml_lint/ruby_extraction/tag_attributes_chunk.rb +63 -0
  45. data/lib/haml_lint/ruby_extraction/tag_script_chunk.rb +30 -0
  46. data/lib/haml_lint/runner.rb +35 -3
  47. data/lib/haml_lint/spec/matchers/report_lint.rb +22 -7
  48. data/lib/haml_lint/spec/normalize_indent.rb +2 -2
  49. data/lib/haml_lint/spec/shared_linter_context.rb +9 -1
  50. data/lib/haml_lint/spec/shared_rubocop_autocorrect_context.rb +158 -0
  51. data/lib/haml_lint/spec.rb +1 -0
  52. data/lib/haml_lint/tree/filter_node.rb +10 -0
  53. data/lib/haml_lint/tree/node.rb +13 -4
  54. data/lib/haml_lint/tree/tag_node.rb +5 -9
  55. data/lib/haml_lint/utils.rb +135 -5
  56. data/lib/haml_lint/version.rb +1 -1
  57. data/lib/haml_lint/version_comparer.rb +25 -0
  58. data/lib/haml_lint.rb +12 -0
  59. metadata +24 -6
  60. data/lib/haml_lint/ruby_extractor.rb +0 -223
@@ -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,244 @@
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
+
111
+ cur_line_start_index = nil
112
+ line_start_indexes_that_need_pipes = []
113
+ haml_output_prefix = first_output_haml_prefix
114
+ to_haml_lines = to_ruby_lines.map.with_index do |line, i|
115
+ if line !~ /\S/
116
+ # whitespace or empty lines, we don't want any indentation
117
+ ''
118
+ elsif statement_start_line_indexes.include?(i)
119
+ cur_line_start_index = i
120
+ code_start = line.index(/\S/)
121
+ if line[code_start..].start_with?(script_output_ruby_prefix)
122
+ line = line.sub(script_output_ruby_prefix, '')
123
+ # The line may have been too indented because of the "HL.out = " prefix
124
+ continued_line_indent_delta = 2 - script_output_ruby_prefix.size
125
+ new_line = "#{line[0...code_start]}#{haml_output_prefix} #{line[code_start..]}"
126
+ haml_output_prefix = '='
127
+ new_line
128
+ else
129
+ continued_line_indent_delta = 2
130
+ "#{line[0...code_start]}- #{line[code_start..]}"
131
+ end
132
+ else
133
+ unless to_ruby_lines[i - 1].end_with?(',')
134
+ line_start_indexes_that_need_pipes << cur_line_start_index
135
+ end
136
+
137
+ HamlLint::Utils.indent(line, continued_line_indent_delta)
138
+ end
139
+ end
140
+
141
+ # Starting from the end because we need to add newlines when 2 groups of lines need pipes, so that they are
142
+ # separate.
143
+ line_start_indexes_that_need_pipes.reverse_each do |cur_line_i|
144
+ loop do
145
+ cur_line = to_haml_lines[cur_line_i]
146
+ break if cur_line.nil? || cur_line.empty?
147
+ to_haml_lines[cur_line_i] = cur_line + ' |'
148
+ cur_line_i += 1
149
+
150
+ break if statement_start_line_indexes.include?(cur_line_i)
151
+ end
152
+
153
+ next_line = to_haml_lines[cur_line_i]
154
+ if next_line && HamlLint::RubyExtraction::ChunkExtractor::HAML_PARSER_INSTANCE.send(:is_multiline?, next_line)
155
+ to_haml_lines.insert(cur_line_i, '')
156
+ end
157
+ end
158
+
159
+ to_haml_lines
160
+ end
161
+
162
+ def self.find_statement_start_line_indexes(to_ruby_lines) # rubocop:disable Metrics
163
+ if to_ruby_lines.size == 1
164
+ if to_ruby_lines.first[/\S/]
165
+ return [0]
166
+ else
167
+ return []
168
+ end
169
+ end
170
+ statement_start_line_indexes = [] # 0-indexed
171
+ allow_expression_after_line_number = 0 # 1-indexed
172
+ last_do_keyword_line_number = nil # 1-indexed, like Ripper.lex
173
+
174
+ to_ruby_string = to_ruby_lines.join("\n")
175
+ if RUBY_VERSION < '3.1'
176
+ # Ruby 2.6's Ripper has issues when it encounters a else, when, elsif without a matching if/case before.
177
+ # It literally stop lexing at that point without any error.
178
+ # Ex from 2.7.8:
179
+ # require 'ripper'
180
+ # Ripper.lex("a\nelse\nb")
181
+ # #=> [[[1, 0], :on_ident, "a", CMDARG], [[1, 1], :on_nl, "\n", BEG], [[2, 0], :on_kw, "else", BEG]]
182
+ # So we add enough ifs to last quite a few layer. Hopefully enough for all needs. To clarify, there would need
183
+ # as many "end" keyword in a single ScriptChunk followed by one of the problematic keyword for the problem
184
+ # to show up.
185
+ # Considering that a `end` without anything else on the line is removed from to_ruby_lines before getting here
186
+ # (in format_ruby_lines_to_haml_lines), 10 ifs should be plenty.
187
+ to_ruby_string = ('if a;' * 10) + to_ruby_string
188
+ end
189
+
190
+ last_line_number_seen = nil
191
+ Ripper.lex(to_ruby_string).each do |start_loc, token, str|
192
+ last_line_number_seen = start_loc[0]
193
+ if token == :on_nl
194
+ # :on_nl happens when we have a meaningful line change.
195
+ allow_expression_after_line_number = start_loc[0]
196
+ next
197
+ elsif token == :on_ignored_nl
198
+ # :on_ignored_nl happens for newlines within an expression, or consecutive newlines..
199
+ # and some cases we care about such as a newline after the pipes after arguments of a block
200
+ if last_do_keyword_line_number == start_loc[0]
201
+ # When starting a block, Ripper.lex gives :on_ignored_nl
202
+ allow_expression_after_line_number = start_loc[0]
203
+ end
204
+ next
205
+ end
206
+
207
+ if allow_expression_after_line_number && str[/\S/]
208
+ if allow_expression_after_line_number < start_loc[0]
209
+ # Ripper.lex returns line numbers 1-indexed, we want 0-indexed
210
+ statement_start_line_indexes << start_loc[0] - 1
211
+ end
212
+ allow_expression_after_line_number = nil
213
+ end
214
+
215
+ if token == :on_comment
216
+ # :on_comment contain its own newline at the end of the content
217
+ allow_expression_after_line_number = start_loc[0]
218
+ elsif token == :on_kw
219
+ if str == 'do'
220
+ # Because of the possible arguments for the block, we can't simply set is_between_expressions to true
221
+ last_do_keyword_line_number = start_loc[0]
222
+ elsif ALLOW_EXPRESSION_AFTER_LINE_ENDING_WITH.include?(str)
223
+ allow_expression_after_line_number = start_loc[0]
224
+ end
225
+ end
226
+ end
227
+
228
+ # number is 1-indexed, and we want the line after it, so that's great
229
+ if last_line_number_seen < to_ruby_lines.size && to_ruby_lines[last_line_number_seen..].any? { |l| l[/\S/] }
230
+ # There are non-empty lines after the last line Ripper showed us, that's a problem!
231
+ msg = +'It seems Ripper did not properly process some source code. Please make sure you are on the '
232
+ msg << 'latest Haml-Lint version, then create an issue at '
233
+ msg << "https://github.com/sds/haml-lint/issues and include the following information:\n"
234
+ msg << "Ruby version: #{RUBY_VERSION}\n"
235
+ msg << "Haml-Lint version: #{HamlLint::VERSION}\n"
236
+ msg << "HAML version: #{Haml::VERSION}\n"
237
+ msg << "problematic source code:\n```\n#{to_ruby_lines.join("\n")}\n```"
238
+ raise msg
239
+ end
240
+
241
+ statement_start_line_indexes
242
+ end
243
+ end
244
+ 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