haml_lint 0.44.0 → 0.46.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) 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 +156 -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 +13 -12
  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/rubocop.rb +351 -59
  20. data/lib/haml_lint/linter/space_before_script.rb +8 -10
  21. data/lib/haml_lint/linter/unnecessary_string_output.rb +1 -1
  22. data/lib/haml_lint/linter/view_length.rb +1 -1
  23. data/lib/haml_lint/linter.rb +56 -9
  24. data/lib/haml_lint/linter_registry.rb +3 -5
  25. data/lib/haml_lint/logger.rb +2 -2
  26. data/lib/haml_lint/options.rb +26 -2
  27. data/lib/haml_lint/rake_task.rb +2 -2
  28. data/lib/haml_lint/reporter/hash_reporter.rb +1 -3
  29. data/lib/haml_lint/reporter/offense_count_reporter.rb +1 -1
  30. data/lib/haml_lint/reporter/utils.rb +33 -4
  31. data/lib/haml_lint/ruby_extraction/ad_hoc_chunk.rb +20 -0
  32. data/lib/haml_lint/ruby_extraction/base_chunk.rb +113 -0
  33. data/lib/haml_lint/ruby_extraction/chunk_extractor.rb +504 -0
  34. data/lib/haml_lint/ruby_extraction/coordinator.rb +181 -0
  35. data/lib/haml_lint/ruby_extraction/haml_comment_chunk.rb +54 -0
  36. data/lib/haml_lint/ruby_extraction/implicit_end_chunk.rb +17 -0
  37. data/lib/haml_lint/ruby_extraction/interpolation_chunk.rb +26 -0
  38. data/lib/haml_lint/ruby_extraction/non_ruby_filter_chunk.rb +32 -0
  39. data/lib/haml_lint/ruby_extraction/placeholder_marker_chunk.rb +40 -0
  40. data/lib/haml_lint/ruby_extraction/ruby_filter_chunk.rb +33 -0
  41. data/lib/haml_lint/ruby_extraction/ruby_source.rb +5 -0
  42. data/lib/haml_lint/ruby_extraction/script_chunk.rb +132 -0
  43. data/lib/haml_lint/ruby_extraction/tag_attributes_chunk.rb +39 -0
  44. data/lib/haml_lint/ruby_extraction/tag_script_chunk.rb +30 -0
  45. data/lib/haml_lint/ruby_extractor.rb +11 -10
  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 +143 -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 +130 -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 +25 -6
