haml_lint 0.47.0 → 0.48.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7a818da3ed1d58db0ad35b864c6c90d8036173109f6609da452da8b496dd1d5
4
- data.tar.gz: ca045650d3373d2fa48d840c684c14b3d46a497f39e91eb9058b1827f3bd74b7
3
+ metadata.gz: 93d2cec8a3ce45f370d5e8d055ed8f9dc5a988e2c56e8e145f50710f04f40a3f
4
+ data.tar.gz: 59a2d93bd2ab520c4fb1288cf85f784d3d20955293b6167f046bffba3f198cac
5
5
  SHA512:
6
- metadata.gz: 0e2d83720d795e4589d534087c4db2be681eeae95cecda6432917345dea5e4c59e8ba7d65633fb2d4cd0ea86eafc0ea89b8fc51f994cc0b7973a50b0fa0a0abd
7
- data.tar.gz: 11fc0ea04b7dae506bb0e264d204bdcdcaa96a65230ade8416ae56094a46f95deb3a1006200919804769dcc1c7adf7ab407bbfdf863355248eea9b0e7858308a
6
+ metadata.gz: be4b59ae43a2cab51fd4148e843177b939da14ee7b0dc40a8ed0d9dd7bbcd12d39d674c1d9c7f19f0a524d912e27fc82b882f6412083eda143c5cf60e218c397
7
+ data.tar.gz: 28075057ee4c955a4966d2561ff77680b9d90c24e647dc83386dab4ebfabc404cbcb22913836f987896cc506244a4f612f74e131d8838c91f1e0db027d0f8cdd
@@ -8,13 +8,14 @@ module HamlLint
8
8
  MESSAGE_FORMAT = %{Do not repeat id "#%s" on the page}
9
9
 
10
10
  def visit_root(_node)
11
- @id_map = Hash.new { |hash, key| hash[key] = [] }
11
+ @id_map = {}
12
12
  end
13
13
 
14
14
  def visit_tag(node)
15
15
  id = node.tag_id
16
16
  return unless id && !id.empty?
17
17
 
18
+ id_map[id] ||= []
18
19
  nodes = (id_map[id] << node)
19
20
  case nodes.size
20
21
  when 1 then nil
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'haml_lint/ruby_extractor'
4
3
  require 'rubocop'
5
4
  require 'tempfile'
6
5
 
@@ -11,6 +11,12 @@ module HamlLint::RubyExtraction
11
11
 
12
12
  attr_reader :script_output_prefix
13
13
 
14
+ HAML_PARSER_INSTANCE = if Haml::VERSION >= '5.0.0'
15
+ ::Haml::Parser.new({})
16
+ else
17
+ ::Haml::Parser.new('', {})
18
+ end
19
+
14
20
  def initialize(document, script_output_prefix:)
15
21
  @document = document
16
22
  @script_output_prefix = script_output_prefix
@@ -19,13 +25,18 @@ module HamlLint::RubyExtraction
19
25
  def extract
20
26
  raise 'Already extracted' if @ruby_chunks
21
27
 
22
- @ruby_chunks = []
23
- @original_haml_lines = @document.source_lines
28
+ prepare_extract
24
29
 
25
30
  visit(@document.tree)
26
31
  @ruby_chunks
27
32
  end
28
33
 
34
+ # Useful for tests
35
+ def prepare_extract
36
+ @ruby_chunks = []
37
+ @original_haml_lines = @document.source_lines
38
+ end
39
+
29
40
  def visit_root(_node)
30
41
  yield # Collect lines of code from children
31
42
  end
@@ -71,33 +82,44 @@ module HamlLint::RubyExtraction
71
82
  # Visiting comments which are output to HTML. Lines looking like
72
83
  # ` / This will be in the HTML source!`
73
84
  def visit_comment(node)
74
- lines = raw_lines_of_interest(node.line - 1)
75
- indent = lines.first.index(/\S/)
85
+ line = @original_haml_lines[node.line - 1]
86
+ indent = line.index(/\S/)
76
87
  @ruby_chunks << PlaceholderMarkerChunk.new(node, 'comment', indent: indent)
77
88
  end
78
89
 
79
90
  # Visit a script which outputs. Lines looking like ` = foo`
80
91
  def visit_script(node, &block)
81
- lines = raw_lines_of_interest(node.line - 1)
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
82
98
 
83
- if lines.first !~ /\A\s*[-=]/
99
+ match = raw_first_line.match(/\A\s*(=|!=|&=)(?!=)/)
100
+ unless match
84
101
  # The line doesn't start with a - or a =, this is actually a "plain"
85
102
  # that contains interpolation.
86
- indent = lines.first.index(/\S/)
103
+ indent = raw_first_line.index(/\S/)
87
104
  @ruby_chunks << PlaceholderMarkerChunk.new(node, 'interpolation', indent: indent)
88
- add_interpolation_chunks(node, lines.first, node.line - 1, indent: indent)
105
+ add_interpolation_chunks(node, raw_first_line, node.line - 1, indent: indent)
89
106
  return
90
107
  end
91
108
 
