haml_lint 0.40.0 → 0.51.0

Sign up to get free protection for your applications and to get access to all the features.
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