@@ -0,0 +1,504 @@
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
+ def initialize(document, script_output_prefix:)
15
+ @document = document
16
+ @script_output_prefix = script_output_prefix
17
+ end
18
+
19
+ def extract
20
+ raise 'Already extracted' if @ruby_chunks
21
+
22
+ @ruby_chunks = []
23
+ @original_haml_lines = @document.source_lines
24
+
25
+ visit(@document.tree)
26
+ @ruby_chunks
27
+ end
28
+
29
+ def visit_root(_node)
30
+ yield # Collect lines of code from children
31
+ end
32
+
33
+ # Visiting lines like ` Some raw text to output`
34
+ def visit_plain(node)
35
+ indent = @original_haml_lines[node.line - 1].index(/\S/)
36
+ @ruby_chunks << PlaceholderMarkerChunk.new(node, 'plain', indent: indent)
37
+ end
38
+
39
+ # Visiting lines like ` -# Some commenting!`
40
+ def visit_haml_comment(node)
41
+ # We want to preserve leading whitespace if it exists, but add a leading
42
+ # whitespace if it doesn't exist so that RuboCop's LeadingCommentSpace
43
+ # doesn't complain
44
+ line_index = node.line - 1
45
+ lines = @original_haml_lines[line_index..(line_index + node.text.count("\n"))].dup
46
+ indent = lines.first.index(/\S/)
47
+ # Remove only the -, the # will align with regular code
48
+ # -# comment
49
+ # - foo()
50
+ # becomes
51
+ # # comment
52
+ # foo()
53
+ lines[0] = lines[0].sub('-', '')
54
+
55
+ # Adding a space before the comment if its missing
56
+ # We can't fix those, so make sure not to generate warnings for them.
57
+ lines[0] = lines[0].sub(/\A(\s*)#(\S)/, '\\1# \\2')
58
+
59
+ HamlLint::Utils.map_after_first!(lines) do |line|
60
+ # Since the indent/spaces of the extra line comments isn't exactly in the haml,
61
+ # it's not RuboCop's job to fix indentation, so just make a reasonable indentation
62
+ # to avoid offenses.
63
+ ' ' * indent + line.sub(/^\s*/, '# ').rstrip
64
+ end
65
+
66
+ # Using Placeholder instead of script because we can't revert back to the
67
+ # exact original comment since multiple syntax lead to the exact same comment.
68
+ @ruby_chunks << HamlCommentChunk.new(node, lines, end_marker_indent: indent)
69
+ end
70
+
71
+ # Visiting comments which are output to HTML. Lines looking like
72
+ # ` / This will be in the HTML source!`
73
+ def visit_comment(node)
74
+ lines = raw_lines_of_interest(node.line - 1)
75
+ indent = lines.first.index(/\S/)
76
+ @ruby_chunks << PlaceholderMarkerChunk.new(node, 'comment', indent: indent)
77
+ end
78
+
79
+ # Visit a script which outputs. Lines looking like ` = foo`
80
+ def visit_script(node, &block)
81
+ lines = raw_lines_of_interest(node.line - 1)
82
+
83
+ if lines.first !~ /\A\s*[-=]/
84
+ # The line doesn't start with a - or a =, this is actually a "plain"
85
+ # that contains interpolation.
86
+ indent = lines.first.index(/\S/)
87
+ @ruby_chunks << PlaceholderMarkerChunk.new(node, 'interpolation', indent: indent)
88
+ add_interpolation_chunks(node, lines.first, node.line - 1, indent: indent)
89
+ return
90
+ end
91
+
92
+ lines[0] = lines[0].sub(/(=[ \t]?)/, '')
93
+ line_indentation = Regexp.last_match(1).size
94
+
95
+ raw_code = lines.join("\n")
96
+
97
+ if lines[0][/\S/] == '#'
98
+ # a script that only constains a comment... needs special handling
99
+ comment_index = lines[0].index(/\S/)
100
+ lines[0].insert(comment_index + 1, " #{script_output_prefix.rstrip}")
101
+ else
102
+ lines[0] = HamlLint::Utils.insert_after_indentation(lines[0], script_output_prefix)
103
+ end
104
+
105
+ indent_delta = script_output_prefix.size - line_indentation
106
+ HamlLint::Utils.map_after_first!(lines) do |line|
107
+ HamlLint::Utils.indent(line, indent_delta)
108
+ end
109
+
110
+ prev_chunk = @ruby_chunks.last
111
+ if prev_chunk.is_a?(ScriptChunk) &&
112
+ prev_chunk.node.type == :script &&
113
+ prev_chunk.node == node.parent
114
+ # When an outputting script is nested under another outputting script,
115
+ # we want to block them from being merged together by rubocop, because
116
+ # this doesn't make sense in HAML.
117
+ # Example:
118
+ # = if this_is_short
119
+ # = this_is_short_too
120
+ # Could become (after RuboCop):
121
+ # HL.out = (HL.out = this_is_short_too if this_is_short)
122
+ # Or in (broken) HAML style:
123
+ # = this_is_short_too = if this_is_short
124
+ # By forcing this to start a chunk, there will be extra placeholders which
125
+ # blocks rubocop from merging the lines.
126
+ must_start_chunk = true
127
+ end
128
+
129
+ finish_visit_any_script(node, lines, raw_code: raw_code, must_start_chunk: must_start_chunk, &block)
130
+ end
131
+
132
+ # Visit a script which doesn't output. Lines looking like ` - foo`
133
+ def visit_silent_script(node, &block)
134
+ lines = raw_lines_of_interest(node.line - 1)
135
+ lines[0] = lines[0].sub(/(-[ \t]?)/, '')
136
+ nb_to_deindent = Regexp.last_match(1).size
137
+
138
+ HamlLint::Utils.map_after_first!(lines) do |line|
139
+ line.sub(/^ {1,#{nb_to_deindent}}/, '')
140
+ end
141
+
142
+ finish_visit_any_script(node, lines, &block)
143
+ end
144
+
145
+ # Code common to both silent and outputting scripts
146
+ #
147
+ # raw_code is the code before we do transformations, such as adding the `HL.out = `
148
+ def finish_visit_any_script(node, lines, raw_code: nil, must_start_chunk: false)
149
+ raw_code ||= lines.join("\n")
150
+ start_nesting = self.class.start_nesting_after?(raw_code)
151
+
152
+ lines = add_following_empty_lines(node, lines)
153
+
154
+ my_indent = lines.first.index(/\S/)
155
+ indent_after = indent_after_line_index(node.line - 1 + lines.size - 1) || 0
156
+ indent_after = [my_indent, indent_after].max
157
+
158
+ @ruby_chunks << ScriptChunk.new(node, lines,
159
+ end_marker_indent: indent_after,
160
+ must_start_chunk: must_start_chunk,
161
+ previous_chunk: @ruby_chunks.last)
162
+
163
+ yield
164
+
165
+ if start_nesting
166
+ if node.children.empty?
167
+ raise "Line #{node.line} should be followed by indentation. This might actually" \
168
+ " work in Haml, but it's almost a bug that it does. haml-lint cannot process."
169
+ end
170
+
171
+ last_child = node.children.last
172
+ if last_child.is_a?(HamlLint::Tree::SilentScriptNode) && last_child.keyword == 'end'
173
+ # This is allowed in Haml 5, gotta handle it!
174
+ # No need for the implicit end chunk since there is an explicit one
175
+ else
176
+ @ruby_chunks << ImplicitEndChunk.new(node, [' ' * my_indent + 'end'],
177
+ haml_line_index: @ruby_chunks.last.haml_end_line_index,
178
+ end_marker_indent: my_indent)
179
+ end
180
+ end
181
+ end
182
+
183
+ # Visiting a tag. Lines looking like ` %div`
184
+ def visit_tag(node)
185
+ indent = @original_haml_lines[node.line - 1].index(/\S/)
186
+
187
+ has_children = !node.children.empty?
188
+ if has_children
189
+ # We don't want to use a block because assignments in a block are local to that block,
190
+ # so the semantics of the extracted ruby would be different from the one generated by
191
+ # Haml. Those differences can make some cops, such as UselessAssignment, have false
192
+ # positives
193
+ code = 'begin # rubocop:disable Style/RedundantBegin,Lint/RedundantCopDisableDirective'
194
+ @ruby_chunks << AdHocChunk.new(node,
195
+ [' ' * indent + code])
196
+ indent += 2
197
+ end
198
+
199
+ @ruby_chunks << PlaceholderMarkerChunk.new(node, 'tag', indent: indent)
200
+
201
+ current_line_index = visit_tag_attributes(node, indent: indent)
202
+ visit_tag_script(node, line_index: current_line_index, indent: indent)
203
+
204
+ if has_children
205
+ yield
206
+ indent -= 2
207
+ @ruby_chunks << AdHocChunk.new(node, [' ' * indent + 'end'],
208
+ haml_line_index: @ruby_chunks.last.haml_end_line_index)
209
+ end
210
+ end
211
+
212
+ # (Called manually form visit_tag)
213
+ # Visiting the attributes of a tag. Lots of different examples below in the code.
214
+ # A common syntax is: `%div{style: 'yes_please'}`
215
+ #
216
+ # Returns the new line_index we reached, useful to handle the script that follows
217
+ def visit_tag_attributes(node, indent:)
218
+ final_line_index = node.line - 1
219
+ additional_attributes = node.dynamic_attributes_sources
220
+
221
+ attributes_code = additional_attributes.first
222
+ if !attributes_code && node.hash_attributes? && node.dynamic_attributes_sources.empty?
223
+ # No idea why .foo{:bar => 123} doesn't get here, but .foo{:bar => '123'} does...
224
+ # The code we get for the later is {:bar => '123'}.
225
+ # We normalize it by removing the { } so that it matches wha we normally get
226
+ attributes_code = node.dynamic_attributes_source[:hash][1...-1]
227
+ end
228
+
229
+ return final_line_index unless attributes_code
230
+ # Attributes have different ways to be given to us:
231
+ # .foo{bar: 123} => "bar: 123"
232
+ # .foo{:bar => 123} => ":bar => 123"
233
+ # .foo{:bar => '123'} => "{:bar => '123'}" # No idea why this is different
234
+ # .foo(bar = 123) => '{"bar" => 123,}'
235
+ # .foo{html_attrs('fr-fr')} => html_attrs('fr-fr')
236
+ #
237
+ # The (bar = 123) case is extra painful to autocorrect (so is ignored).
238
+ # #raw_ruby_from_haml will "detect" this case by not finding the code.
239
+ #
240
+ # We wrap the result in a method to have a valid syntax for all 3 ways
241
+ # without having to differentiate them.
242
+ first_line_offset, raw_attributes_lines = raw_ruby_lines_from_haml(attributes_code,
243
+ node.line - 1)
244
+
245
+ return final_line_index unless raw_attributes_lines
246
+
247
+ final_line_index += raw_attributes_lines.size - 1
248
+
249
+ # Since .foo{bar: 123} => "bar: 123" needs wrapping (Or it would be a syntax error) and
250
+ # .foo{html_attrs('fr-fr')} => html_attrs('fr-fr') doesn't care about being
251
+ # wrapped, we always wrap to place them to a similar offset to how they are in the haml.
252
+ wrap_by = first_line_offset - indent
253
+ if wrap_by < 2
254
+ # Need 2 minimum, for "W(". If we have less, we must indent everything for the difference
255
+ extra_indent = 2 - wrap_by
256
+ HamlLint::Utils.map_after_first!(raw_attributes_lines) do |line|
257
+ HamlLint::Utils.indent(line, extra_indent)
258
+ end
259
+ wrap_by = 2
260
+ end
261
+ raw_attributes_lines = wrap_lines(raw_attributes_lines, wrap_by)
262
+ raw_attributes_lines[0] = ' ' * indent + raw_attributes_lines[0]
263
+
264
+ @ruby_chunks << TagAttributesChunk.new(node, raw_attributes_lines,
265
+ end_marker_indent: indent,
266
+ indent_to_remove: extra_indent)
267
+
268
+ final_line_index
269
+ end
270
+
271
+ # Visiting the script besides tag. The part to the right of the equal sign of
272
+ # lines looking like ` %div= foo(bar)`
273
+ def visit_tag_script(node, line_index:, indent:)
274
+ return if node.script.nil? || node.script.empty?
275
+ # We ignore scripts which are just a comment
276
+ return if node.script[/\S/] == '#'
277
+
278
+ first_line_offset, script_lines = raw_ruby_lines_from_haml(node.script, line_index)
279
+
280
+ if script_lines.nil?
281
+ # This is a string with interpolation after a tag
282
+ # ex: %tag hello #{world}
283
+ # Sadly, the text with interpolation is escaped from the original, but this code
284
+ # needs the original.
285
+ interpolation_original = @document.unescape_interpolation_to_original_cache[node.script]
286
+
287
+ line_start_index = @original_haml_lines[node.line - 1].rindex(interpolation_original)
288
+ add_interpolation_chunks(node, interpolation_original, node.line - 1,
289
+ line_start_index: line_start_index, indent: indent)
290
+ else
291
+ script_lines[0] = "#{' ' * indent}#{script_output_prefix}#{script_lines[0]}"
292
+ indent_delta = script_output_prefix.size - first_line_offset + indent
293
+ HamlLint::Utils.map_after_first!(script_lines) do |line|
294
+ HamlLint::Utils.indent(line, indent_delta)
295
+ end
296
+
297
+ @ruby_chunks << TagScriptChunk.new(node, script_lines,
298
+ haml_line_index: line_index,
299
+ end_marker_indent: indent)
300
+ end
301
+ end
302
+
303
+ # Visiting a HAML filter. Lines looking like ` :javascript` and the following lines
304
+ # that are nested.
305
+ def visit_filter(node)
306
+ # For unknown reasons, haml doesn't escape interpolations in filters.
307
+ # So we can rely on \n to split / get the number of lines.
308
+ filter_name_indent = @original_haml_lines[node.line - 1].index(/\S/)
309
+ if node.filter_type == 'ruby'
310
+ # The indentation in node.text is normalized, so that at least one line
311
+ # is indented by 0.
312
+ lines = node.text.split("\n")
313
+ lines.map! do |line|
314
+ if line !~ /\S/
315
+ # whitespace or empty
316
+ ''
317
+ else
318
+ ' ' * filter_name_indent + line
319
+ end
320
+ end
321
+
322
+ @ruby_chunks << RubyFilterChunk.new(node, lines,
323
+ haml_line_index: node.line, # it's one the next line, no need for -1
324
+ start_marker_indent: filter_name_indent,
325
+ end_marker_indent: filter_name_indent)
326
+ elsif node.text.include?('#')
327
+ name_indentation = ' ' * @original_haml_lines[node.line - 1].index(/\S/)
328
+ # TODO: HAML_LINT_FILTER could be in the string and mess things up
329
+ lines = ["#{name_indentation}#{script_output_prefix}<<~HAML_LINT_FILTER"]
330
+ lines.concat @original_haml_lines[node.line..(node.line + node.text.count("\n") - 1)]
331
+ lines << "#{name_indentation}HAML_LINT_FILTER"
332
+ @ruby_chunks << NonRubyFilterChunk.new(node, lines,
333
+ end_marker_indent: filter_name_indent)
334
+ # Those could be interpolation. We treat them as a here-doc, which is nice since we can
335
+ # keep the indentation as-is.
336
+ else
337
+ @ruby_chunks << PlaceholderMarkerChunk.new(node, 'filter', indent: filter_name_indent,
338
+ nb_lines: 1 + node.text.count("\n"))
339
+ end
340
+ end
341
+
342
+ # Adds chunks for the interpolation within the given code
343
+ def add_interpolation_chunks(node, code, haml_line_index, indent:, line_start_index: 0)
344
+ HamlLint::Utils.handle_interpolation_with_indexes(code) do |scanner, line_index, char_index|
345
+ escapes = scanner[2].size
346
+ next if escapes.odd?
347
+ char = scanner[3] # '{', '@' or '$'
348
+ if Gem::Version.new(Haml::VERSION) >= Gem::Version.new('5') && (char != '{')
349
+ # Before Haml 5, scanner didn't have a scanner[3], it only handled `#{}`
350
+ next
351
+ end
352
+
353
+ start_char_index = char_index
354
+ start_char_index += line_start_index if line_index == 0
355
+
356
+ Haml::Util.balance(scanner, '{', '}', 1)[0][0...-1]
357
+
358
+ # Need to manually get the code now that we have positions so that all whitespace is present,
359
+ # because Haml::Util.balance does a strip...
360
+ interpolated_code = code[char_index...scanner.charpos - 1]
361
+
362
+ interpolated_code = "#{' ' * indent}#{script_output_prefix}#{interpolated_code}"
363
+
364
+ if interpolated_code.include?("\n")
365
+ # We can't correct multiline interpolation.
366
+ # Finding meaningful code to generate and then transfer back is pretty complex
367
+ placeholder_code = interpolated_code.gsub(/\s*\n\s*/, ' ').rstrip
368
+ unless parse_ruby(placeholder_code)
369
+ placeholder_code = interpolated_code.gsub(/\s*\n\s*/, '; ').rstrip
370
+ end
371
+ @ruby_chunks << AdHocChunk.new(node, [placeholder_code],
372
+ haml_line_index: haml_line_index + line_index)
373
+ else
374
+ @ruby_chunks << InterpolationChunk.new(node, [interpolated_code],
375
+ haml_line_index: haml_line_index + line_index,
376
+ start_char_index: start_char_index,
377
+ end_marker_indent: indent)
378
+ end
379
+ end
380
+ end
381
+
382
+ # Returns the raw lines from the haml for the given index.
383
+ # Multiple lines are returned when a line ends with a comma as that is the only
384
+ # time HAMLs allows Ruby lines to be split.
385
+ def raw_lines_of_interest(first_line_index)
386
+ line_index = first_line_index
387
+ lines_of_interest = [@original_haml_lines[line_index]]
388
+
389
+ while @original_haml_lines[line_index].rstrip.end_with?(',')
390
+ line_index += 1
391
+ lines_of_interest << @original_haml_lines[line_index]
392
+ end
393
+
394
+ lines_of_interest
395
+ end
396
+
397
+ # Haml's line-splitting rules (allowed after comma in scripts and attributes) are handled
398
+ # at the parser level, so Haml doesn't provide the code as it is actually formatted in the Haml
399
+ # file. #raw_ruby_from_haml extracts the ruby code as it is exactly in the Haml file.
400
+ # The first and last lines may not be the complete lines from the Haml, only the Ruby parts
401
+ # and the indentation between the first and last list.
402
+ def raw_ruby_lines_from_haml(code, first_line_index)
403
+ stripped_code = code.strip
404
+ return if stripped_code.empty?
405
+
406
+ lines_of_interest = raw_lines_of_interest(first_line_index)
407
+
408
+ if lines_of_interest.size == 1
409
+ index = lines_of_interest.first.index(stripped_code)
410
+ if lines_of_interest.first.include?(stripped_code)
411
+ return [index, [stripped_code]]
412
+ else
413
+ # Sometimes, the code just isn't in the Haml when Haml does transformations to it
414
+ return
415
+ end
416
+ end
417
+
418
+ raw_haml = lines_of_interest.join("\n")
419
+
420
+ # Need the gsub because while multiline scripts are turned into a single line,
421
+ # by haml, multiline tag attributes are not.
422
+ code_parts = stripped_code.gsub("\n", ' ').split(/,\s*/)
423
+
424
+ regexp_code = code_parts.map { |c| Regexp.quote(c) }.join(',\\s*')
425
+ regexp = Regexp.new(regexp_code)
426
+
427
+ match = raw_haml.match(regexp)
428
+
429
+ raw_ruby = match[0]
430
+ ruby_lines = raw_ruby.split("\n")
431
+ first_line_offset = match.begin(0)
432
+
433
+ [first_line_offset, ruby_lines]
434
+ end
435
+
436
+ def wrap_lines(lines, wrap_depth)
437
+ lines = lines.dup
438
+ wrapping_prefix = 'W' * (wrap_depth - 1) + '('
439
+ lines[0] = wrapping_prefix + lines[0]
440
+ lines[-1] = lines[-1] + ')'
441
+ lines
442
+ end
443
+
444
+ # Adds empty lines that follow the lines (Used for scripts), so that
445
+ # RuboCop can receive them too. Some cops are sensitive to empty lines.
446
+ def add_following_empty_lines(node, lines)
447
+ first_line_index = node.line - 1 + lines.size
448
+ extra_lines = []
449
+
450
+ extra_lines << '' while HamlLint::Utils.is_blank_line?(@original_haml_lines[first_line_index + extra_lines.size])
451
+
452
+ if @original_haml_lines[first_line_index + extra_lines.size].nil?
453
+ # Since we reached the end of the document without finding content,
454
+ # then we don't add those lines.
455
+ return lines
456
+ end
457
+
458
+ lines + extra_lines
459
+ end
460
+
461
+ def parse_ruby(source)
462
+ @ruby_parser ||= HamlLint::RubyParser.new
463
+ @ruby_parser.parse(source)
464
+ end
465
+
466
+ def indent_after_line_index(line_index)
467
+ (line_index + 1..@original_haml_lines.size - 1).each do |i|
468
+ indent = @original_haml_lines[i].index(/\S/)
469
+ return indent if indent
470
+ end
471
+ nil
472
+ end
473
+
474
+ def self.start_nesting_after?(code)
475
+ anonymous_block?(code) || start_block_keyword?(code)
476
+ end
477
+
478
+ def self.anonymous_block?(code)
479
+ # Don't start with a comment and end with a `do`
480
+ # Definetly not perfect for the comment handling, but otherwise a more advanced parsing system is needed.
481
+ # Move the comment to its own line if it's annoying.
482
+ code !~ /\A\s*#/ &&
483
+ code =~ /\bdo\s*(\|[^|]*\|\s*)?(#.*)?\z/
484
+ end
485
+
486
+ START_BLOCK_KEYWORDS = %w[if unless case begin for until while].freeze
487
+ def self.start_block_keyword?(code)
488
+ START_BLOCK_KEYWORDS.include?(block_keyword(code))
489
+ end
490
+
491
+ LOOP_KEYWORDS = %w[for until while].freeze
492
+ def self.block_keyword(code)
493
+ # Need to handle 'for'/'while' since regex stolen from HAML parser doesn't
494
+ if (keyword = code[/\A\s*([^\s]+)\s+/, 1]) && LOOP_KEYWORDS.include?(keyword)
495
+ return keyword
496
+ end
497
+
498
+ return unless keyword = code.scan(Haml::Parser::BLOCK_KEYWORD_REGEX)[0]
499
+ keyword[0] || keyword[1]
500
+ end
501
+ end
502
+ end
503
+
504
+ # rubocop:enable Metrics
@@ -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