92
- lines[0] = lines[0].sub(/(=[ \t]?)/, '')
109
+ script_prefix = match[1]
110
+ _first_line_offset, lines = extract_raw_ruby_lines(node.script, node.line - 1)
111
+ # We want the actual indentation and prefix for the first line
112
+ first_line = lines[0] = @original_haml_lines[node.line - 1].rstrip
113
+ process_multiline!(first_line)
114
+
115
+ lines[0] = lines[0].sub(/(#{script_prefix}[ \t]?)/, '')
93
116
  line_indentation = Regexp.last_match(1).size
94
117
 
95
118
  raw_code = lines.join("\n")
96
119
 
97
120
  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}")
121
+ # a "=" script that only contains a comment... No need for the "HL.out = " prefix,
122
+ # just treat it as comment which will turn into a "-" comment
101
123
  else
102
124
  lines[0] = HamlLint::Utils.insert_after_indentation(lines[0], script_output_prefix)
103
125
  end
@@ -124,14 +146,25 @@ module HamlLint::RubyExtraction
124
146
  # By forcing this to start a chunk, there will be extra placeholders which
125
147
  # blocks rubocop from merging the lines.
126
148
  must_start_chunk = true
149
+ elsif script_prefix != '='
150
+ # In the few cases where &= and != are used to start the script,
151
+ # We need to remember and put it back in the final HAML. Fusing scripts together
152
+ # would make that basically impossible. Instead, a script has a "first_output_prefix"
153
+ # field for this specific case
154
+ must_start_chunk = true
127
155
  end
128
156
 
129
- finish_visit_any_script(node, lines, raw_code: raw_code, must_start_chunk: must_start_chunk, &block)
157
+ finish_visit_any_script(node, lines, raw_code: raw_code, must_start_chunk: must_start_chunk,
158
+ first_output_prefix: script_prefix, &block)
130
159
  end
131
160
 
132
161
  # Visit a script which doesn't output. Lines looking like ` - foo`
133
162
  def visit_silent_script(node, &block)
134
- lines = raw_lines_of_interest(node.line - 1)
163
+ _first_line_offset, lines = extract_raw_ruby_lines(node.script, node.line - 1)
164
+ # We want the actual indentation and prefix for the first line
165
+ first_line = lines[0] = @original_haml_lines[node.line - 1].rstrip
166
+ process_multiline!(first_line)
167
+
135
168
  lines[0] = lines[0].sub(/(-[ \t]?)/, '')
136
169
  nb_to_deindent = Regexp.last_match(1).size
137
170
 
@@ -145,7 +178,7 @@ module HamlLint::RubyExtraction
145
178
  # Code common to both silent and outputting scripts
146
179
  #
147
180
  # 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)
181
+ def finish_visit_any_script(node, lines, raw_code: nil, must_start_chunk: false, first_output_prefix: '=')
149
182
  raw_code ||= lines.join("\n")
150
183
  start_nesting = self.class.start_nesting_after?(raw_code)
151
184
 
@@ -158,7 +191,8 @@ module HamlLint::RubyExtraction
158
191
  @ruby_chunks << ScriptChunk.new(node, lines,
159
192
  end_marker_indent: indent_after,
160
193
  must_start_chunk: must_start_chunk,
161
- previous_chunk: @ruby_chunks.last)
194
+ previous_chunk: @ruby_chunks.last,
195
+ first_output_haml_prefix: first_output_prefix)
162
196
 
163
197
  yield
164
198
 
@@ -184,26 +218,32 @@ module HamlLint::RubyExtraction
184
218
  def visit_tag(node)
185
219
  indent = @original_haml_lines[node.line - 1].index(/\S/)
186
220
 
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
221
+ # We don't want to use a block because assignments in a block are local to that block,
222
+ # so the semantics of the extracted ruby would be different from the one generated by
223
+ # Haml. Those differences can make some cops, such as UselessAssignment, have false
224
+ # positives
225
+ code = 'begin'
226
+ @ruby_chunks << AdHocChunk.new(node,
227
+ [' ' * indent + code])
228
+ indent += 2
198
229
 
199
- @ruby_chunks << PlaceholderMarkerChunk.new(node, 'tag', indent: indent)
230
+ tag_chunk = PlaceholderMarkerChunk.new(node, 'tag', indent: indent)
231
+ @ruby_chunks << tag_chunk
200
232
 
201
233
  current_line_index = visit_tag_attributes(node, indent: indent)
202
234
  visit_tag_script(node, line_index: current_line_index, indent: indent)
203
235
 
204
- if has_children
205
- yield
206
- indent -= 2
236
+ yield
237
+
238
+ indent -= 2
239
+
240
+ if @ruby_chunks.last.equal?(tag_chunk)
241
+ # So there is nothing going "in" the tag, remove the wrapping "begin" and replace the PlaceholderMarkerChunk
242
+ # by one less indented
243
+ @ruby_chunks.pop
244
+ @ruby_chunks.pop
245
+ @ruby_chunks << PlaceholderMarkerChunk.new(node, 'tag', indent: indent)
246
+ else
207
247
  @ruby_chunks << AdHocChunk.new(node,
208
248
  [' ' * indent + 'ensure', ' ' * indent + ' HL.noop', ' ' * indent + 'end'],
209
249
  haml_line_index: @ruby_chunks.last.haml_end_line_index)
@@ -222,11 +262,16 @@ module HamlLint::RubyExtraction
222
262
  attributes_code = additional_attributes.first
223
263
  if !attributes_code && node.hash_attributes? && node.dynamic_attributes_sources.empty?
224
264
  # 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'}.
265
+ # The code we get for the latter is {:bar => '123'}.
226
266
  # We normalize it by removing the { } so that it matches wha we normally get
227
267
  attributes_code = node.dynamic_attributes_source[:hash][1...-1]
228
268
  end
229
269
 
270
+ if attributes_code&.start_with?('{')
271
+ # Looks like the .foo(bar = 123) case. Ignoring.
272
+ attributes_code = nil
273
+ end
274
+
230
275
  return final_line_index unless attributes_code
231
276
  # Attributes have different ways to be given to us:
232
277
  # .foo{bar: 123} => "bar: 123"
@@ -235,14 +280,13 @@ module HamlLint::RubyExtraction
235
280
  # .foo(bar = 123) => '{"bar" => 123,}'
236
281
  # .foo{html_attrs('fr-fr')} => html_attrs('fr-fr')
