super-slim 3.0.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.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../super_slim"
4
+ require_relative "linter"
5
+ require_relative "parallel"
6
+
7
+ module SuperSlim
8
+ class CLI
9
+ USAGE = <<~TEXT
10
+ Usage: super-slim lint [PATH…] report what breaks the rules of .super-slim.yml
11
+ super-slim format [--check] [PATH…] rewrite templates in their canonical layout (alias: fmt)
12
+ super-slim version
13
+ TEXT
14
+ SKIPPED_DIRECTORIES = %r{(\A|/)(\.git|node_modules|tmp|vendor)/}
15
+ EX_USAGE = 64
16
+ HELP_OPTIONS = %w[--help -h].freeze
17
+ OPTIONS_BY_COMMAND = {"format" => ["--check"], "fmt" => ["--check"]}.freeze
18
+
19
+ def call(arguments)
20
+ return puts(USAGE) || 0 if arguments.first == "help" || arguments.intersect?(HELP_OPTIONS)
21
+
22
+ command, *arguments = arguments
23
+ unknown_option = (arguments.grep(/\A-/) - OPTIONS_BY_COMMAND.fetch(command, [])).first
24
+ return warn("Unknown option #{unknown_option}", USAGE) || EX_USAGE if unknown_option
25
+
26
+ configuration = Linter.configuration
27
+ SuperSlim.tailwind_classes = configuration["tailwind_classes"] if configuration.key?("tailwind_classes")
28
+ case command
29
+ when "lint" then lint(arguments, configuration)
30
+ when "format", "fmt" then format(arguments, configuration["format"] || configuration.fetch("fmt", {}))
31
+ when "version", "--version" then puts(VERSION) || 0
32
+ else warn(USAGE) || EX_USAGE
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def lint(paths, configuration)
39
+ linter = Linter.new(configuration.fetch("linters", {}))
40
+ offenses = Parallel.map(templates(paths)) { |path| linter.call(path, File.read(path)) }.flatten
41
+ offenses.each { |offense| puts "#{offense.path}:#{offense.lineno}: [#{offense.rule}] #{offense.message}" }
42
+ offenses.empty? ? 0 : 1
43
+ end
44
+
45
+ def format(arguments, options)
46
+ check = arguments.delete("--check")
47
+ formatter = Formatter.new(max_line_length: options["max_line_length"], attribute_delimiters: options.fetch("attribute_delimiters", "()"))
48
+ outcomes = Parallel.map(templates(arguments)) do |path|
49
+ source = File.read(path)
50
+ formatted = formatter.call(source)
51
+ next if formatted == source
52
+
53
+ File.write(path, formatted) unless check
54
+ [:reformatted, path]
55
+ rescue Error => exception
56
+ [:refused, "#{path}: #{exception.message.lines.first.strip}"]
57
+ end
58
+ outcomes.compact.each { |outcome, text| (outcome == :reformatted) ? puts(text) : warn(text) }
59
+ (outcomes.any? { |outcome,| outcome == :refused || (outcome == :reformatted && check) }) ? 1 : 0
60
+ end
61
+
62
+ def templates(paths)
63
+ paths = ["."] if paths.empty?
64
+ paths.flat_map do |path|
65
+ next [path] unless File.directory?(path)
66
+
67
+ Dir.glob("**/*.slim", base: path).filter_map { |template| File.join(path, template) unless template.match?(SKIPPED_DIRECTORIES) }
68
+ end.sort
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,467 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperSlim
4
+ class Compiler
5
+ include AST
6
+
7
+ VOID_ELEMENTS = %w[area base br col embed hr img input keygen link menuitem meta param source track wbr].to_h { [_1, true] }.freeze
8
+ HYPHENATED_ATTRIBUTES = %w[data aria].freeze
9
+ DOCTYPES = {
10
+ html: {
11
+ "5" => "<!DOCTYPE html>",
12
+ "html" => "<!DOCTYPE html>",
13
+ "strict" => '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">',
14
+ "frameset" => '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Frameset//EN" "http://www.w3.org/TR/html4/frameset.dtd">',
15
+ "transitional" => '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">'
16
+ },
17
+ xml: {
18
+ "1.1" => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">',
19
+ "5" => "<!DOCTYPE html>",
20
+ "html" => "<!DOCTYPE html>",
21
+ "strict" => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">',
22
+ "frameset" => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Frameset//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd">',
23
+ "mobile" => '<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN" "http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">',
24
+ "basic" => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML Basic 1.1//EN" "http://www.w3.org/TR/xhtml-basic/xhtml-basic11.dtd">',
25
+ "transitional" => '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
26
+ "svg" => '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">'
27
+ }
28
+ }.freeze
29
+
30
+ BLOCK_SELF_OPENING = /(\A(if|unless|else|elsif|when|in|begin|rescue|ensure|case)\b)|\bdo\s*(\|[^|]*\|\s*)?\z/
31
+ BLOCK_OPENING = /\A(if|begin|unless|else|elsif|when|in|rescue|ensure)\b|\bdo\s*(\|[^|]*\|)?\s*$/
32
+ BLOCK_CONTINUATION = /\A(else|elsif|when|in|rescue|ensure)\b/
33
+ EXPLICIT_END = /\Aend\b/
34
+ CAPTURED_OUTPUT = /\A(if|unless)\b|\bdo\s*(\|[^|]*\|)?\s*$/
35
+
36
+ ESCAPED_INTERPOLATION = /\A\\#\{/
37
+ INTERPOLATION = /\A#\{((?>[^{}]|(\{(?>[^{}]|\g<1>)*\}))*)\}/
38
+ UNESCAPED_INTERPOLATION_CODE = /\A\{.*\}\Z/
39
+ INTERPOLATION_FREE_TEXT = /\A([#\\]?[^#\\]*([#\\][^\\#\{][^#\\]*)*)/ # rubocop:disable Style/RedundantRegexpEscape -- `#{` would open an interpolation
40
+
41
+ def initialize(format: :html, rails: false, html_safe: String.method_defined?(:html_safe?), preamble: nil, postamble: nil)
42
+ raise ArgumentError, "Invalid format #{format.inspect}" unless DOCTYPES.key?(format)
43
+
44
+ @xml = format == :xml
45
+ @doctypes = DOCTYPES.fetch(format)
46
+ @rails = rails
47
+ @html_safe = html_safe
48
+ @escape_function = html_safe ? "::SuperSlim::Runtime.escape_html_safe" : "::ERB::Escape.html_escape"
49
+ @preamble = preamble
50
+ @postamble = postamble
51
+ end
52
+
53
+ def call(nodes)
54
+ @code = +""
55
+ @static = +""
56
+ @variable_count = 0
57
+ @buffer = @rails ? OutputBuffer.new : StringBuffer.new("_buf", @escape_function)
58
+
59
+ emit_code(@buffer.open)
60
+ emit_static(@preamble) if @preamble
61
+ compile_children(nodes)
62
+ emit_static(@postamble) if @postamble
63
+ emit_code(@buffer.close)
64
+ @code
65
+ end
66
+
67
+ private
68
+
69
+ def compile_children(nodes)
70
+ block_open = false
71
+ nodes.each do |node|
72
+ if node.is_a?(Control)
73
+ raise Error, "Explicit end statements are forbidden" if node.code.match?(EXPLICIT_END)
74
+
75
+ emit_code("end") if block_open && !node.code.match?(BLOCK_CONTINUATION)
76
+ block_open = with_do(node.code, node.children).match?(BLOCK_OPENING)
77
+ elsif block_open && node != NEWLINE
78
+ emit_code("end")
79
+ block_open = false
80
+ end
81
+ compile_node(node)
82
+ end
83
+ emit_code("end") if block_open
84
+ end
85
+
86
+ def compile_node(node)
87
+ case node
88
+ when NEWLINE
89
+ @code << "\n" unless @newline_emitted
90
+ @newline_emitted = false
91
+ when Static then emit_static(node.text)
92
+ when Text then emit_interpolated(node.text)
93
+ when TextBlock then compile_children(node.children)
94
+ when Tag then compile_tag(node)
95
+ when Control then compile_control(node)
96
+ when Output then compile_output(node)
97
+ when InlineHtml
98
+ emit_interpolated(node.text)
99
+ compile_children(node.children)
100
+ when Comment
101
+ emit_static("<!--")
102
+ compile_children(node.children)
103
+ emit_static("-->")
104
+ when Doctype then emit_static(doctype(node.type))
105
+ when Embedded then compile_embedded(node)
106
+ end
107
+ end
108
+
109
+ def compile_control(control)
110
+ emit_code(with_do(control.code, control.children))
111
+ compile_children(control.children)
112
+ end
113
+
114
+ def compile_output(output)
115
+ code = with_do(output.code, output.children)
116
+ unless code.match?(CAPTURED_OUTPUT)
117
+ emit_output(code, output.escape)
118
+ return compile_children(output.children)
119
+ end
120
+
121
+ result = new_variable
122
+ emit_code("#{result} = #{code}")
123
+ if @rails
124
+ compile_children(output.children)
125
+ else
126
+ capture_into(new_variable) { compile_children(output.children) }
127
+ end
128
+ emit_code("end")
129
+ emit_output(result, output.escape)
130
+ end
131
+
132
+ def with_do(code, children)
133
+ (code.match?(BLOCK_SELF_OPENING) || AST.empty?(children)) ? code : "#{code} do"
134
+ end
135
+
136
+ def compile_tag(tag)
137
+ return compile_dynamic_tag(tag) if tag.name == "*"
138
+
139
+ closed = tag.content.nil? || (AST.renders_nothing?(tag.content) && (@xml || VOID_ELEMENTS.key?(tag.name)))
140
+ @static << "<" << tag.name
141
+ compile_attributes(tag.attributes)
142
+ emit_static((closed && @xml) ? " />" : ">")
143
+ return if closed && tag.content.nil?
144
+
145
+ compile_children(tag.content)
146
+ @static << "</" << tag.name << ">" unless closed
147
+ end
148
+
149
+ def compile_dynamic_tag(tag)
150
+ attributes = compile_runtime_attributes(tag.attributes)
151
+ if tag.content.nil?
152
+ emit_output("#{attributes}.build_tag", false)
153
+ elsif AST.empty?(tag.content)
154
+ emit_output(%(#{attributes}.build_tag("")), false)
155
+ compile_children(tag.content)
156
+ else
157
+ content = new_variable
158
+ capture_into(content) { compile_children(tag.content) }
159
+ emit_output("#{attributes}.build_tag(#{content})", false)
160
+ end
161
+ end
162
+
163
+ def compile_attributes(attributes)
164
+ return if attributes.empty?
165
+ return emit_output(compile_runtime_attributes(attributes), false) if attributes.any? { runtime_attribute?(_1) }
166
+
167
+ if attributes.size > 1
168
+ seen_names = {}
169
+ attributes.each do |attribute|
170
+ next if attribute.name == "class"
171
+ raise Error, "Multiple #{attribute.name} attributes specified" if seen_names.key?(attribute.name)
172
+
173
+ seen_names[attribute.name] = true
174
+ end
175
+ end
176
+
177
+ # Every class value renders at the place of the first class attribute.
178
+ class_attributes = nil
179
+ attributes.each do |attribute|
180
+ next compile_attribute(attribute) unless attribute.name == "class"
181
+ next if class_attributes
182
+
183
+ class_attributes = attributes.select { _1.name == "class" }
184
+ compile_class_attribute(class_attributes.map { attribute_parts(_1) })
185
+ end
186
+ end
187
+
188
+ def runtime_attribute?(attribute)
189
+ attribute.is_a?(Splat) || (attribute.is_a?(CodeAttribute) && HYPHENATED_ATTRIBUTES.include?(attribute.name))
190
+ end
191
+
192
+ def compile_attribute(attribute)
193
+ case attribute
194
+ when BooleanAttribute then emit_boolean_attribute(attribute.name)
195
+ when CodeAttribute then compile_code_attribute(attribute)
196
+ else emit_attribute(attribute.name, attribute_parts(attribute))
197
+ end
198
+ end
199
+
200
+ def compile_code_attribute(attribute)
201
+ case attribute.code
202
+ when "true" then emit_boolean_attribute(attribute.name)
203
+ when "false", "nil" then nil
204
+ else
205
+ value = new_variable
206
+ # A case on literals compiles to a jump table: no `==` call for the strings and numbers most values are.
207
+ emit_code("case (#{value} = #{attribute.code})")
208
+ emit_code("when true")
209
+ emit_boolean_attribute(attribute.name)
210
+ emit_code("when false, nil")
211
+ emit_code("else")
212
+ emit_attribute(attribute.name, [[:dynamic, value, attribute.escape]])
213
+ emit_code("end")
214
+ end
215
+ end
216
+
217
+ # Slim joins the non-empty values with spaces and drops an empty result. When the first value carries literal
218
+ # text, as shortcuts always come first, the attribute is certain to render: each later value only needs its
219
+ # separator, so nothing is joined at render time.
220
+ def compile_class_attribute(values)
221
+ values = values.reject { |parts| parts.all? { |kind, value| kind == :static && value.empty? } }
222
+ if values.empty?
223
+ nil
224
+ elsif literal_text?(values.first)
225
+ emit_static(' class="')
226
+ values.each_with_index { |parts, index| emit_class_value(parts, separator: index > 0) }
227
+ emit_static('"')
228
+ else
229
+ classes = new_variable
230
+ expressions = values.map { string_expression(_1) }
231
+ emit_code((values.size == 1) ? "#{classes} = #{expressions.first}" : "#{classes} = ::SuperSlim::Runtime.join_classes(#{expressions.join(", ")})")
232
+ emit_code("unless #{classes}.empty?")
233
+ emit_attribute("class", [[:raw, classes]])
234
+ emit_code("end")
235
+ end
236
+ end
237
+
238
+ def emit_class_value(parts, separator:)
239
+ if literal_text?(parts)
240
+ emit_static(" ") if separator
241
+ emit_parts(parts)
242
+ else
243
+ value = new_variable
244
+ emit_code("#{value} = #{string_expression(parts)}")
245
+ emit_code("unless #{value}.empty?")
246
+ emit_static(" ") if separator
247
+ emit_output(value, false)
248
+ emit_code("end")
249
+ end
250
+ end
251
+
252
+ def literal_text?(parts)
253
+ parts.any? { |kind, value| kind == :static && !value.empty? }
254
+ end
255
+
256
+ def compile_runtime_attributes(attributes)
257
+ builder = new_variable
258
+ emit_code("#{builder} = ::SuperSlim::Runtime::Attributes.new(#{@html_safe})")
259
+ attributes.each do |attribute|
260
+ case attribute
261
+ when Splat
262
+ emit_code("#{builder}.splat_attrs((#{attribute.code}))")
263
+ when CodeAttribute
264
+ emit_code("#{builder}.code_attr(#{attribute.name.inspect}, #{attribute.escape}, (#{attribute.code}))")
265
+ else
266
+ emit_code("#{builder}.attr(#{attribute.name.inspect}, #{string_expression(attribute_parts(attribute))})")
267
+ end
268
+ end
269
+ builder
270
+ end
271
+
272
+ def attribute_parts(attribute)
273
+ case attribute
274
+ when StaticAttribute then [[:static, attribute.value]]
275
+ when BooleanAttribute then []
276
+ when CodeAttribute
277
+ value = new_variable
278
+ emit_code("#{value} = #{attribute.code}")
279
+ [[:raw, "::SuperSlim::Runtime.class_value(#{value}, #{attribute.escape}, #{@html_safe})"]]
280
+ when QuotedAttribute
281
+ interpolation_parts(attribute.text).map do |kind, value, escape|
282
+ (kind == :static && attribute.escape) ? [:static, ERB::Escape.html_escape(value)] : [kind, value, escape]
283
+ end
284
+ end
285
+ end
286
+
287
+ def emit_attribute(name, parts)
288
+ emit_static(%( #{name}="))
289
+ emit_parts(parts)
290
+ emit_static('"')
291
+ end
292
+
293
+ def emit_boolean_attribute(name)
294
+ emit_static(@xml ? %( #{name}="") : " #{name}")
295
+ end
296
+
297
+ def emit_parts(parts)
298
+ parts.each do |kind, value, escape|
299
+ case kind
300
+ when :static then emit_static(value)
301
+ when :dynamic then emit_output(value, escape)
302
+ when :raw then emit_output(value, false)
303
+ end
304
+ end
305
+ end
306
+
307
+ def string_expression(parts)
308
+ if parts.size == 1
309
+ kind, value, escape = parts.first
310
+ return "#{@escape_function}((#{value}))" if kind == :dynamic && escape
311
+ return value if kind == :raw
312
+ end
313
+
314
+ literal = parts.map do |kind, value, escape|
315
+ case kind
316
+ when :static then value.inspect[1..-2]
317
+ when :dynamic then escape ? "\#{#{@escape_function}((#{value}))}" : "\#{#{value}}"
318
+ when :raw then "\#{#{value}}"
319
+ end
320
+ end
321
+ %("#{literal.join}")
322
+ end
323
+
324
+ def emit_interpolated(text)
325
+ emit_parts(interpolation_parts(text))
326
+ end
327
+
328
+ def interpolation_parts(text)
329
+ return [[:static, text]] unless text.include?('#{')
330
+
331
+ parts = []
332
+ loop do
333
+ case text
334
+ when ESCAPED_INTERPOLATION
335
+ parts << [:static, '#{']
336
+ when INTERPOLATION
337
+ code = $1
338
+ escape = !code.match?(UNESCAPED_INTERPOLATION_CODE)
339
+ parts << [:dynamic, escape ? code : code[1..-2], escape]
340
+ when INTERPOLATION_FREE_TEXT
341
+ parts << [:static, $&]
342
+ end
343
+ text = $'
344
+ break if text.empty?
345
+ end
346
+ parts
347
+ end
348
+
349
+ def compile_embedded(embedded)
350
+ case embedded.engine
351
+ when "ruby"
352
+ compile_ruby(embedded.children)
353
+ when "javascript" then compile_tag(Tag.new("script", embedded.attributes, [TextBlock.new(embedded.children)]))
354
+ when "css" then compile_tag(Tag.new("style", embedded.attributes, embedded.children))
355
+ when "markdown" then compile_markdown(embedded.children)
356
+ end
357
+ end
358
+
359
+ # Keeps every line of the block on its template line, blank lines included. The code must end with a
360
+ # newline, as its last line may be a comment: when no blank line follows, that newline is the one of the
361
+ # newline node that comes next.
362
+ def compile_ruby(children)
363
+ code = children.grep(Text).map(&:text).join
364
+ trailing_newline_count = children.count(NEWLINE) - 1 - code.count("\n")
365
+ @code << "\n"
366
+ emit_code(code)
367
+ @code << ("\n" * [trailing_newline_count, 1].max)
368
+ @newline_emitted = trailing_newline_count.zero?
369
+ end
370
+
371
+ def compile_markdown(children)
372
+ require "markly"
373
+
374
+ placeholder = "%#{object_id.abs.to_s(36)}%"
375
+ dynamic_parts = []
376
+ markdown = children.grep(Text).flat_map { interpolation_parts(_1.text) }.map do |part|
377
+ next part[1] if part.first == :static
378
+
379
+ dynamic_parts << part
380
+ "#{placeholder}#{dynamic_parts.size - 1}%"
381
+ end
382
+ Markly.render_html(markdown.join).split(/#{placeholder}(\d+)%/).each_with_index do |piece, index|
383
+ index.odd? ? emit_parts([dynamic_parts[piece.to_i]]) : emit_static(piece)
384
+ end
385
+ children.count(NEWLINE).times { @code << "\n" }
386
+ rescue LoadError
387
+ raise Error, "Embedded engine markdown requires the markly gem"
388
+ end
389
+
390
+ def doctype(type)
391
+ type = type.downcase
392
+ if (xml = type.match(/\Axml(\s+(.+))?\z/))
393
+ raise Error, "Invalid xml directive in html mode" unless @xml
394
+
395
+ %(<?xml version="1.0" encoding="#{xml[2] || "utf-8"}" ?>)
396
+ else
397
+ @doctypes[type] || raise(Error, "Invalid doctype #{type}")
398
+ end
399
+ end
400
+
401
+ def capture_into(variable)
402
+ if @rails
403
+ emit_code("#{variable} = capture do")
404
+ yield
405
+ emit_code("end")
406
+ else
407
+ outer_buffer = @buffer
408
+ flush_static
409
+ @buffer = StringBuffer.new(variable, @escape_function)
410
+ emit_code(@buffer.open)
411
+ yield
412
+ emit_code(@buffer.close)
413
+ @buffer = outer_buffer
414
+ end
415
+ end
416
+
417
+ def emit_static(text)
418
+ @static << text
419
+ end
420
+
421
+ def emit_output(code, escape)
422
+ emit_code(escape ? @buffer.escaped(code) : @buffer.raw(code))
423
+ end
424
+
425
+ def emit_code(code)
426
+ flush_static
427
+ append(code)
428
+ end
429
+
430
+ def flush_static
431
+ return if @static.empty?
432
+
433
+ append(@buffer.static(@static))
434
+ @static = +""
435
+ end
436
+
437
+ def append(code)
438
+ @code << "; " unless @code.empty? || @code.end_with?("\n")
439
+ @code << code
440
+ end
441
+
442
+ def new_variable
443
+ "_super_slim#{@variable_count += 1}"
444
+ end
445
+
446
+ class StringBuffer
447
+ def initialize(name, escape_function)
448
+ @name = name
449
+ @escape_function = escape_function
450
+ end
451
+
452
+ def open = "#{@name} = +\"\""
453
+ def close = @name
454
+ def static(text) = "#{@name} << #{text.inspect}.freeze"
455
+ def escaped(code) = "#{@name} << #{@escape_function}((#{code}))"
456
+ def raw(code) = "#{@name} << (#{code}).to_s"
457
+ end
458
+
459
+ class OutputBuffer
460
+ def open = "@output_buffer = output_buffer || ActionView::OutputBuffer.new"
461
+ def close = "@output_buffer"
462
+ def static(text) = "@output_buffer.safe_concat(#{text.inspect}.freeze)"
463
+ def escaped(code) = "@output_buffer << (#{code})"
464
+ def raw(code) = "@output_buffer.safe_concat((#{code}).to_s)"
465
+ end
466
+ end
467
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module SuperSlim
6
+ class Formatter
7
+ # Breaks the lines of a formatted template longer than max_line_length: a tag's attributes one per line
8
+ # inside attribute_delimiters, then the arguments of its Ruby code after each comma, as Slim reads a line ending
9
+ # with a comma as continued on the next one. Lines already spread over several lines are left as they are.
10
+ class LineWrapper
11
+ CODE_LINE = /\A *(?:-|==?[<>]*) /
12
+ TAG_OUTPUT = /\G\s*==?['<>]*\s*/
13
+ OPENINGS = %i[on_lparen on_lbracket on_lbrace on_tlambeg on_embexpr_beg].freeze
14
+ CLOSINGS = %i[on_rparen on_rbracket on_rbrace on_embexpr_end].freeze
15
+ CONTINUATION_KINDS = %i[continuation text text_blank].freeze
16
+ MODIFIERS = %w[if unless while until rescue].freeze
17
+
18
+ # Templates attach ( and [ to their tag but set { apart, as a Ruby block: 6,097 public templates agree.
19
+ OPENING_BY_ATTRIBUTE_DELIMITERS = {"()" => "(", "[]" => "[", "{}" => " {"}.freeze
20
+
21
+ def initialize(max_line_length, tailwind_classes:, attribute_delimiters: "()")
22
+ @opening = OPENING_BY_ATTRIBUTE_DELIMITERS.fetch(attribute_delimiters) do
23
+ raise ArgumentError, "Invalid attribute_delimiters #{attribute_delimiters.inspect}: one of #{OPENING_BY_ATTRIBUTE_DELIMITERS.keys.join(", ")}"
24
+ end
25
+
26
+ @max_line_length = max_line_length
27
+ @tailwind_classes = tailwind_classes
28
+ @closing = attribute_delimiters[1]
29
+ end
30
+
31
+ def call(source)
32
+ lines = source.split("\n")
33
+ return source if lines.none? { _1.size > @max_line_length }
34
+
35
+ parser = Parser.new(outline: true, tailwind_classes: @tailwind_classes)
36
+ parser.call(source)
37
+ wrapped = lines.each.with_index(1).flat_map do |line, lineno|
38
+ info = parser.outline[lineno]
39
+ next [line] unless line.size > @max_line_length && info.kind == :structure && one_line?(parser.outline[lineno + 1], lineno)
40
+
41
+ wrap(line, info, children?(parser.outline, lineno))
42
+ end
43
+ "#{wrapped.join("\n")}\n"
44
+ end
45
+
46
+ private
47
+
48
+ def one_line?(next_info, lineno)
49
+ next_info.nil? || next_info.owner != lineno || !CONTINUATION_KINDS.include?(next_info.kind)
50
+ end
51
+
52
+ def children?(outline, lineno)
53
+ next_structure = outline[(lineno + 1)..].find { _1.kind == :structure }
54
+ next_structure && next_structure.depth > outline[lineno].depth
55
+ end
56
+
57
+ def wrap(line, info, children)
58
+ indent = line[/\A */]
59
+ lists = info.attribute_lists
60
+ lines, code_start =
61
+ if lists&.size == 1 && named_tag?(line[0...lists.first.first].strip)
62
+ wrap_attributes(line, lists.first, indent, info.output_column)
63
+ else
64
+ [[line], code_start(line, info.output_column)]
65
+ end
66
+ return lines unless code_start && lines.last.size > @max_line_length
67
+
68
+ # Arguments go two levels deeper than the line when it opens a block, so they stand apart from its children.
69
+ argument_indent = indent + (" " * (children ? 2 * INDENT_WIDTH : INDENT_WIDTH))
70
+ lines[0...-1] + wrap_arguments(lines.last, code_start, argument_indent)
71
+ end
72
+
73
+ # A dynamic tag, `*{tag: name}` or `li: *{tag: name}`, has no name for its attribute list to follow.
74
+ def named_tag?(head)
75
+ !head.empty? && !head.end_with?(":")
76
+ end
77
+
78
+ # Returns the lines and where the Ruby code of the last one starts, if it outputs some.
79
+ def wrap_attributes(line, (first, last, spans), indent, output_column)
80
+ attribute_indent = indent + (" " * INDENT_WIDTH)
81
+ closing_line = indent + @closing + line[last..]
82
+ lines = [line[0...first].rstrip + @opening, *spans.map { |span_first, span_last| attribute_indent + line[span_first...span_last] }, closing_line]
83
+ [lines, output_column && code_start(closing_line, output_column - last + indent.size + @closing.size)]
84
+ end
85
+
86
+ def code_start(line, output_column)
87
+ if output_column
88
+ TAG_OUTPUT.match(line, output_column)&.end(0)
89
+ elsif (match = CODE_LINE.match(line))
90
+ match.end(0)
91
+ end
92
+ end
93
+
94
+ def wrap_arguments(line, code_start, argument_indent)
95
+ code = line[code_start..]
96
+ offsets = argument_breaks(code)
97
+ return [line] if offsets.empty?
98
+
99
+ first = 0
100
+ first_piece, *pieces = [*offsets, code.bytesize].map do |last|
101
+ piece = code.byteslice(first...last).strip
102
+ first = last
103
+ piece
104
+ end
105
+ [line[0...code_start] + first_piece, *pieces.map { argument_indent + _1 }]
106
+ end
107
+
108
+ # Byte offsets right after each comma that separates the arguments of the outermost call, up to its block.
109
+ def argument_breaks(code)
110
+ depth = 0
111
+ offsets = []
112
+ Ripper.lex(code).each do |(_, column), type, token|
113
+ case type
114
+ when :on_heredoc_beg then return []
115
+ when :on_kw
116
+ break if token == "do"
117
+ # RuboCop refuses a modifier that ends a statement spread over several lines (Style/MultilineIfModifier).
118
+ return [] if depth.zero? && MODIFIERS.include?(token)
119
+ when :on_op
120
+ # `a, b = …` assigns several variables: its commas separate no arguments.
121
+ return [] if depth.zero? && token == "=" && !offsets.empty?
122
+ when :on_comma then offsets << column + 1 if depth.zero?
123
+ when *OPENINGS then depth += 1
124
+ when *CLOSINGS then depth -= 1
125
+ end
126
+ end
127
+ offsets
128
+ end
129
+ end
130
+ end
131
+ end