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,672 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics
4
+ module HamlLint::RubyExtraction
5
+ # Extracts "chunks" of the haml file into instances of subclasses of HamlLint::RubyExtraction::BaseChunk.
6
+ #
7
+ # This is the first step of generating Ruby code from a HAML file to then be processed by RuboCop.
8
+ # See HamlLint::RubyExtraction::BaseChunk for more details.
9
+ class ChunkExtractor
10
+ include HamlLint::HamlVisitor
11
+
12
+ attr_reader :script_output_prefix
13
+
14
+ HAML_PARSER_INSTANCE = if Haml::VERSION >= '5.0.0'
15
+ ::Haml::Parser.new({})
16
+ else
17
+ ::Haml::Parser.new('', {})
18
+ end
19
+
20
+ def initialize(document, script_output_prefix:)
21
+ @document = document
22
+ @script_output_prefix = script_output_prefix
23
+ end
24
+
25
+ def extract
26
+ raise 'Already extracted' if @ruby_chunks
27
+
28
+ prepare_extract
29
+
30
+ visit(@document.tree)
31
+ @ruby_chunks
32
+ end
33
+
34
+ # Useful for tests
35
+ def prepare_extract
36
+ @ruby_chunks = []
37
+ @original_haml_lines = @document.source_lines
38
+ end
39
+
40
+ def visit_root(_node)
41
+ yield # Collect lines of code from children
42
+ end
43
+
44
+ # Visiting lines like ` Some raw text to output`
45
+ def visit_plain(node)
46
+ indent = @original_haml_lines[node.line - 1].index(/\S/)
47
+ @ruby_chunks << PlaceholderMarkerChunk.new(node, 'plain', indent: indent)
48
+ end
49
+
50
+ # Visiting lines like ` -# Some commenting!`
51
+ def visit_haml_comment(node)
52
+ # We want to preserve leading whitespace if it exists, but add a leading
53
+ # whitespace if it doesn't exist so that RuboCop's LeadingCommentSpace
54
+ # doesn't complain
55
+ line_index = node.line - 1
56
+ lines = @original_haml_lines[line_index..(line_index + node.text.count("\n"))].dup
57
+ indent = lines.first.index(/\S/)
58
+ # Remove only the -, the # will align with regular code
59
+ # -# comment
60
+ # - foo()
61
+ # becomes
62
+ # # comment
63
+ # foo()
64
+ lines[0] = lines[0].sub('-', '')
65
+
66
+ # Adding a space before the comment if its missing
67
+ # We can't fix those, so make sure not to generate warnings for them.
68
+ lines[0] = lines[0].sub(/\A(\s*)#(\S)/, '\\1# \\2')
69
+
70
+ HamlLint::Utils.map_after_first!(lines) do |line|
71
+ # Since the indent/spaces of the extra line comments isn't exactly in the haml,
72
+ # it's not RuboCop's job to fix indentation, so just make a reasonable indentation
73
+ # to avoid offenses.
74
+ ' ' * indent + line.sub(/^\s*/, '# ').rstrip
75
+ end
76
+
77
+ # Using Placeholder instead of script because we can't revert back to the
78
+ # exact original comment since multiple syntax lead to the exact same comment.
79
+ @ruby_chunks << HamlCommentChunk.new(node, lines, end_marker_indent: indent)
80
+ end
81
+
82
+ # Visiting comments which are output to HTML. Lines looking like
83
+ # ` / This will be in the HTML source!`
84
+ def visit_comment(node)
85
+ line = @original_haml_lines[node.line - 1]
86
+ indent = line.index(/\S/)
87
+ @ruby_chunks << PlaceholderMarkerChunk.new(node, 'comment', indent: indent)
88
+ end
89
+
90
+ # Visit a script which outputs. Lines looking like ` = foo`
91
+ def visit_script(node, &block)
92
+ raw_first_line = @original_haml_lines[node.line - 1]
93
+
94
+ # ==, !, !==, &, &== means interpolation (was needed before HAML 2.2... it's still supported)
95
+ # =, !=, &= mean actual ruby code is coming
96
+ # Anything else is interpolation
97
+ # The regex lists the case for Ruby Code. The 3 cases and making sure they are not followed by another = sign
98
+
99
+ match = raw_first_line.match(/\A\s*(=|!=|&=)(?!=)/)
100
+ unless match
101
+ # The line doesn't start with a - or a =, this is actually a "plain"
102
+ # that contains interpolation.
103
+ indent = raw_first_line.index(/\S/)
104
+ @ruby_chunks << PlaceholderMarkerChunk.new(node, 'interpolation', indent: indent)
105
+ lines = extract_piped_plain_multilines(node.line - 1)
106
+ add_interpolation_chunks(node, lines.join("\n"), node.line - 1, indent: indent)
107
+ return
108
+ end
109
+
110
+ script_prefix = match[1]
111
+ _first_line_offset, lines = extract_raw_ruby_lines(node.script, node.line - 1)
112
+ # We want the actual indentation and prefix for the first line
113
+ first_line = lines[0] = @original_haml_lines[node.line - 1].rstrip
114
+ process_multiline!(first_line)
115
+
116
+ lines[0] = lines[0].sub(/(#{script_prefix}[ \t]?)/, '')
117
+ line_indentation = Regexp.last_match(1).size
118
+
119
+ raw_code = lines.join("\n")
120
+
121
+ if lines[0][/\S/] == '#'
122
+ # a "=" script that only contains a comment... No need for the "HL.out = " prefix,
123
+ # just treat it as comment which will turn into a "-" comment
124
+ else
125
+ lines[0] = HamlLint::Utils.insert_after_indentation(lines[0], script_output_prefix)
126
+ end
127
+
128
+ indent_delta = script_output_prefix.size - line_indentation
129
+ HamlLint::Utils.map_after_first!(lines) do |line|
130
+ HamlLint::Utils.indent(line, indent_delta)
131
+ end
132
+
133
+ prev_chunk = @ruby_chunks.last
134
+ if prev_chunk.is_a?(ScriptChunk) &&
135
+ prev_chunk.node.type == :script &&
136
+ prev_chunk.node == node.parent
137
+ # When an outputting script is nested under another outputting script,
138
+ # we want to block them from being merged together by rubocop, because
139
+ # this doesn't make sense in HAML.
140
+ # Example:
141
+ # = if this_is_short
142
+ # = this_is_short_too
143
+ # Could become (after RuboCop):
144
+ # HL.out = (HL.out = this_is_short_too if this_is_short)
145
+ # Or in (broken) HAML style:
146
+ # = this_is_short_too = if this_is_short
147
+ # By forcing this to start a chunk, there will be extra placeholders which
148
+ # blocks rubocop from merging the lines.
149
+ must_start_chunk = true
150
+ elsif script_prefix != '='
151
+ # In the few cases where &= and != are used to start the script,
152
+ # We need to remember and put it back in the final HAML. Fusing scripts together
153
+ # would make that basically impossible. Instead, a script has a "first_output_prefix"
154
+ # field for this specific case
155
+ must_start_chunk = true
156
+ end
157
+
158
+ finish_visit_any_script(node, lines, raw_code: raw_code, must_start_chunk: must_start_chunk,
159
+ first_output_prefix: script_prefix, &block)
160
+ end
161
+
162
+ # Visit a script which doesn't output. Lines looking like ` - foo`
163
+ def visit_silent_script(node, &block)
164
+ _first_line_offset, lines = extract_raw_ruby_lines(node.script, node.line - 1)
165
+ # We want the actual indentation and prefix for the first line
166
+ first_line = lines[0] = @original_haml_lines[node.line - 1].rstrip
167
+ process_multiline!(first_line)
168
+
169
+ lines[0] = lines[0].sub(/(-[ \t]?)/, '')
170
+ nb_to_deindent = Regexp.last_match(1).size
171
+
172
+ HamlLint::Utils.map_after_first!(lines) do |line|
173
+ line.sub(/^ {1,#{nb_to_deindent}}/, '')
174
+ end
175
+
176
+ finish_visit_any_script(node, lines, &block)
177
+ end
178
+
179
+ # Code common to both silent and outputting scripts
180
+ #
181
+ # raw_code is the code before we do transformations, such as adding the `HL.out = `
182
+ def finish_visit_any_script(node, lines, raw_code: nil, must_start_chunk: false, first_output_prefix: '=')
183
+ raw_code ||= lines.join("\n")
184
+ start_nesting = self.class.start_nesting_after?(raw_code)
185
+
186
+ lines = add_following_empty_lines(node, lines)
187
+
188
+ my_indent = lines.first.index(/\S/)
189
+ indent_after = indent_after_line_index(node.line - 1 + lines.size - 1) || 0
190
+ indent_after = [my_indent, indent_after].max
191
+
192
+ @ruby_chunks << ScriptChunk.new(node, lines,
193
+ end_marker_indent: indent_after,
194
+ must_start_chunk: must_start_chunk,
195
+ previous_chunk: @ruby_chunks.last,
196
+ first_output_haml_prefix: first_output_prefix)
197
+
198
+ yield
199
+
200
+ if start_nesting
201
+ if node.children.empty?
202
+ raise "Line #{node.line} should be followed by indentation. This might actually" \
203
+ " work in Haml, but it's almost a bug that it does. haml-lint cannot process."
204
+ end
205
+
206
+ last_child = node.children.last
207
+ if last_child.is_a?(HamlLint::Tree::SilentScriptNode) && last_child.keyword == 'end'
208
+ # This is allowed in Haml 5, gotta handle it!
209
+ # No need for the implicit end chunk since there is an explicit one
210
+ else
211
+ @ruby_chunks << ImplicitEndChunk.new(node, [' ' * my_indent + 'end'],
212
+ haml_line_index: @ruby_chunks.last.haml_end_line_index,
213
+ end_marker_indent: my_indent)
214
+ end
215
+ end
216
+ end
217
+
218
+ # Visiting a tag. Lines looking like ` %div`
219
+ def visit_tag(node)
220
+ indent = @original_haml_lines[node.line - 1].index(/\S/)
221
+
222
+ # We don't want to use a block because assignments in a block are local to that block,
223
+ # so the semantics of the extracted ruby would be different from the one generated by
224
+ # Haml. Those differences can make some cops, such as UselessAssignment, have false
225
+ # positives
226
+ code = 'begin'
227
+ @ruby_chunks << AdHocChunk.new(node,
228
+ [' ' * indent + code])
229
+ indent += 2
230
+
231
+ tag_chunk = PlaceholderMarkerChunk.new(node, 'tag', indent: indent)
232
+ @ruby_chunks << tag_chunk
233
+
234
+ current_line_index = visit_tag_attributes(node, indent: indent)
235
+ visit_tag_script(node, line_index: current_line_index, indent: indent)
236
+
237
+ yield
238
+
239
+ indent -= 2
240
+
241
+ if @ruby_chunks.last.equal?(tag_chunk)
242
+ # So there is nothing going "in" the tag, remove the wrapping "begin" and replace the PlaceholderMarkerChunk
243
+ # by one less indented
244
+ @ruby_chunks.pop
245
+ @ruby_chunks.pop
246
+ @ruby_chunks << PlaceholderMarkerChunk.new(node, 'tag', indent: indent)
247
+ else
248
+ @ruby_chunks << AdHocChunk.new(node,
249
+ [' ' * indent + 'ensure', ' ' * indent + ' HL.noop', ' ' * indent + 'end'],
250
+ haml_line_index: @ruby_chunks.last.haml_end_line_index)
251
+ end
252
+ end
253
+
254
+ # (Called manually form visit_tag)
255
+ # Visiting the attributes of a tag. Lots of different examples below in the code.
256
+ # A common syntax is: `%div{style: 'yes_please'}`
257
+ #
258
+ # Returns the new line_index we reached, useful to handle the script that follows
259
+ def visit_tag_attributes(node, indent:)
260
+ final_line_index = node.line - 1
261
+ additional_attributes = node.dynamic_attributes_sources
262
+
263
+ attributes_code = additional_attributes.first
264
+ if !attributes_code && node.hash_attributes? && node.dynamic_attributes_sources.empty?
265
+ # No idea why .foo{:bar => 123} doesn't get here, but .foo{:bar => '123'} does...
266
+ # The code we get for the latter is {:bar => '123'}.
267
+ # We normalize it by removing the { } so that it matches wha we normally get
268
+ attributes_code = node.dynamic_attributes_source[:hash][1...-1]
269
+ end
270
+
271
+ if attributes_code&.start_with?('{')
272
+ # Looks like the .foo(bar = 123) case. Ignoring.
273
+ attributes_code = nil
274
+ end
275
+
276
+ return final_line_index unless attributes_code
277
+ # Attributes have different ways to be given to us:
278
+ # .foo{bar: 123} => "bar: 123"
279
+ # .foo{:bar => 123} => ":bar => 123"
280
+ # .foo{:bar => '123'} => "{:bar => '123'}" # No idea why this is different
281
+ # .foo(bar = 123) => '{"bar" => 123,}'
282
+ # .foo{html_attrs('fr-fr')} => html_attrs('fr-fr')
283
+ #
284
+ # The (bar = 123) case is extra painful to autocorrect (so is ignored up there).
285
+ # #raw_ruby_from_haml will "detect" this case by not finding the code.
286
+ #
287
+ # We wrap the result in a method to have a valid syntax for all 3 ways
288
+ # without having to differentiate them.
289
+ first_line_offset, raw_attributes_lines = extract_raw_tag_attributes_ruby_lines(attributes_code,
290
+ node.line - 1)
291
+ return final_line_index unless raw_attributes_lines
292
+
293
+ final_line_index += raw_attributes_lines.size - 1
294
+
295
+ # Since .foo{bar: 123} => "bar: 123" needs wrapping (Or it would be a syntax error) and
296
+ # .foo{html_attrs('fr-fr')} => html_attrs('fr-fr') doesn't care about being
297
+ # wrapped, we always wrap to place them to a similar offset to how they are in the haml.
298
+ wrap_by = first_line_offset - indent
299
+ if wrap_by < 2
300
+ # Need 2 minimum, for "W(". If we have less, we must indent everything for the difference
301
+ extra_indent = 2 - wrap_by
302
+ HamlLint::Utils.map_after_first!(raw_attributes_lines) do |line|
303
+ HamlLint::Utils.indent(line, extra_indent)
304
+ end
305
+ wrap_by = 2
306
+ end
307
+ raw_attributes_lines = wrap_lines(raw_attributes_lines, wrap_by)
308
+ raw_attributes_lines[0] = ' ' * indent + raw_attributes_lines[0]
309
+
310
+ @ruby_chunks << TagAttributesChunk.new(node, raw_attributes_lines,
311
+ end_marker_indent: indent,
312
+ indent_to_remove: extra_indent)
313
+
314
+ final_line_index
315
+ end
316
+
317
+ # Visiting the script besides tag. The part to the right of the equal sign of
318
+ # lines looking like ` %div= foo(bar)`
319
+ def visit_tag_script(node, line_index:, indent:)
320
+ return if node.script.nil? || node.script.empty?
321
+ # We ignore scripts which are just a comment
322
+ return if node.script[/\S/] == '#'
323
+
324
+ first_line_offset, script_lines = extract_raw_ruby_lines(node.script, line_index)
325
+
326
+ if script_lines.nil?
327
+ # This is a string with interpolation after a tag
328
+ # ex: %tag hello #{world}
329
+ # Sadly, the text with interpolation is escaped from the original, but this code
330
+ # needs the original.
331
+
332
+ interpolation_original = @document.unescape_interpolation_to_original_cache[node.script]
333
+ line_start_index = @original_haml_lines[node.line - 1].rindex(interpolation_original)
334
+ if line_start_index.nil?
335
+ raw_lines = extract_piped_plain_multilines(node.line - 1)
336
+ equivalent_haml_code = "#{raw_lines.first} #{raw_lines[1..].map(&:lstrip).join(' ')}"
337
+ line_start_index = equivalent_haml_code.rindex(interpolation_original)
338
+
339
+ interpolation_original = raw_lines.join("\n")
340
+ end
341
+ add_interpolation_chunks(node, interpolation_original, node.line - 1,
342
+ line_start_index: line_start_index, indent: indent)
343
+ else
344
+ script_lines[0] = "#{' ' * indent}#{script_output_prefix}#{script_lines[0]}"
345
+ indent_delta = script_output_prefix.size - first_line_offset + indent
346
+ HamlLint::Utils.map_after_first!(script_lines) do |line|
347
+ HamlLint::Utils.indent(line, indent_delta)
348
+ end
349
+
350
+ @ruby_chunks << TagScriptChunk.new(node, script_lines,
351
+ haml_line_index: line_index,
352
+ end_marker_indent: indent)
353
+ end
354
+ end
355
+
356
+ # Visiting a HAML filter. Lines looking like ` :javascript` and the following lines
357
+ # that are nested.
358
+ def visit_filter(node)
359
+ # For unknown reasons, haml doesn't escape interpolations in filters.
360
+ # So we can rely on \n to split / get the number of lines.
361
+ filter_name_indent = @original_haml_lines[node.line - 1].index(/\S/)
362
+ if node.filter_type == 'ruby'
363
+ # The indentation in node.text is normalized, so that at least one line
364
+ # is indented by 0.
365
+ lines = node.text.split("\n")
366
+ lines.map! do |line|
367
+ if line !~ /\S/
368
+ # whitespace or empty
369
+ ''
370
+ else
371
+ ' ' * filter_name_indent + line
372
+ end
373
+ end
374
+
375
+ @ruby_chunks << RubyFilterChunk.new(node, lines,
376
+ haml_line_index: node.line, # it's one the next line, no need for -1
377
+ start_marker_indent: filter_name_indent,
378
+ end_marker_indent: filter_name_indent)
379
+ elsif node.text.include?('#')
380
+ name_indentation = ' ' * @original_haml_lines[node.line - 1].index(/\S/)
381
+ # TODO: HAML_LINT_FILTER could be in the string and mess things up
382
+ lines = ["#{name_indentation}#{script_output_prefix}<<~HAML_LINT_FILTER"]
383
+ lines.concat @original_haml_lines[node.line..(node.line + node.text.count("\n") - 1)]
384
+ lines << "#{name_indentation}HAML_LINT_FILTER"
385
+ @ruby_chunks << NonRubyFilterChunk.new(node, lines,
386
+ end_marker_indent: filter_name_indent)
387
+ # Those could be interpolation. We treat them as a here-doc, which is nice since we can
388
+ # keep the indentation as-is.
389
+ else
390
+ @ruby_chunks << PlaceholderMarkerChunk.new(node, 'filter', indent: filter_name_indent,
391
+ nb_lines: 1 + node.text.count("\n"))
392
+ end
393
+ end
394
+
395
+ # Adds chunks for the interpolation within the given code
396
+ def add_interpolation_chunks(node, code, haml_line_index, indent:, line_start_index: 0)
397
+ HamlLint::Utils.handle_interpolation_with_indexes(code) do |scanner, line_index, line_char_index|
398
+ escapes = scanner[2].size
399
+ next if escapes.odd?
400
+ char = scanner[3] # '{', '@' or '$'
401
+ if Gem::Version.new(Haml::VERSION) >= Gem::Version.new('5') && (char != '{')
402
+ # Before Haml 5, scanner didn't have a scanner[3], it only handled `#{}`
403
+ next
404
+ end
405
+
406
+ line_start_char_index = line_char_index
407
+ line_start_char_index += line_start_index if line_index == 0
408
+ code_start_char_index = scanner.charpos
409
+
410
+ # This moves the scanner
411
+ Haml::Util.balance(scanner, '{', '}', 1)
412
+
413
+ # Need to manually get the code now that we have positions so that all whitespace is present,
414
+ # because Haml::Util.balance does a strip...
415
+ interpolated_code = code[code_start_char_index...scanner.charpos - 1]
416
+
417
+ if interpolated_code.include?("\n")
418
+ # We can't correct multiline interpolation.
419
+ # Finding meaningful code to generate and then transfer back is pretty complex
420
+
421
+ # Since we can't fix it, strip around the code to reduce RuboCop lints that we won't be able to fix.
422
+ interpolated_code = interpolated_code.strip
423
+ interpolated_code = "#{' ' * indent}#{script_output_prefix}#{interpolated_code}"
424
+
425
+ placeholder_code = interpolated_code.gsub(/\s*\n\s*/, ' ').rstrip
426
+ unless parse_ruby(placeholder_code)
427
+ placeholder_code = interpolated_code.gsub(/\s*\n\s*/, '; ').rstrip
428
+ end
429
+ @ruby_chunks << AdHocChunk.new(node, [placeholder_code],
430
+ haml_line_index: haml_line_index + line_index)
431
+ else
432
+ interpolated_code = "#{' ' * indent}#{script_output_prefix}#{interpolated_code}"
433
+ @ruby_chunks << InterpolationChunk.new(node, [interpolated_code],
434
+ haml_line_index: haml_line_index + line_index,
435
+ start_char_index: line_start_char_index,
436
+ end_marker_indent: indent)
437
+ end
438
+ end
439
+ end
440
+
441
+ def process_multiline!(line)
442
+ if HAML_PARSER_INSTANCE.send(:is_multiline?, line)
443
+ line.chop!.rstrip!
444
+ true
445
+ else
446
+ false
447
+ end
448
+ end
449
+
450
+ def process_plain_multiline!(line)
451
+ if line&.end_with?(' |')
452
+ line[-2..] = ''
453
+ true
454
+ else
455
+ false
456
+ end
457
+ end
458
+
459
+ # Returns the raw lines from the haml for the given index.
460
+ # Multiple lines are returned when a line ends with a comma as that is the only
461
+ # time HAMLs allows Ruby lines to be split.
462
+
463
+ # Haml's line-splitting rules (allowed after comma in scripts and attributes) are handled
464
+ # at the parser level, so Haml doesn't provide the code as it is actually formatted in the Haml
465
+ # file. #raw_ruby_from_haml extracts the ruby code as it is exactly in the Haml file.
466
+ # The first and last lines may not be the complete lines from the Haml, only the Ruby parts
467
+ # and the indentation between the first and last list.
468
+
469
+ # HAML transforms the ruby code in many ways as it parses a document. Often removing lines and/or
470
+ # indentation. This is quite annoying for us since we want the exact layout of the code to analyze it.
471
+ #
472
+ # This function receives the code as haml provides it and the line where it starts. It returns
473
+ # the actual code as it is in the haml file, keeping breaks and indentation for the following lines.
474
+ # In addition, the start position of the code in the first line.
475
+ #
476
+ # The rules for handling multiline code in HAML are as follow:
477
+ # * if the line being processed ends with a space and a pipe, then append to the line (without
478
+ # newlines) every following lines that also end with a space and a pipe. This means the last line of
479
+ # the "block" also needs a pipe at the end.
480
+ # * after processing the pipes, when dealing with ruby code (and not in tag attributes' hash), if the line
481
+ # (which maybe span across multiple lines) ends with a comma, add the next line to the current piece of code.
482
+ #
483
+ # @return [first_line_offset, ruby_lines]
484
+ def extract_raw_ruby_lines(haml_processed_ruby_code, first_line_index)
485
+ haml_processed_ruby_code = haml_processed_ruby_code.strip
486
+ first_line = @original_haml_lines[first_line_index]
487
+
488
+ char_index = first_line.index(haml_processed_ruby_code)
489
+
490
+ if char_index
491
+ return [char_index, [haml_processed_ruby_code]]
492
+ end
493
+
494
+ cur_line_index = first_line_index
495
+ cur_line = first_line.rstrip
496
+ lines = []
497
+
498
+ # The pipes must also be on the last line of the multi-line section
499
+ while cur_line && process_multiline!(cur_line)
500
+ lines << cur_line
501
+ cur_line_index += 1
502
+ cur_line = @original_haml_lines[cur_line_index].rstrip
503
+ end
504
+
505
+ if lines.empty?
506
+ lines << cur_line
507
+ else
508
+ # The pipes must also be on the last line of the multi-line section. So cur_line is not the next line.
509
+ # We want to go back to check for commas
510
+ cur_line_index -= 1
511
+ cur_line = lines.last
512
+ end
513
+
514
+ while HAML_PARSER_INSTANCE.send(:is_ruby_multiline?, cur_line)
515
+ cur_line_index += 1
516
+ cur_line = @original_haml_lines[cur_line_index].rstrip
517
+ lines << cur_line
518
+ end
519
+
520
+ joined_lines = lines.join("\n")
521
+
522
+ if haml_processed_ruby_code.include?("\n")
523
+ haml_processed_ruby_code = haml_processed_ruby_code.gsub("\n", ' ')
524
+ end
525
+
526
+ haml_processed_ruby_code.split(/[, ]/)
527
+
528
+ regexp = HamlLint::Utils.regexp_for_parts(haml_processed_ruby_code.split(/,\s*|\s+/), '(?:,\\s*|\\s+)')
529
+
530
+ match = joined_lines.match(regexp)
531
+ # This can happen when pipes are used as marker for multiline parts, and when tag attributes change lines
532
+ # without ending by a comma. This is quite a can of worm and is probably not too frequent, so for now,
533
+ # these cases are not supported.
534
+ return if match.nil?
535
+
536
+ raw_ruby = match[0]
537
+ ruby_lines = raw_ruby.split("\n")
538
+ first_line_offset = match.begin(0)
539
+
540
+ [first_line_offset, ruby_lines]
541
+ end
542
+
543
+ def extract_piped_plain_multilines(first_line_index)
544
+ lines = []
545
+
546
+ cur_line = @original_haml_lines[first_line_index].rstrip
547
+ cur_line_index = first_line_index
548
+
549
+ # The pipes must also be on the last line of the multi-line section
550
+ while cur_line && process_plain_multiline!(cur_line)
551
+ lines << cur_line
552
+ cur_line_index += 1
553
+ cur_line = @original_haml_lines[cur_line_index].rstrip
554
+ end
555
+
556
+ if lines.empty?
557
+ lines << cur_line
558
+ end
559
+ lines
560
+ end
561
+
562
+ # Tag attributes actually handle multiline differently than scripts.
563
+ # The basic system basically keeps considering more lines until it meets the closing braces, but still
564
+ # processes pipes too (same as extract_raw_ruby_lines).
565
+ def extract_raw_tag_attributes_ruby_lines(haml_processed_ruby_code, first_line_index)
566
+ haml_processed_ruby_code = haml_processed_ruby_code.strip
567
+ first_line = @original_haml_lines[first_line_index]
568
+
569
+ char_index = first_line.index(haml_processed_ruby_code)
570
+
571
+ if char_index
572
+ return [char_index, [haml_processed_ruby_code]]
573
+ end
574
+
575
+ min_non_white_chars_to_add = haml_processed_ruby_code.scan(/\S/).size
576
+
577
+ regexp = HamlLint::Utils.regexp_for_parts(haml_processed_ruby_code.split(/\s+/), '\\s+')
578
+
579
+ joined_lines = first_line.rstrip
580
+ process_multiline!(joined_lines)
581
+
582
+ cur_line_index = first_line_index + 1
583
+ while @original_haml_lines[cur_line_index] && min_non_white_chars_to_add > 0
584
+ new_line = @original_haml_lines[cur_line_index].rstrip
585
+ process_multiline!(new_line)
586
+
587
+ min_non_white_chars_to_add -= new_line.scan(/\S/).size
588
+ joined_lines << "\n"
589
+ joined_lines << new_line
590
+ cur_line_index += 1
591
+ end
592
+
593
+ match = joined_lines.match(regexp)
594
+
595
+ return if match.nil?
596
+
597
+ first_line_offset = match.begin(0)
598
+ raw_ruby = match[0]
599
+ ruby_lines = raw_ruby.split("\n")
600
+
601
+ [first_line_offset, ruby_lines]
602
+ end
603
+
604
+ def wrap_lines(lines, wrap_depth)
605
+ lines = lines.dup
606
+ wrapping_prefix = 'W' * (wrap_depth - 1) + '('
607
+ lines[0] = wrapping_prefix + lines[0]
608
+ lines[-1] = lines[-1] + ')'
609
+ lines
610
+ end
611
+
612
+ # Adds empty lines that follow the lines (Used for scripts), so that
613
+ # RuboCop can receive them too. Some cops are sensitive to empty lines.
614
+ def add_following_empty_lines(node, lines)
615
+ first_line_index = node.line - 1 + lines.size
616
+ extra_lines = []
617
+
618
+ extra_lines << '' while HamlLint::Utils.is_blank_line?(@original_haml_lines[first_line_index + extra_lines.size])
619
+
620
+ if @original_haml_lines[first_line_index + extra_lines.size].nil?
621
+ # Since we reached the end of the document without finding content,
622
+ # then we don't add those lines.
623
+ return lines
624
+ end
625
+
626
+ lines + extra_lines
627
+ end
628
+
629
+ def parse_ruby(source)
630
+ @ruby_parser ||= HamlLint::RubyParser.new
631
+ @ruby_parser.parse(source)
632
+ end
633
+
634
+ def indent_after_line_index(line_index)
635
+ (line_index + 1..@original_haml_lines.size - 1).each do |i|
636
+ indent = @original_haml_lines[i].index(/\S/)
637
+ return indent if indent
638
+ end
639
+ nil
640
+ end
641
+
642
+ def self.start_nesting_after?(code)
643
+ anonymous_block?(code) || start_block_keyword?(code)
644
+ end
645
+
646
+ def self.anonymous_block?(code)
647
+ # Don't start with a comment and end with a `do`
648
+ # Definetly not perfect for the comment handling, but otherwise a more advanced parsing system is needed.
649
+ # Move the comment to its own line if it's annoying.
650
+ code !~ /\A\s*#/ &&
651
+ code =~ /\bdo\s*(\|[^|]*\|\s*)?(#.*)?\z/
652
+ end
653
+
654
+ START_BLOCK_KEYWORDS = %w[if unless case begin for until while].freeze
655
+ def self.start_block_keyword?(code)
656
+ START_BLOCK_KEYWORDS.include?(block_keyword(code))
657
+ end
658
+
659
+ LOOP_KEYWORDS = %w[for until while].freeze
660
+ def self.block_keyword(code)
661
+ # Need to handle 'for'/'while' since regex stolen from HAML parser doesn't
662
+ if (keyword = code[/\A\s*([^\s]+)\s+/, 1]) && LOOP_KEYWORDS.include?(keyword)
663
+ return keyword
664
+ end
665
+
666
+ return unless keyword = code.scan(Haml::Parser::BLOCK_KEYWORD_REGEX)[0]
667
+ keyword[0] || keyword[1]
668
+ end
669
+ end
670
+ end
671
+
672
+ # rubocop:enable Metrics