237
282
  #
238
- # The (bar = 123) case is extra painful to autocorrect (so is ignored).
283
+ # The (bar = 123) case is extra painful to autocorrect (so is ignored up there).
239
284
  # #raw_ruby_from_haml will "detect" this case by not finding the code.
240
285
  #
241
286
  # We wrap the result in a method to have a valid syntax for all 3 ways
242
287
  # without having to differentiate them.
243
- first_line_offset, raw_attributes_lines = raw_ruby_lines_from_haml(attributes_code,
244
- node.line - 1)
245
-
288
+ first_line_offset, raw_attributes_lines = extract_raw_tag_attributes_ruby_lines(attributes_code,
289
+ node.line - 1)
246
290
  return final_line_index unless raw_attributes_lines
247
291
 
248
292
  final_line_index += raw_attributes_lines.size - 1
@@ -276,7 +320,7 @@ module HamlLint::RubyExtraction
276
320
  # We ignore scripts which are just a comment
277
321
  return if node.script[/\S/] == '#'
278
322
 
279
- first_line_offset, script_lines = raw_ruby_lines_from_haml(node.script, line_index)
323
+ first_line_offset, script_lines = extract_raw_ruby_lines(node.script, line_index)
280
324
 
281
325
  if script_lines.nil?
282
326
  # This is a string with interpolation after a tag
@@ -380,52 +424,87 @@ module HamlLint::RubyExtraction
380
424
  end
381
425
  end
382
426
 
427
+ def process_multiline!(line)
428
+ if HAML_PARSER_INSTANCE.send(:is_multiline?, line)
429
+ line.chop!.rstrip!
430
+ true
431
+ else
432
+ false
433
+ end
434
+ end
435
+
383
436
  # Returns the raw lines from the haml for the given index.
384
437
  # Multiple lines are returned when a line ends with a comma as that is the only
385
438
  # 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
439
 
398
440
  # Haml's line-splitting rules (allowed after comma in scripts and attributes) are handled
399
441
  # at the parser level, so Haml doesn't provide the code as it is actually formatted in the Haml
400
442
  # file. #raw_ruby_from_haml extracts the ruby code as it is exactly in the Haml file.
401
443
  # The first and last lines may not be the complete lines from the Haml, only the Ruby parts
402
444
  # 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
445
 
407
- lines_of_interest = raw_lines_of_interest(first_line_index)
446
+ # HAML transforms the ruby code in many ways as it parses a document. Often removing lines and/or
447
+ # indentation. This is quite annoying for us since we want the exact layout of the code to analyze it.
448
+ #
449
+ # This function receives the code as haml provides it and the line where it starts. It returns
450
+ # the actual code as it is in the haml file, keeping breaks and indentation for the following lines.
451
+ # In addition, the start position of the code in the first line.
452
+ #
453
+ # The rules for handling multiline code in HAML are as follow:
454
+ # * if the line being processed ends with a space and a pipe, then append to the line (without
455
+ # newlines) every following lines that also end with a space and a pipe. This means the last line of
456
+ # the "block" also needs a pipe at the end.
457
+ # * after processing the pipes, when dealing with ruby code (and not in tag attributes' hash), if the line
458
+ # (which maybe span across multiple lines) ends with a comma, add the next line to the current piece of code.
459
+ #
460
+ # @return [first_line_offset, ruby_lines]
461
+ def extract_raw_ruby_lines(haml_processed_ruby_code, first_line_index)
462
+ haml_processed_ruby_code = haml_processed_ruby_code.strip
463
+ first_line = @original_haml_lines[first_line_index]
408
464
 
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
465
+ char_index = first_line.index(haml_processed_ruby_code)
466
+
467
+ if char_index
468
+ return [char_index, [haml_processed_ruby_code]]
469
+ end
470
+
471
+ cur_line_index = first_line_index
472
+ cur_line = first_line.rstrip
473
+ lines = []
474
+
475
+ # The pipes must also be on the last line of the multi-line section
476
+ while cur_line && process_multiline!(cur_line)
477
+ lines << cur_line
478
+ cur_line_index += 1
479
+ cur_line = @original_haml_lines[cur_line_index].rstrip
480
+ end
481
+
482
+ if lines.empty?
483
+ lines << cur_line
484
+ else
485
+ # The pipes must also be on the last line of the multi-line section. So cur_line is not the next line.
486
+ # We want to go back to check for commas
487
+ cur_line_index -= 1
488
+ cur_line = lines.last
417
489
  end
418
490
 
419
- raw_haml = lines_of_interest.join("\n")
491
+ while HAML_PARSER_INSTANCE.send(:is_ruby_multiline?, cur_line)
492
+ cur_line_index += 1
493
+ cur_line = @original_haml_lines[cur_line_index].rstrip
494
+ lines << cur_line
495
+ end
496
+
497
+ joined_lines = lines.join("\n")
498
+
499
+ if haml_processed_ruby_code.include?("\n")
500
+ haml_processed_ruby_code = haml_processed_ruby_code.gsub("\n", ' ')
501
+ end
420
502
 
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*/)
503
+ haml_processed_ruby_code.split(/[, ]/)
424
504
 
425
- regexp_code = code_parts.map { |c| Regexp.quote(c) }.join(',\\s*')
426
- regexp = Regexp.new(regexp_code)
505
+ regexp = HamlLint::Utils.regexp_for_parts(haml_processed_ruby_code.split(/,\s*|\s+/), '(?:,\\s*|\\s+)')
427
506
 
428
- match = raw_haml.match(regexp)
507
+ match = joined_lines.match(regexp)
429
508
  # This can happen when pipes are used as marker for multiline parts, and when tag attributes change lines
430
509
  # without ending by a comma. This is quite a can of worm and is probably not too frequent, so for now,
