haml_lint 0.45.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +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/rubocop.rb +353 -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 +60 -10
  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 +24 -0
  32. data/lib/haml_lint/ruby_extraction/base_chunk.rb +114 -0
  33. data/lib/haml_lint/ruby_extraction/chunk_extractor.rb +509 -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 +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 +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,509 @@
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'
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,
208
+ [' ' * indent + 'ensure', ' ' * indent + ' HL.noop', ' ' * indent + 'end'],
209
+ haml_line_index: @ruby_chunks.last.haml_end_line_index)
210
+ end
211
+ end
212
+
213
+ # (Called manually form visit_tag)
214
+ # Visiting the attributes of a tag. Lots of different examples below in the code.
215
+ # A common syntax is: `%div{style: 'yes_please'}`
216
+ #
217
+ # Returns the new line_index we reached, useful to handle the script that follows
218
+ def visit_tag_attributes(node, indent:)
219
+ final_line_index = node.line - 1
220
+ additional_attributes = node.dynamic_attributes_sources
221
+
222
+ attributes_code = additional_attributes.first
223
+ if !attributes_code && node.hash_attributes? && node.dynamic_attributes_sources.empty?
224
+ # No idea why .foo{:bar => 123} doesn't get here, but .foo{:bar => '123'} does...
225
+ # The code we get for the later is {:bar => '123'}.
226
+ # We normalize it by removing the { } so that it matches wha we normally get
227
+ attributes_code = node.dynamic_attributes_source[:hash][1...-1]
228
+ end
229
+
230
+ return final_line_index unless attributes_code
231
+ # Attributes have different ways to be given to us:
232
+ # .foo{bar: 123} => "bar: 123"
233
+ # .foo{:bar => 123} => ":bar => 123"
234
+ # .foo{:bar => '123'} => "{:bar => '123'}" # No idea why this is different
235
+ # .foo(bar = 123) => '{"bar" => 123,}'
236
+ # .foo{html_attrs('fr-fr')} => html_attrs('fr-fr')
237
+ #
238
+ # The (bar = 123) case is extra painful to autocorrect (so is ignored).
239
+ # #raw_ruby_from_haml will "detect" this case by not finding the code.
240
+ #
241
+ # We wrap the result in a method to have a valid syntax for all 3 ways
242
+ # without having to differentiate them.
243
+ first_line_offset, raw_attributes_lines = raw_ruby_lines_from_haml(attributes_code,
244
+ node.line - 1)
245
+
246
+ return final_line_index unless raw_attributes_lines
247
+
248
+ final_line_index += raw_attributes_lines.size - 1
249
+
250
+ # Since .foo{bar: 123} => "bar: 123" needs wrapping (Or it would be a syntax error) and
251
+ # .foo{html_attrs('fr-fr')} => html_attrs('fr-fr') doesn't care about being
252
+ # wrapped, we always wrap to place them to a similar offset to how they are in the haml.
253
+ wrap_by = first_line_offset - indent
254
+ if wrap_by < 2
255
+ # Need 2 minimum, for "W(". If we have less, we must indent everything for the difference
256
+ extra_indent = 2 - wrap_by
257
+ HamlLint::Utils.map_after_first!(raw_attributes_lines) do |line|
258
+ HamlLint::Utils.indent(line, extra_indent)
259
+ end
260
+ wrap_by = 2
261
+ end
262
+ raw_attributes_lines = wrap_lines(raw_attributes_lines, wrap_by)
263
+ raw_attributes_lines[0] = ' ' * indent + raw_attributes_lines[0]
264
+
265
+ @ruby_chunks << TagAttributesChunk.new(node, raw_attributes_lines,
266
+ end_marker_indent: indent,
267
+ indent_to_remove: extra_indent)
268
+
269
+ final_line_index
270
+ end
271
+
272
+ # Visiting the script besides tag. The part to the right of the equal sign of
273
+ # lines looking like ` %div= foo(bar)`
274
+ def visit_tag_script(node, line_index:, indent:)
275
+ return if node.script.nil? || node.script.empty?
276
+ # We ignore scripts which are just a comment
277
+ return if node.script[/\S/] == '#'
278
+
279
+ first_line_offset, script_lines = raw_ruby_lines_from_haml(node.script, line_index)
280
+
281
+ if script_lines.nil?
282
+ # This is a string with interpolation after a tag
283
+ # ex: %tag hello #{world}
284
+ # Sadly, the text with interpolation is escaped from the original, but this code
285
+ # needs the original.
286
+ interpolation_original = @document.unescape_interpolation_to_original_cache[node.script]
287
+
288
+ line_start_index = @original_haml_lines[node.line - 1].rindex(interpolation_original)
289
+ add_interpolation_chunks(node, interpolation_original, node.line - 1,
290
+ line_start_index: line_start_index, indent: indent)
291
+ else
292
+ script_lines[0] = "#{' ' * indent}#{script_output_prefix}#{script_lines[0]}"
293
+ indent_delta = script_output_prefix.size - first_line_offset + indent
294
+ HamlLint::Utils.map_after_first!(script_lines) do |line|
295
+ HamlLint::Utils.indent(line, indent_delta)
296
+ end
297
+
298
+ @ruby_chunks << TagScriptChunk.new(node, script_lines,
299
+ haml_line_index: line_index,
300
+ end_marker_indent: indent)
301
+ end
302
+ end
303
+
304
+ # Visiting a HAML filter. Lines looking like ` :javascript` and the following lines
305
+ # that are nested.
306
+ def visit_filter(node)
307
+ # For unknown reasons, haml doesn't escape interpolations in filters.
308
+ # So we can rely on \n to split / get the number of lines.
309
+ filter_name_indent = @original_haml_lines[node.line - 1].index(/\S/)
310
+ if node.filter_type == 'ruby'
311
+ # The indentation in node.text is normalized, so that at least one line
312
+ # is indented by 0.
313
+ lines = node.text.split("\n")
314
+ lines.map! do |line|
315
+ if line !~ /\S/
316
+ # whitespace or empty
317
+ ''
318
+ else
319
+ ' ' * filter_name_indent + line
320
+ end
321
+ end
322
+
323
+ @ruby_chunks << RubyFilterChunk.new(node, lines,
324
+ haml_line_index: node.line, # it's one the next line, no need for -1
325
+ start_marker_indent: filter_name_indent,
326
+ end_marker_indent: filter_name_indent)
327
+ elsif node.text.include?('#')
328
+ name_indentation = ' ' * @original_haml_lines[node.line - 1].index(/\S/)
329
+ # TODO: HAML_LINT_FILTER could be in the string and mess things up
330
+ lines = ["#{name_indentation}#{script_output_prefix}<<~HAML_LINT_FILTER"]
331
+ lines.concat @original_haml_lines[node.line..(node.line + node.text.count("\n") - 1)]
332
+ lines << "#{name_indentation}HAML_LINT_FILTER"
333
+ @ruby_chunks << NonRubyFilterChunk.new(node, lines,
334
+ end_marker_indent: filter_name_indent)
335
+ # Those could be interpolation. We treat them as a here-doc, which is nice since we can
336
+ # keep the indentation as-is.
337
+ else
338
+ @ruby_chunks << PlaceholderMarkerChunk.new(node, 'filter', indent: filter_name_indent,
339
+ nb_lines: 1 + node.text.count("\n"))
340
+ end
341
+ end
342
+
343
+ # Adds chunks for the interpolation within the given code
344
+ def add_interpolation_chunks(node, code, haml_line_index, indent:, line_start_index: 0)
345
+ HamlLint::Utils.handle_interpolation_with_indexes(code) do |scanner, line_index, char_index|
346
+ escapes = scanner[2].size
347
+ next if escapes.odd?
348
+ char = scanner[3] # '{', '@' or '$'
349
+ if Gem::Version.new(Haml::VERSION) >= Gem::Version.new('5') && (char != '{')
350
+ # Before Haml 5, scanner didn't have a scanner[3], it only handled `#{}`
351
+ next
352
+ end
353
+
354
+ start_char_index = char_index
355
+ start_char_index += line_start_index if line_index == 0
356
+
357
+ Haml::Util.balance(scanner, '{', '}', 1)[0][0...-1]
358
+
359
+ # Need to manually get the code now that we have positions so that all whitespace is present,
360
+ # because Haml::Util.balance does a strip...
361
+ interpolated_code = code[char_index...scanner.charpos - 1]
362
+
363
+ interpolated_code = "#{' ' * indent}#{script_output_prefix}#{interpolated_code}"
364
+
365
+ if interpolated_code.include?("\n")
366
+ # We can't correct multiline interpolation.
367
+ # Finding meaningful code to generate and then transfer back is pretty complex
368
+ placeholder_code = interpolated_code.gsub(/\s*\n\s*/, ' ').rstrip
369
+ unless parse_ruby(placeholder_code)
370
+ placeholder_code = interpolated_code.gsub(/\s*\n\s*/, '; ').rstrip
371
+ end
372
+ @ruby_chunks << AdHocChunk.new(node, [placeholder_code],
373
+ haml_line_index: haml_line_index + line_index)
374
+ else
375
+ @ruby_chunks << InterpolationChunk.new(node, [interpolated_code],
376
+ haml_line_index: haml_line_index + line_index,
377
+ start_char_index: start_char_index,
378
+ end_marker_indent: indent)
379
+ end
380
+ end
381
+ end
382
+
383
+ # Returns the raw lines from the haml for the given index.
384
+ # Multiple lines are returned when a line ends with a comma as that is the only
385
+ # time HAMLs allows Ruby lines to be split.
386
+ def raw_lines_of_interest(first_line_index)
387
+ line_index = first_line_index
388
+ lines_of_interest = [@original_haml_lines[line_index]]
389
+
390
+ while @original_haml_lines[line_index].rstrip.end_with?(',')
391
+ line_index += 1
392
+ lines_of_interest << @original_haml_lines[line_index]
393
+ end
394
+
395
+ lines_of_interest
396
+ end
397
+
398
+ # Haml's line-splitting rules (allowed after comma in scripts and attributes) are handled
399
+ # at the parser level, so Haml doesn't provide the code as it is actually formatted in the Haml
400
+ # file. #raw_ruby_from_haml extracts the ruby code as it is exactly in the Haml file.
401
+ # The first and last lines may not be the complete lines from the Haml, only the Ruby parts
402
+ # and the indentation between the first and last list.
403
+ def raw_ruby_lines_from_haml(code, first_line_index)
404
+ stripped_code = code.strip
405
+ return if stripped_code.empty?
406
+
407
+ lines_of_interest = raw_lines_of_interest(first_line_index)
408
+
409
+ if lines_of_interest.size == 1
410
+ index = lines_of_interest.first.index(stripped_code)
411
+ if lines_of_interest.first.include?(stripped_code)
412
+ return [index, [stripped_code]]
413
+ else
414
+ # Sometimes, the code just isn't in the Haml when Haml does transformations to it
415
+ return
416
+ end
417
+ end
418
+
419
+ raw_haml = lines_of_interest.join("\n")
420
+
421
+ # Need the gsub because while multiline scripts are turned into a single line,
422
+ # by haml, multiline tag attributes are not.
423
+ code_parts = stripped_code.gsub("\n", ' ').split(/,\s*/)
424
+
425
+ regexp_code = code_parts.map { |c| Regexp.quote(c) }.join(',\\s*')
426
+ regexp = Regexp.new(regexp_code)
427
+
428
+ match = raw_haml.match(regexp)
429
+ # This can happen when pipes are used as marker for multiline parts, and when tag attributes change lines
430
+ # without ending by a comma. This is quite a can of worm and is probably not too frequent, so for now,
431
+ # these cases are not supported.
432
+ return if match.nil?
433
+
434
+ raw_ruby = match[0]
435
+ ruby_lines = raw_ruby.split("\n")
436
+ first_line_offset = match.begin(0)
437
+
438
+ [first_line_offset, ruby_lines]
439
+ end
440
+
441
+ def wrap_lines(lines, wrap_depth)
442
+ lines = lines.dup
443
+ wrapping_prefix = 'W' * (wrap_depth - 1) + '('
444
+ lines[0] = wrapping_prefix + lines[0]
445
+ lines[-1] = lines[-1] + ')'
446
+ lines
447
+ end
448
+
449
+ # Adds empty lines that follow the lines (Used for scripts), so that
450
+ # RuboCop can receive them too. Some cops are sensitive to empty lines.
451
+ def add_following_empty_lines(node, lines)
452
+ first_line_index = node.line - 1 + lines.size
453
+ extra_lines = []
454
+
455
+ extra_lines << '' while HamlLint::Utils.is_blank_line?(@original_haml_lines[first_line_index + extra_lines.size])
456
+
457
+ if @original_haml_lines[first_line_index + extra_lines.size].nil?
458
+ # Since we reached the end of the document without finding content,
459
+ # then we don't add those lines.
460
+ return lines
461
+ end
462
+
463
+ lines + extra_lines
464
+ end
465
+
466
+ def parse_ruby(source)
467
+ @ruby_parser ||= HamlLint::RubyParser.new
468
+ @ruby_parser.parse(source)
469
+ end
470
+
471
+ def indent_after_line_index(line_index)
472
+ (line_index + 1..@original_haml_lines.size - 1).each do |i|
473
+ indent = @original_haml_lines[i].index(/\S/)
474
+ return indent if indent
475
+ end
476
+ nil
477
+ end
478
+
479
+ def self.start_nesting_after?(code)
480
+ anonymous_block?(code) || start_block_keyword?(code)
481
+ end
482
+
483
+ def self.anonymous_block?(code)
484
+ # Don't start with a comment and end with a `do`
485
+ # Definetly not perfect for the comment handling, but otherwise a more advanced parsing system is needed.
486
+ # Move the comment to its own line if it's annoying.
487
+ code !~ /\A\s*#/ &&
488
+ code =~ /\bdo\s*(\|[^|]*\|\s*)?(#.*)?\z/
489
+ end
490
+
491
+ START_BLOCK_KEYWORDS = %w[if unless case begin for until while].freeze
492
+ def self.start_block_keyword?(code)
493
+ START_BLOCK_KEYWORDS.include?(block_keyword(code))
494
+ end
495
+
496
+ LOOP_KEYWORDS = %w[for until while].freeze
497
+ def self.block_keyword(code)
498
+ # Need to handle 'for'/'while' since regex stolen from HAML parser doesn't
499
+ if (keyword = code[/\A\s*([^\s]+)\s+/, 1]) && LOOP_KEYWORDS.include?(keyword)
500
+ return keyword
501
+ end
502
+
503
+ return unless keyword = code.scan(Haml::Parser::BLOCK_KEYWORD_REGEX)[0]
504
+ keyword[0] || keyword[1]
505
+ end
506
+ end
507
+ end
508
+
509
+ # 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