haml_lint 0.44.0 → 0.46.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 +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