431
510
  # these cases are not supported.
@@ -438,6 +517,48 @@ module HamlLint::RubyExtraction
438
517
  [first_line_offset, ruby_lines]
439
518
  end
440
519
 
520
+ # Tag attributes actually handle multiline differently than scripts.
521
+ # The basic system basically keeps considering more lines until it meets the closing braces, but still
522
+ # processes pipes too (same as extract_raw_ruby_lines).
523
+ def extract_raw_tag_attributes_ruby_lines(haml_processed_ruby_code, first_line_index)
524
+ haml_processed_ruby_code = haml_processed_ruby_code.strip
525
+ first_line = @original_haml_lines[first_line_index]
526
+
527
+ char_index = first_line.index(haml_processed_ruby_code)
528
+
529
+ if char_index
530
+ return [char_index, [haml_processed_ruby_code]]
531
+ end
532
+
533
+ min_non_white_chars_to_add = haml_processed_ruby_code.scan(/\S/).size
534
+
535
+ regexp = HamlLint::Utils.regexp_for_parts(haml_processed_ruby_code.split(/\s+/), '\\s+')
536
+
537
+ joined_lines = first_line.rstrip
538
+ process_multiline!(joined_lines)
539
+
540
+ cur_line_index = first_line_index + 1
541
+ while @original_haml_lines[cur_line_index] && min_non_white_chars_to_add > 0
542
+ new_line = @original_haml_lines[cur_line_index].rstrip
543
+ process_multiline!(new_line)
544
+
545
+ min_non_white_chars_to_add -= new_line.scan(/\S/).size
546
+ joined_lines << "\n"
547
+ joined_lines << new_line
548
+ cur_line_index += 1
549
+ end
550
+
551
+ match = joined_lines.match(regexp)
552
+
553
+ return if match.nil?
554
+
555
+ first_line_offset = match.begin(0)
556
+ raw_ruby = match[0]
557
+ ruby_lines = raw_ruby.split("\n")
558
+
559
+ [first_line_offset, ruby_lines]
560
+ end
561
+
441
562
  def wrap_lines(lines, wrap_depth)
442
563
  lines = lines.dup
443
564
  wrapping_prefix = 'W' * (wrap_depth - 1) + '('
@@ -15,26 +15,6 @@ module HamlLint::RubyExtraction
15
15
  HamlCommentChunk.new(node, @ruby_lines + following_chunk.ruby_lines, end_marker_indent: end_marker_indent)
16
16
  end
17
17
 
18
- def fuse_script_chunk(following_chunk)
19
- return if following_chunk.end_marker_indent.nil?
20
- return if following_chunk.must_start_chunk
21
-
22
- nb_blank_lines_between = following_chunk.haml_line_index - haml_line_index - nb_haml_lines
23
- blank_lines = nb_blank_lines_between > 0 ? [''] * nb_blank_lines_between : []
24
- new_lines = @ruby_lines + blank_lines + following_chunk.ruby_lines
25
-
26
- source_map_skips = @skip_line_indexes_in_source_map
27
- source_map_skips.concat(following_chunk.skip_line_indexes_in_source_map
28
- .map { |i| i + @ruby_lines.size })
29
-
30
- ScriptChunk.new(node,
31
- new_lines,
32
- haml_line_index: haml_line_index,
33
- skip_line_indexes_in_source_map: source_map_skips,
34
- end_marker_indent: following_chunk.end_marker_indent,
35
- previous_chunk: previous_chunk)
36
- end
37
-
38
18
  def transfer_correction_logic(_coordinator, to_ruby_lines, haml_lines)
39
19
  if to_ruby_lines.empty?
40
20
  haml_lines.slice!(@haml_line_index..haml_end_line_index)
@@ -1,11 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'ripper'
4
+
3
5
  module HamlLint::RubyExtraction
4
6
  # Chunk for handling outputting and silent scripts, so ` = foo` and ` - bar`
5
7
  # Does NOT handle a script beside a tag (ex: `%div= spam`)
6
8
  class ScriptChunk < BaseChunk
7
9
  MID_BLOCK_KEYWORDS = %w[else elsif when rescue ensure].freeze
8
10
 
11
+ # @return [String] The prefix for the first outputting string of this script. (One of = != &=)
12
+ # The outputting scripts after the first are always with =
13
+ attr_reader :first_output_haml_prefix
14
+
9
15
  # @return [Boolean] true if this ScriptChunk must be at the beginning of a chunk.
10
16
  # This blocks this ScriptChunk from being fused to a ScriptChunk that is before it.
11
17
  # Needed to handle some patterns of outputting script.
@@ -19,12 +25,13 @@ module HamlLint::RubyExtraction
19
25
  # our starting marker must be indented.
20
26
  attr_reader :previous_chunk
21
27
 
22
- def initialize(*args, previous_chunk:, must_start_chunk: false,
23
- skip_line_indexes_in_source_map: [], **kwargs)
28
+ def initialize(*args, previous_chunk:, must_start_chunk: false, # rubocop:disable Metrics/ParameterLists
29
+ skip_line_indexes_in_source_map: [], first_output_haml_prefix: '=', **kwargs)
24
30
  super(*args, **kwargs)
25
31
  @must_start_chunk = must_start_chunk
26
32
  @skip_line_indexes_in_source_map = skip_line_indexes_in_source_map
27
33
  @previous_chunk = previous_chunk
34
+ @first_output_haml_prefix = first_output_haml_prefix
28
35
  end
29
36
 
30
37
  def fuse(following_chunk)
@@ -53,7 +60,8 @@ module HamlLint::RubyExtraction
53
60
  haml_line_index: haml_line_index,
54
61
  skip_line_indexes_in_source_map: source_map_skips,
55
62
  end_marker_indent: following_chunk.end_marker_indent,
56
- previous_chunk: previous_chunk)
63
+ previous_chunk: previous_chunk,
64
+ first_output_haml_prefix: @first_output_haml_prefix)
57
65
  end
58
66
 
59
67
  def fuse_implicit_end(following_chunk)
@@ -70,7 +78,8 @@ module HamlLint::RubyExtraction
70
78
  haml_line_index: haml_line_index,
71
79
  skip_line_indexes_in_source_map: source_map_skips,
72
80
  end_marker_indent: following_chunk.end_marker_indent,
73
- previous_chunk: previous_chunk)
81
+ previous_chunk: previous_chunk,
82
+ first_output_haml_prefix: @first_output_haml_prefix)
74
83
  end
75
84
 
76
85
  def start_marker_indent
@@ -79,54 +88,157 @@ module HamlLint::RubyExtraction
79
88
  [default_indent, previous_chunk&.end_marker_indent || previous_chunk&.start_marker_indent].compact.max
80
89
  end
81
90
 
82
- def transfer_correction_logic(coordinator, to_ruby_lines, haml_lines) # rubocop:disable Metrics
91
+ def transfer_correction_logic(coordinator, to_ruby_lines, haml_lines)
92
+ to_haml_lines = self.class.format_ruby_lines_to_haml_lines(
93
+ to_ruby_lines,
94
+ script_output_ruby_prefix: coordinator.script_output_prefix,
95
+ first_output_haml_prefix: @first_output_haml_prefix
96
+ )
97
+
98
+ haml_lines[@haml_line_index..haml_end_line_index] = to_haml_lines
99
+ end
100
+
101
+ ALLOW_EXPRESSION_AFTER_LINE_ENDING_WITH = %w[else begin ensure].freeze
102
+
103
+ def self.format_ruby_lines_to_haml_lines(to_ruby_lines, script_output_ruby_prefix:, first_output_haml_prefix: '=') # rubocop:disable Metrics
83
104
  to_ruby_lines.reject! { |l| l.strip == 'end' }
105
+ return [] if to_ruby_lines.empty?
84
106
 
85
- output_comment_prefix = ' ' + coordinator.script_output_prefix.rstrip
86
- to_ruby_lines.map! do |line|
87
- if line.lstrip.start_with?('#' + output_comment_prefix)
88
- line = line.dup
89
- comment_index = line.index('#')
90
- removal_start_index = comment_index + 1
91
- removal_end_index = removal_start_index + output_comment_prefix.size
92
- line[removal_start_index...removal_end_index] = ''
93
- # It will be removed again below, but will know its suposed to be a =
94
- line.insert(comment_index, coordinator.script_output_prefix)
95
- end
96
- line
97
- end
107
+ statement_start_line_indexes = find_statement_start_line_indexes(to_ruby_lines)
98
108
 
99
109
  continued_line_indent_delta = 2
100
110
 
111
+ cur_line_start_index = nil
112
+ line_start_indexes_that_need_pipes = []
113
+ haml_output_prefix = first_output_haml_prefix
101
114
  to_haml_lines = to_ruby_lines.map.with_index do |line, i|
102
115
  if line !~ /\S/
103
116
  # whitespace or empty lines, we don't want any indentation
104
117
  ''
105
- elsif line_starts_script?(to_ruby_lines, i)
118
+ elsif statement_start_line_indexes.include?(i)
119
+ cur_line_start_index = i
106
120
  code_start = line.index(/\S/)
107
- if line[code_start..].start_with?(coordinator.script_output_prefix)
108
- line = line.sub(coordinator.script_output_prefix, '')
109
- continued_line_indent_delta = 2 - coordinator.script_output_prefix.size
110
- "#{line[0...code_start]}= #{line[code_start..]}"
121
+ if line[code_start..].start_with?(script_output_ruby_prefix)
122
+ line = line.sub(script_output_ruby_prefix, '')
123
+ # The line may have been too indented because of the "HL.out = " prefix
124
+ continued_line_indent_delta = 2 - script_output_ruby_prefix.size
125
+ new_line = "#{line[0...code_start]}#{haml_output_prefix} #{line[code_start..]}"
126
+ haml_output_prefix = '='
127
+ new_line
111
128
  else
112
129
  continued_line_indent_delta = 2
113
130
  "#{line[0...code_start]}- #{line[code_start..]}"
114
131
  end
115
132
  else
133
+ unless to_ruby_lines[i - 1].end_with?(',')
134
+ line_start_indexes_that_need_pipes << cur_line_start_index
135
+ end
136
+
116
137
  HamlLint::Utils.indent(line, continued_line_indent_delta)
117
138
  end
118
139
  end
119
140
 
120
- haml_lines[@haml_line_index..haml_end_line_index] = to_haml_lines
121
- end
141
+ # Starting from the end because we need to add newlines when 2 groups of lines need pipes, so that they are
142
+ # separate.
143
+ line_start_indexes_that_need_pipes.reverse_each do |cur_line_i|
144
+ loop do
145
+ cur_line = to_haml_lines[cur_line_i]
146
+ break if cur_line.nil? || cur_line.empty?
147
+ to_haml_lines[cur_line_i] = cur_line + ' |'
148
+ cur_line_i += 1
149
+
150
+ break if statement_start_line_indexes.include?(cur_line_i)
151
+ end
152
+
153
+ next_line = to_haml_lines[cur_line_i]
154
+ if next_line && HamlLint::RubyExtraction::ChunkExtractor::HAML_PARSER_INSTANCE.send(:is_multiline?, next_line)
155
+ to_haml_lines.insert(cur_line_i, '')
156
+ end
157
+ end
122
158
 
123
- def unfinished_script_line?(lines, line_index)
124
- !!lines[line_index][/,[ \t]*\z/]
159
+ to_haml_lines
125
160
  end
126
161
 
127
- def line_starts_script?(lines, line_index)
128
- return true if line_index == 0
129
- !unfinished_script_line?(lines, line_index - 1)
162
+ def self.find_statement_start_line_indexes(to_ruby_lines) # rubocop:disable Metrics
163
+ if to_ruby_lines.size == 1
164
+ if to_ruby_lines.first[/\S/]
165
+ return [0]
166
+ else
167
+ return []
168
+ end
169
+ end
170
+ statement_start_line_indexes = [] # 0-indexed
171
+ allow_expression_after_line_number = 0 # 1-indexed
172
+ last_do_keyword_line_number = nil # 1-indexed, like Ripper.lex
173
+
174
+ to_ruby_string = to_ruby_lines.join("\n")
175
+ if RUBY_VERSION < '3.1'
176
+ # Ruby 2.6's Ripper has issues when it encounters a else, when, elsif without a matching if/case before.
177
+ # It literally stop lexing at that point without any error.
178
+ # Ex from 2.7.8:
179
+ # require 'ripper'
180
+ # Ripper.lex("a\nelse\nb")
181
+ # #=> [[[1, 0], :on_ident, "a", CMDARG], [[1, 1], :on_nl, "\n", BEG], [[2, 0], :on_kw, "else", BEG]]
182
+ # So we add enough ifs to last quite a few layer. Hopefully enough for all needs. To clarify, there would need
183
+ # as many "end" keyword in a single ScriptChunk followed by one of the problematic keyword for the problem
184
+ # to show up.
185
+ # Considering that a `end` without anything else on the line is removed from to_ruby_lines before getting here
186
+ # (in format_ruby_lines_to_haml_lines), 10 ifs should be plenty.
187
+ to_ruby_string = ('if a;' * 10) + to_ruby_string
188
+ end
189
+
190
+ last_line_number_seen = nil
191
+ Ripper.lex(to_ruby_string).each do |start_loc, token, str|
192
+ last_line_number_seen = start_loc[0]
193
+ if token == :on_nl
194
+ # :on_nl happens when we have a meaningful line change.
195
+ allow_expression_after_line_number = start_loc[0]
196
+ next
197
+ elsif token == :on_ignored_nl
198
+ # :on_ignored_nl happens for newlines within an expression, or consecutive newlines..
199
+ # and some cases we care about such as a newline after the pipes after arguments of a block
200
+ if last_do_keyword_line_number == start_loc[0]
201
+ # When starting a block, Ripper.lex gives :on_ignored_nl
202
+ allow_expression_after_line_number = start_loc[0]
203
+ end
204
+ next
205
+ end
206
+
207
+ if allow_expression_after_line_number && str[/\S/]
208
+ if allow_expression_after_line_number < start_loc[0]
209
+ # Ripper.lex returns line numbers 1-indexed, we want 0-indexed
210
+ statement_start_line_indexes << start_loc[0] - 1
211
+ end
212
+ allow_expression_after_line_number = nil
213
+ end
214
+
215
+ if token == :on_comment
216
+ # :on_comment contain its own newline at the end of the content
217
+ allow_expression_after_line_number = start_loc[0]
218
+ elsif token == :on_kw
219
+ if str == 'do'
220
+ # Because of the possible arguments for the block, we can't simply set is_between_expressions to true
221
+ last_do_keyword_line_number = start_loc[0]
222
+ elsif ALLOW_EXPRESSION_AFTER_LINE_ENDING_WITH.include?(str)
223
+ allow_expression_after_line_number = start_loc[0]
224
+ end
225
+ end
226
+ end
227
+
228
+ # number is 1-indexed, and we want the line after it, so that's great
229
+ if last_line_number_seen < to_ruby_lines.size && to_ruby_lines[last_line_number_seen..].any? { |l| l[/\S/] }
230
+ # There are non-empty lines after the last line Ripper showed us, that's a problem!
231
+ msg = +'It seems Ripper did not properly process some source code. Please make sure you are on the '
232
+ msg << 'latest Haml-Lint version, then create an issue at '
233
+ msg << "https://github.com/sds/haml-lint/issues and include the following information:\n"
234
+ msg << "Ruby version: #{RUBY_VERSION}\n"
235
+ msg << "Haml-Lint version: #{HamlLint::VERSION}\n"
236
+ msg << "HAML version: #{Haml::VERSION}\n"
237
+ msg << "problematic source code:\n```\n#{to_ruby_lines.join("\n")}\n```"
238
+ raise msg
239
+ end
240
+
241
+ statement_start_line_indexes
130
242
  end
131
243
  end
132
244
  end
@@ -8,19 +8,43 @@ module HamlLint::RubyExtraction
8
8
  @indent_to_remove = indent_to_remove
9
9
  end
10
10
 
11
- def transfer_correction_logic(_coordinator, to_ruby_lines, haml_lines)
11
+ def transfer_correction_logic(_coordinator, to_ruby_lines, haml_lines) # rubocop:disable Metrics
12
+ return if @ruby_lines == to_ruby_lines
13
+
12
14
  affected_haml_lines = haml_lines[@haml_line_index..haml_end_line_index]
13
15
 
14
16
  affected_haml = affected_haml_lines.join("\n")
15
17
 
16
18
  from_ruby = unwrap(@ruby_lines).join("\n")
19
+
20
+ if to_ruby_lines.size > 1
21
+ min_indent = to_ruby_lines.first[/^\s*/]
22
+ to_ruby_lines.each.with_index do |line, i|
23
+ next if i == 0
24
+ next if line.start_with?(min_indent)
25
+ to_ruby_lines[i] = "#{min_indent}#{line.lstrip}"
26
+ end
27
+ end
28
+
17
29
  to_ruby = unwrap(to_ruby_lines).join("\n")
18
30
 
19
31
  affected_start_index = affected_haml.index(from_ruby)
20
- affected_end_index = affected_start_index + from_ruby.size
32
+ if affected_start_index
33
+ affected_end_index = affected_start_index + from_ruby.size
34
+ else
35
+ regexp = HamlLint::Utils.regexp_for_parts(from_ruby.split("\n"), "(?:\s*\\|?\n)")
36
+ mo = affected_haml.match(regexp)
37
+ affected_start_index = mo.begin(0)
38
+ affected_end_index = mo.end(0)
39
+ end
40
+
21
41
  affected_haml[affected_start_index...affected_end_index] = to_ruby
22
42
 
23
43
  haml_lines[@haml_line_index..haml_end_line_index] = affected_haml.split("\n")
44
+
45
+ if haml_lines[haml_end_line_index].end_with?(' |')
46
+ haml_lines[haml_end_line_index].chop!.rstrip!
47
+ end
24
48
  end
25
49
 
26
50
  def unwrap(lines)
@@ -276,5 +276,10 @@ module HamlLint
276
276
  ensure
277
277
  $stdin = original_stdin
278
278
  end
279
+
280
+ def regexp_for_parts(parts, join_regexp)
281
+ regexp_code = parts.map { |c| Regexp.quote(c) }.join(join_regexp)
282
+ Regexp.new(regexp_code)
283
+ end
279
284
  end
280
285
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Defines the gem version.
4
4
  module HamlLint
5
- VERSION = '0.47.0'
5
+ VERSION = '0.48.0'
6
6
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: haml_lint
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.47.0
4
+ version: 0.48.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shane da Silva
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-07-05 00:00:00.000000000 Z
11
+ date: 2023-07-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: haml
@@ -179,7 +179,6 @@ files:
179
179
  - lib/haml_lint/ruby_extraction/script_chunk.rb
180
180
  - lib/haml_lint/ruby_extraction/tag_attributes_chunk.rb
181
181
  - lib/haml_lint/ruby_extraction/tag_script_chunk.rb
182
- - lib/haml_lint/ruby_extractor.rb
183
182
  - lib/haml_lint/ruby_parser.rb
184
183
  - lib/haml_lint/runner.rb
185
184
  - lib/haml_lint/severity.rb
@@ -221,7 +220,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
221
220
  - !ruby/object:Gem::Version
222
221
  version: '0'
223
222
  requirements: []
224
- rubygems_version: 3.0.3.1
223
+ rubygems_version: 3.1.6
225
224
  signing_key:
226
225
  specification_version: 4
227
226
  summary: HAML lint tool
@@ -1,224 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module HamlLint
4
- # Utility class for extracting Ruby script from a HAML file that can then be
5
- # linted with a Ruby linter (i.e. is "legal" Ruby). The goal is to turn this:
6
- #
7
- # - if signed_in?(viewer)
8
- # %span Stuff
9
- # = link_to 'Sign Out', sign_out_path
10
- # - else
11
- # .some-class{ class: my_method }= my_method
12
- # = link_to 'Sign In', sign_in_path
13
- #
14
- # into this:
15
- #
16
- # if signed_in?(viewer)
17
- # link_to 'Sign Out', sign_out_path
18
- # else
19
- # { class: my_method }
20
- # my_method
21
- # link_to 'Sign In', sign_in_path
22
- # end
23
- #
24
- # The translation won't be perfect, and won't make any real sense, but the
25
- # relationship between variable declarations/uses and the flow control graph
26
- # will remain intact.
27
- class RubyExtractor
28
- include HamlVisitor
29
-
30
- # Stores the extracted source and a map of lines of generated source to the
31
- # original source that created them.
32
- #
33
- # @attr_reader source [String] generated source code
34
- # @attr_reader source_map [Hash] map of line numbers from generated source
35
- # to original source line number
36
- RubySource = Struct.new(:source, :source_map)
37
-
38
- # Extracts Ruby code from Sexp representing a Slim document.
39
- #
40
- # @param document [HamlLint::Document]
41
- # @return [HamlLint::RubyExtractor::RubySource]
42
- def extract(document)
43
- visit(document.tree)
44
- RubySource.new(@source_lines.join("\n"), @source_map)
45
- end
46
-
47
- def visit_root(_node)
48
- @source_lines = []
49
- @source_map = {}
50
- @line_count = 0
51
- @indent_level = 0
52
- @output_count = 0
53
-
54
- yield # Collect lines of code from children
55
- end
56
-
57
- def visit_plain(node)
58
- # Don't output the text, as we don't want to have to deal with any RuboCop
59
- # cops regarding StringQuotes or AsciiComments, and it's not important to
60
- # overall document anyway.
61
- add_dummy_puts(node)
62
- end
63
-
64
- def visit_tag(node)
65
- additional_attributes = node.dynamic_attributes_sources
66
-
67
- # Include dummy references to code executed in attributes list
68
- # (this forces a "use" of a variable to prevent "assigned but unused
69
- # variable" lints)
70
- additional_attributes.each do |attributes_code|
71
- # Normalize by removing excess whitespace to avoid format lints
72
- attributes_code = attributes_code.gsub(/\s*\n\s*/, "\n").strip
73
-
74
- # Attributes can either be a method call or a literal hash, so wrap it
75
- # in a method call itself in order to avoid having to differentiate the
76
- # two. Use the tag name for the method to differentiate different tag types
77
- # for RuboCop and prevent erroneous warnings.
78
- add_line("#{node.tag_name}(#{attributes_code})", node)
79
- end
80
-
81
- check_tag_static_hash_source(node)
82
-
83
- # We add a dummy puts statement to represent the tag name being output.
84
- # This prevents some erroneous RuboCop warnings.
85
- add_dummy_puts(node, node.tag_name)
86
-
87
- code = node.script.strip
88
- add_line(code, node) unless code.empty?
89
- end
90
-
91
- def after_visit_tag(node)
92
- # We add a dummy puts statement for closing tag.
93
- add_dummy_puts(node, "#{node.tag_name}/")
94
- end
95
-
96
- def visit_script(node)
97
- code = node.text
98
-
99
- add_line(code.strip, node)
100
-
101
- start_block = anonymous_block?(code) || start_block_keyword?(code)
102
-
103
- if start_block
104
- @indent_level += 1
105
- end
106
-
107
- yield # Continue extracting code from children
108
-
109
- if start_block
110
- @indent_level -= 1
111
- add_line('end', node)
112
- end
113
- end
114
-
115
- def visit_haml_comment(node)
116
- # We want to preseve leading whitespace if it exists, but include leading
117
- # whitespace if it doesn't exist so that RuboCop's LeadingCommentSpace
118
- # doesn't complain
119
- comment = node.text
120
- .gsub(/\n(\S)/, "\n# \\1")
121
- .gsub(/\n(\s)/, "\n#\\1")
122
- add_line("##{comment}", node)
123
- end
124
-
125
- def visit_silent_script(node, &block)
126
- visit_script(node, &block)
127
- end
128
-
129
- def visit_filter(node)
130
- if node.filter_type == 'ruby'
131
- node.text.split("\n").each_with_index do |line, index|
132
- add_line(line, node.line + index + 1, discard_blanks: false)
133
- end
134
- else
135
- add_dummy_puts(node, ":#{node.filter_type}")
136
- HamlLint::Utils.extract_interpolated_values(node.text) do |interpolated_code, line|
137
- add_line(interpolated_code, node.line + line)
138
- end
139
- end
140
- end
141
-
142
- private
143
-
144
- def check_tag_static_hash_source(node)
145
- # Haml::Parser converts hashrocket-style hash attributes of strings and symbols
146
- # to static attributes, and excludes them from the dynamic attribute sources:
147
- # https://github.com/haml/haml/blob/08f97ec4dc8f59fe3d7f6ab8f8807f86f2a15b68/lib/haml/parser.rb#L400-L404
148
- # https://github.com/haml/haml/blob/08f97ec4dc8f59fe3d7f6ab8f8807f86f2a15b68/lib/haml/parser.rb#L540-L554
149
- # Here, we add the hash source back in so it can be inspected by rubocop.
150
- if node.hash_attributes? && node.dynamic_attributes_sources.empty?
151
- normalized_attr_source = node.dynamic_attributes_source[:hash].gsub(/\s*\n\s*/, ' ')
152
-
153
- add_line(normalized_attr_source, node)
154
- end
155
- end
156
-
157
- # Adds a dummy method call with a unique name so we don't get
158
- # Style/IdenticalConditionalBranches RuboCop warnings
159
- def add_dummy_puts(node, annotation = nil)
160
- annotation = " # #{annotation}" if annotation
161
- add_line("_haml_lint_puts_#{@output_count}#{annotation}", node)
162
- @output_count += 1
163
- end
164
-
165
- def add_line(code, node_or_line, discard_blanks: true)
166
- return if code.empty? && discard_blanks
167
-
168
- indent_level = @indent_level
169
-
170
- if node_or_line.respond_to?(:line) && mid_block_keyword?(code)
171
- # Since mid-block keywords are children of the corresponding start block
172
- # keyword, we need to reduce their indentation level by 1. However, we
173
- # don't do this unless this is an actual tag node (a raw line number
174
- # means this came from a `:ruby` filter).
175
- indent_level -= 1
176
- end
177
-
178
- indent = (' ' * 2 * indent_level)
179
-
180
- @source_lines << indent_code(code, indent)
181
-
182
- original_line =
183
- node_or_line.respond_to?(:line) ? node_or_line.line : node_or_line
184
-
185
- # For interpolated code in filters that spans multiple lines, the
186
- # resulting code will span multiple lines, so we need to create a
187
- # mapping for each line.
188
- (code.count("\n") + 1).times do
189
- @line_count += 1
190
- @source_map[@line_count] = original_line
191
- end
192
- end
193
-
194
- def indent_code(code, indent)
195
- codes = code.split("\n")
196
- codes.map { |c| indent + c }.join("\n")
197
- end
198
-
199
- def anonymous_block?(text)
200
- text =~ /\bdo\s*(\|\s*[^|]*\s*\|)?(\s*#.*)?\z/
201
- end
202
-
203
- START_BLOCK_KEYWORDS = %w[if unless case begin for until while].freeze
204
- def start_block_keyword?(text)
205
- START_BLOCK_KEYWORDS.include?(block_keyword(text))
206
- end
207
-
208
- MID_BLOCK_KEYWORDS = %w[else elsif when rescue ensure].freeze
209
- def mid_block_keyword?(text)
210
- MID_BLOCK_KEYWORDS.include?(block_keyword(text))
211
- end
212
-
213
- LOOP_KEYWORDS = %w[for until while].freeze
214
- def block_keyword(text)
215
- # Need to handle 'for'/'while' since regex stolen from HAML parser doesn't
216
- if (keyword = text[/\A\s*([^\s]+)\s+/, 1]) && LOOP_KEYWORDS.include?(keyword)
217
- return keyword
218
- end
219
-
220
- return unless keyword = text.scan(Haml::Parser::BLOCK_KEYWORD_REGEX)[0]
221
- keyword[0] || keyword[1]
222
- end
223
- end
224
- end