rsx-rb 0.1.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,1034 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSX
4
+ # Transforms .rsx source (Ruby with embedded JSX-style markup) into plain Ruby.
5
+ #
6
+ # The transformer is a single-pass scanner. Ruby is copied through verbatim
7
+ # while enough lexical state is tracked to know two things:
8
+ #
9
+ # 1. whether a "<" starts markup or is an operator, which comes down to
10
+ # whether an expression is expected at that position (exactly how Babel
11
+ # decides that a "<" opens JSX), and
12
+ # 2. where the `end` matching a `component` block is, so the block can be
13
+ # rewritten into a class definition.
14
+ #
15
+ # Generated Ruby keeps the same line numbering as the source, so exceptions
16
+ # raised inside a template point at the .rsx file and line the user wrote.
17
+ class Transformer
18
+ IDENT_START = /[A-Za-z_]/
19
+ IDENT_CHAR = /[A-Za-z0-9_]/
20
+ TAG_START = /[A-Za-z_>]/
21
+ TAG_CHAR = /[A-Za-z0-9_\-.:]/
22
+ ATTR_START = /[A-Za-z_@]/
23
+ ATTR_CHAR = /[A-Za-z0-9_\-.:]/
24
+ DIGIT = /[0-9]/
25
+
26
+ # Keywords after which an expression is expected.
27
+ OPENS_EXPRESSION = %w[
28
+ and begin break case do elsif else ensure if in next not or raise rescue
29
+ return then unless until when while yield
30
+ ].to_h { |word| [word, true] }.freeze
31
+
32
+ # Keywords that terminate an expression.
33
+ CLOSES_EXPRESSION = %w[
34
+ end self nil true false __FILE__ __LINE__ __dir__ __method__
35
+ ].to_h { |word| [word, true] }.freeze
36
+
37
+ # Keywords that open a block terminated by `end`.
38
+ BLOCK_KEYWORDS = %w[begin case class def module].to_h { |word| [word, true] }.freeze
39
+
40
+ # Block keywords that are also statement modifiers (`x if y`).
41
+ MODIFIER_KEYWORDS = %w[if unless while until].to_h { |word| [word, true] }.freeze
42
+
43
+ # Appended to compiled output when the file declares components, so a file
44
+ # of components can be told apart from a file of markup without running it.
45
+ COMPONENT_MARKER = "# rsx:components"
46
+
47
+ def self.transform(source, path: nil)
48
+ new(source, path: path).transform
49
+ end
50
+
51
+ def initialize(source, path: nil)
52
+ @src = source.to_s
53
+ @path = path
54
+ @len = @src.length
55
+ @pos = 0
56
+ @line = 1
57
+ @out_line = 1
58
+ @prev = :start
59
+ @buffers = [+""]
60
+ @heredocs = []
61
+ @blocks = []
62
+ @pending_loop = false
63
+ @jsx_spans = []
64
+ @components = 0
65
+ # Static markup slots are keyed by a digest of the source, so recompiling
66
+ # the same file reuses the same slots instead of leaking new ones.
67
+ @codegen = Codegen.new(path: path, prefix: "#{Digest::SHA256.hexdigest(@src)[0, 10]}-")
68
+ end
69
+
70
+ def transform
71
+ scan(:toplevel)
72
+ unless @blocks.empty?
73
+ component = @blocks.reverse.find { |frame| frame[:kind] == :component }
74
+ if component
75
+ raise SyntaxError.new("unterminated `component #{component[:name]}` block",
76
+ path: @path, line: component[:line])
77
+ end
78
+ end
79
+
80
+ # The marker goes after the last line, so line numbers are unaffected.
81
+ if @components.positive?
82
+ write("\n") unless @buffers.first.empty? || @buffers.first.end_with?("\n")
83
+ write("#{COMPONENT_MARKER}\n")
84
+ end
85
+ @buffers.first
86
+ end
87
+
88
+ def self.defines_components?(ruby)
89
+ ruby.include?(COMPONENT_MARKER)
90
+ end
91
+
92
+ private
93
+
94
+ # ------------------------------------------------------------------
95
+ # Output
96
+ # ------------------------------------------------------------------
97
+
98
+ def out
99
+ @buffers.last
100
+ end
101
+
102
+ # Copies n characters of source through to the output unchanged.
103
+ def copy(count = 1)
104
+ chunk = @src[@pos, count]
105
+ @pos += chunk.length
106
+ newlines = chunk.count("\n")
107
+ @line += newlines
108
+ @out_line += newlines
109
+ out << chunk
110
+ chunk
111
+ end
112
+
113
+ # Writes generated Ruby that has no direct source counterpart.
114
+ def write(text)
115
+ @out_line += text.count("\n")
116
+ out << text
117
+ end
118
+
119
+ # Collects output produced by the block instead of appending it.
120
+ def capture
121
+ @buffers.push(+"")
122
+ saved_line = @out_line
123
+ yield
124
+ @out_line = saved_line
125
+ @buffers.pop
126
+ end
127
+
128
+ # ------------------------------------------------------------------
129
+ # Character helpers
130
+ # ------------------------------------------------------------------
131
+
132
+ def eof?
133
+ @pos >= @len
134
+ end
135
+
136
+ def peek(offset = 0)
137
+ @pos + offset < @len ? @src[@pos + offset] : nil
138
+ end
139
+
140
+ def lookahead(length, offset = 0)
141
+ @src[@pos + offset, length]
142
+ end
143
+
144
+ def advance(count = 1)
145
+ count.times do
146
+ @line += 1 if @src[@pos] == "\n"
147
+ @pos += 1
148
+ end
149
+ end
150
+
151
+ def error(message, line: @line)
152
+ raise SyntaxError.new(message, path: @path, line: line)
153
+ end
154
+
155
+ # ------------------------------------------------------------------
156
+ # Main scanner
157
+ # ------------------------------------------------------------------
158
+
159
+ # mode is :toplevel or :brace. In :brace mode scanning stops (without
160
+ # consuming) at the "}" that closes the current expression container.
161
+ def scan(mode)
162
+ braces = 0
163
+
164
+ until eof?
165
+ char = peek
166
+
167
+ case char
168
+ when "\n"
169
+ copy
170
+ consume_heredoc_bodies
171
+ @prev = :start
172
+ when " ", "\t", "\r", "\f", "\v"
173
+ copy
174
+ when "#"
175
+ copy_line_comment
176
+ when "'", '"', "`"
177
+ copy_quoted(char)
178
+ @prev = :value
179
+ when "%"
180
+ if @prev == :start && percent_literal?
181
+ copy_percent_literal
182
+ @prev = :value
183
+ else
184
+ copy_operator
185
+ end
186
+ when "<"
187
+ if heredoc_ahead?
188
+ copy_heredoc_header
189
+ elsif @prev == :start && jsx_ahead?
190
+ emit_jsx
191
+ else
192
+ copy_operator
193
+ end
194
+ when "/"
195
+ if @prev == :start
196
+ copy_regex
197
+ @prev = :value
198
+ else
199
+ copy_operator
200
+ end
201
+ when "?"
202
+ if @prev == :start && character_literal?
203
+ copy(2)
204
+ @prev = :value
205
+ else
206
+ copy_operator
207
+ end
208
+ when "{"
209
+ braces += 1
210
+ copy
211
+ @prev = :start
212
+ when "}"
213
+ return if mode == :brace && braces.zero?
214
+
215
+ braces -= 1
216
+ copy
217
+ @prev = :value
218
+ when "="
219
+ if block_comment_ahead?
220
+ copy_block_comment
221
+ else
222
+ copy_operator
223
+ end
224
+ else
225
+ if IDENT_START.match?(char) || char == "@" || char == "$"
226
+ scan_word
227
+ elsif DIGIT.match?(char)
228
+ copy_number
229
+ @prev = :value
230
+ else
231
+ copy_operator
232
+ end
233
+ end
234
+ end
235
+ end
236
+
237
+ def copy_operator
238
+ char = peek
239
+
240
+ # `::` and `?.`-like sequences are copied whole so state stays accurate.
241
+ if char == ":" && peek(1) == ":"
242
+ copy(2)
243
+ @prev = :start
244
+ elsif char == ":" && symbol_ahead?
245
+ copy_symbol
246
+ @prev = :value
247
+ else
248
+ copy
249
+ @prev = case char
250
+ when ")", "]" then :value
251
+ else :start
252
+ end
253
+ end
254
+ end
255
+
256
+ def copy_number
257
+ copy while !eof? && /[0-9a-zA-Z_]/.match?(peek)
258
+ if peek == "." && peek(1) && DIGIT.match?(peek(1))
259
+ copy
260
+ copy while !eof? && /[0-9a-zA-Z_]/.match?(peek)
261
+ end
262
+ end
263
+
264
+ def copy_line_comment
265
+ copy until eof? || peek == "\n"
266
+ end
267
+
268
+ def block_comment_ahead?
269
+ lookahead(6) == "=begin" && at_line_start?
270
+ end
271
+
272
+ def at_line_start?
273
+ index = @pos - 1
274
+ index -= 1 while index >= 0 && (@src[index] == " " || @src[index] == "\t")
275
+ index.negative? || @src[index] == "\n"
276
+ end
277
+
278
+ def copy_block_comment
279
+ copy until eof? || (peek == "\n" && lookahead(4, 1) == "=end")
280
+ return if eof?
281
+
282
+ copy # newline
283
+ copy until eof? || peek == "\n"
284
+ end
285
+
286
+ # ------------------------------------------------------------------
287
+ # Literals
288
+ # ------------------------------------------------------------------
289
+
290
+ def copy_quoted(quote)
291
+ copy # opening quote
292
+ interpolating = quote != "'"
293
+
294
+ until eof?
295
+ char = peek
296
+ if char == "\\"
297
+ copy(2)
298
+ elsif char == quote
299
+ copy
300
+ return
301
+ elsif interpolating && char == "#" && peek(1) == "{"
302
+ copy(2)
303
+ scan(:brace)
304
+ error("unterminated interpolation") if eof?
305
+ copy # closing brace
306
+ else
307
+ copy
308
+ end
309
+ end
310
+
311
+ error("unterminated string literal")
312
+ end
313
+
314
+ PERCENT_TYPES = "qQwWiIrsx"
315
+ PAIRS = { "(" => ")", "[" => "]", "{" => "}", "<" => ">" }.freeze
316
+
317
+ def percent_literal?
318
+ offset = 1
319
+ offset += 1 if peek(1) && PERCENT_TYPES.include?(peek(1))
320
+ delimiter = peek(offset)
321
+ return false if delimiter.nil?
322
+
323
+ !/[A-Za-z0-9\s=]/.match?(delimiter)
324
+ end
325
+
326
+ def copy_percent_literal
327
+ copy # %
328
+ copy if PERCENT_TYPES.include?(peek)
329
+ open = peek
330
+ close = PAIRS[open] || open
331
+ nesting = 0
332
+ copy # delimiter
333
+
334
+ until eof?
335
+ char = peek
336
+ if char == "\\"
337
+ copy(2)
338
+ elsif char == open && close != open
339
+ nesting += 1
340
+ copy
341
+ elsif char == close
342
+ copy
343
+ return if nesting.zero?
344
+
345
+ nesting -= 1
346
+ elsif char == "#" && peek(1) == "{"
347
+ copy(2)
348
+ scan(:brace)
349
+ copy unless eof?
350
+ else
351
+ copy
352
+ end
353
+ end
354
+
355
+ error("unterminated %-literal")
356
+ end
357
+
358
+ def copy_regex
359
+ copy # opening slash
360
+ in_class = false
361
+
362
+ until eof?
363
+ char = peek
364
+ case char
365
+ when "\\" then copy(2)
366
+ when "[" then in_class = true; copy
367
+ when "]" then in_class = false; copy
368
+ when "#"
369
+ if peek(1) == "{"
370
+ copy(2)
371
+ scan(:brace)
372
+ copy unless eof?
373
+ else
374
+ copy
375
+ end
376
+ when "/"
377
+ if in_class
378
+ copy
379
+ else
380
+ copy
381
+ copy while !eof? && /[imxounse]/.match?(peek)
382
+ return
383
+ end
384
+ when "\n"
385
+ error("unterminated regexp literal")
386
+ else copy
387
+ end
388
+ end
389
+ end
390
+
391
+ def character_literal?
392
+ after = peek(1)
393
+ return false if after.nil? || /\s/.match?(after)
394
+
395
+ following = peek(2)
396
+ following.nil? || !IDENT_CHAR.match?(following)
397
+ end
398
+
399
+ def symbol_ahead?
400
+ after = peek(1)
401
+ return false if after.nil?
402
+
403
+ IDENT_START.match?(after) || after == '"' || after == "'" || after == "@" || after == "$"
404
+ end
405
+
406
+ def copy_symbol
407
+ copy # colon
408
+ if peek == '"' || peek == "'"
409
+ copy_quoted(peek)
410
+ return
411
+ end
412
+ copy while !eof? && (IDENT_CHAR.match?(peek) || peek == "@" || peek == "$")
413
+ copy if peek == "?" || peek == "!" || (peek == "=" && peek(1) != "=" && peek(1) != ">" && peek(1) != "~")
414
+ end
415
+
416
+ # ------------------------------------------------------------------
417
+ # Heredocs
418
+ # ------------------------------------------------------------------
419
+
420
+ def heredoc_ahead?
421
+ return false unless lookahead(2) == "<<"
422
+
423
+ offset = 2
424
+ squiggly = peek(offset) == "~" || peek(offset) == "-"
425
+ offset += 1 if squiggly
426
+ char = peek(offset)
427
+ return false if char.nil?
428
+
429
+ if char == '"' || char == "'" || char == "`"
430
+ squiggly || @prev == :start
431
+ elsif IDENT_START.match?(char)
432
+ # A bare `a << B` is a push, not a heredoc, unless an expression is
433
+ # expected here. `<<~` and `<<-` are unambiguous.
434
+ squiggly || (@prev == :start && /[A-Z_]/.match?(char))
435
+ else
436
+ false
437
+ end
438
+ end
439
+
440
+ def copy_heredoc_header
441
+ copy(2)
442
+ indented = peek == "~" || peek == "-"
443
+ copy if indented
444
+
445
+ quote = (peek == '"' || peek == "'" || peek == "`") ? peek : nil
446
+ copy if quote
447
+ identifier = +""
448
+ while !eof? && IDENT_CHAR.match?(peek)
449
+ identifier << peek
450
+ copy
451
+ end
452
+ copy if quote
453
+
454
+ @heredocs << { id: identifier, indented: indented }
455
+ @prev = :value
456
+ end
457
+
458
+ def consume_heredoc_bodies
459
+ return if @heredocs.empty?
460
+
461
+ pending = @heredocs
462
+ @heredocs = []
463
+
464
+ pending.each do |heredoc|
465
+ loop do
466
+ break if eof?
467
+
468
+ line_start = @pos
469
+ copy until eof? || peek == "\n"
470
+ text = @src[line_start...@pos]
471
+ copy unless eof?
472
+ terminator = heredoc[:indented] ? text.strip : text.chomp
473
+ break if terminator == heredoc[:id]
474
+ end
475
+ end
476
+ end
477
+
478
+ # ------------------------------------------------------------------
479
+ # Words: identifiers, keywords, and RSX statements
480
+ # ------------------------------------------------------------------
481
+
482
+ def scan_word
483
+ if peek == "@" || peek == "$"
484
+ copy
485
+ copy while peek == "@"
486
+ copy while !eof? && IDENT_CHAR.match?(peek)
487
+ @prev = :value
488
+ return
489
+ end
490
+
491
+ word = read_word_text
492
+ word_start = @pos
493
+ statement_position = @prev == :start
494
+
495
+ if statement_position
496
+ case word
497
+ when "component"
498
+ return if try_component_header
499
+ when "import"
500
+ return if try_import
501
+ when "export"
502
+ return if try_export
503
+ end
504
+ end
505
+
506
+ copy(word.length)
507
+ copy if (peek == "?" || peek == "!") && peek(1) != "="
508
+
509
+ case word
510
+ when "end"
511
+ close_block(word_start)
512
+ @prev = :value
513
+ when "def"
514
+ @blocks.push({ kind: :block, line: @line }) unless endless_def_ahead?
515
+ @prev = :start
516
+ when "do"
517
+ if @pending_loop
518
+ @pending_loop = false
519
+ else
520
+ @blocks.push({ kind: :block, line: @line })
521
+ end
522
+ @prev = :start
523
+ when "while", "until", "for"
524
+ if statement_position
525
+ @blocks.push({ kind: :block, line: @line })
526
+ @pending_loop = true
527
+ end
528
+ @prev = :start
529
+ when "if", "unless"
530
+ @blocks.push({ kind: :block, line: @line }) if statement_position
531
+ @prev = :start
532
+ else
533
+ if BLOCK_KEYWORDS.key?(word)
534
+ @blocks.push({ kind: :block, line: @line })
535
+ @prev = :start
536
+ elsif CLOSES_EXPRESSION.key?(word)
537
+ @prev = :value
538
+ elsif OPENS_EXPRESSION.key?(word)
539
+ @prev = :start
540
+ else
541
+ @prev = :value
542
+ end
543
+ end
544
+ end
545
+
546
+ def read_word_text
547
+ offset = 0
548
+ offset += 1 while @pos + offset < @len && IDENT_CHAR.match?(@src[@pos + offset])
549
+ @src[@pos, offset]
550
+ end
551
+
552
+ def close_block(body_end)
553
+ frame = @blocks.pop
554
+ return unless frame && frame[:kind] == :component
555
+
556
+ write("; #{component_epilogue(frame, body_end)}end")
557
+ end
558
+
559
+ def component_epilogue(frame, body_end)
560
+ return "" unless static_component?(frame, body_end)
561
+
562
+ "rsx_static!; "
563
+ end
564
+
565
+ # A component whose body is nothing but static markup can be rendered once
566
+ # and reused forever, which is the fastest path RSX has.
567
+ def static_component?(frame, body_end)
568
+ body = @src[frame[:body_start]...body_end].dup
569
+ spans = @jsx_spans.select { |span| span[0] >= frame[:body_start] && span[1] <= body_end }
570
+ return false if spans.empty?
571
+ return false unless spans.all? { |span| span[2] }
572
+
573
+ spans.reverse_each do |span|
574
+ body[(span[0] - frame[:body_start])...(span[1] - frame[:body_start])] = ""
575
+ end
576
+
577
+ # What is left over must be nothing but `return`, grouping parentheses,
578
+ # comments and whitespace for the body to count as purely static.
579
+ leftover = body.gsub(/^[ \t]*#.*$/, "").gsub(/[\s;()]/, "")
580
+ leftover.delete_prefix!("return")
581
+ leftover.empty?
582
+ end
583
+
584
+ def endless_def_ahead?
585
+ offset = 0
586
+ offset += 1 while @pos + offset < @len && /[ \t]/.match?(@src[@pos + offset])
587
+ offset += 1 while @pos + offset < @len && /[A-Za-z0-9_.?!\[\]<>=+\-*\/%&|^~]/.match?(@src[@pos + offset])
588
+
589
+ if @src[@pos + offset] == "("
590
+ depth = 0
591
+ while @pos + offset < @len
592
+ char = @src[@pos + offset]
593
+ depth += 1 if char == "("
594
+ if char == ")"
595
+ depth -= 1
596
+ if depth.zero?
597
+ offset += 1
598
+ break
599
+ end
600
+ end
601
+ break if char == "\n"
602
+
603
+ offset += 1
604
+ end
605
+ end
606
+
607
+ offset += 1 while @pos + offset < @len && /[ \t]/.match?(@src[@pos + offset])
608
+ @src[@pos + offset] == "=" && !["=", "~", ">"].include?(@src[@pos + offset + 1])
609
+ end
610
+
611
+ # ------------------------------------------------------------------
612
+ # component / import / export statements
613
+ # ------------------------------------------------------------------
614
+
615
+ # component Name[, options] do [|params|]
616
+ def try_component_header
617
+ match = /\Acomponent[ \t]+([A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)/.match(@src[@pos..])
618
+ unless match
619
+ lowercase = /\Acomponent[ \t]+([a-z_][A-Za-z0-9_]*)/.match(@src[@pos..])
620
+ if lowercase
621
+ error("component names must be constants, got `#{lowercase[1]}` " \
622
+ "(try `component #{lowercase[1].split('_').map(&:capitalize).join}`)")
623
+ end
624
+ return false
625
+ end
626
+
627
+ line = @line
628
+ name = match[1]
629
+ start = @pos
630
+ advance(match[0].length)
631
+
632
+ options = read_component_options
633
+ if options.nil?
634
+ # No `do` follows, so this was not a component header after all.
635
+ @pos = start
636
+ return false
637
+ end
638
+
639
+ params = read_component_params
640
+ body_start = @pos
641
+
642
+ write(%(::RSX.define_component("#{name}"#{options}) do; def rsx_render(#{params});))
643
+ @components += 1
644
+ @blocks.push({ kind: :component, name: name, line: line, body_start: body_start })
645
+ @prev = :start
646
+ true
647
+ end
648
+
649
+ # Reads any options between the component name and `do`, e.g. `, cache: true`.
650
+ def read_component_options
651
+ start = @pos
652
+ depth = 0
653
+
654
+ until eof?
655
+ char = peek
656
+ case char
657
+ when "(", "[", "{" then depth += 1; advance
658
+ when ")", "]", "}" then depth -= 1; advance
659
+ when "'", '"'
660
+ capture { copy_quoted(char) }
661
+ when "#"
662
+ advance until eof? || peek == "\n"
663
+ when "d"
664
+ if depth.zero? && lookahead(2) == "do" && !identifier_char?(peek(2)) && !identifier_char?(@src[@pos - 1])
665
+ options = @src[start...@pos].strip
666
+ advance(2)
667
+ return "" if options.empty?
668
+ return options.start_with?(",") ? options : ", #{options}"
669
+ end
670
+ advance
671
+ else
672
+ advance
673
+ end
674
+ end
675
+
676
+ @pos = start
677
+ nil
678
+ end
679
+
680
+ def identifier_char?(char)
681
+ !char.nil? && IDENT_CHAR.match?(char)
682
+ end
683
+
684
+ def read_component_params
685
+ advance while !eof? && /[ \t]/.match?(peek)
686
+ return "_props = nil" unless peek == "|"
687
+
688
+ advance
689
+ start = @pos
690
+ depth = 0
691
+
692
+ until eof?
693
+ char = peek
694
+ case char
695
+ when "(", "[", "{" then depth += 1; advance
696
+ when ")", "]", "}" then depth -= 1; advance
697
+ when "'", '"' then capture { copy_quoted(char) }
698
+ when "|"
699
+ if depth.zero?
700
+ params = @src[start...@pos].strip
701
+ advance
702
+ return params.empty? ? "_props = nil" : params
703
+ end
704
+ advance
705
+ when "\n" then error("unterminated component parameter list")
706
+ else advance
707
+ end
708
+ end
709
+
710
+ error("unterminated component parameter list")
711
+ end
712
+
713
+ # import Name from "path" / import { A, B } from "path" / import "path"
714
+ def try_import
715
+ rest = @src[@pos..][/\A[^\n]*/]
716
+ named = /\Aimport[ \t]+([A-Z][A-Za-z0-9_:]*)[ \t]+from[ \t]+(["'])(.+?)\2[ \t]*;?[ \t]*\z/.match(rest)
717
+ listed = /\Aimport[ \t]+\{([^}]+)\}[ \t]+from[ \t]+(["'])(.+?)\2[ \t]*;?[ \t]*\z/.match(rest)
718
+ bare = /\Aimport[ \t]+(["'])(.+?)\1[ \t]*;?[ \t]*\z/.match(rest)
719
+
720
+ if named
721
+ advance(rest.length)
722
+ write(%(::RSX.import("#{named[3]}", as: %i[#{named[1]}], from: __FILE__)))
723
+ elsif listed
724
+ names = listed[1].split(",").map(&:strip).reject(&:empty?)
725
+ advance(rest.length)
726
+ write(%(::RSX.import("#{listed[3]}", as: %i[#{names.join(" ")}], from: __FILE__)))
727
+ elsif bare
728
+ advance(rest.length)
729
+ write(%(::RSX.import("#{bare[2]}", from: __FILE__)))
730
+ else
731
+ return false
732
+ end
733
+
734
+ @prev = :value
735
+ true
736
+ end
737
+
738
+ # export default Name / export Name
739
+ def try_export
740
+ rest = @src[@pos..][/\A[^\n]*/]
741
+ match = /\Aexport[ \t]+(default[ \t]+)?([A-Z][A-Za-z0-9_:]*)[ \t]*;?[ \t]*\z/.match(rest)
742
+ return false unless match
743
+
744
+ advance(rest.length)
745
+ if match[1]
746
+ write(%(::RSX.export_default(#{match[2]}, from: __FILE__)))
747
+ else
748
+ write(%(::RSX.export(#{match[2]}, from: __FILE__)))
749
+ end
750
+ @prev = :value
751
+ true
752
+ end
753
+
754
+ # ------------------------------------------------------------------
755
+ # JSX
756
+ # ------------------------------------------------------------------
757
+
758
+ def jsx_ahead?
759
+ after = peek(1)
760
+ !after.nil? && TAG_START.match?(after)
761
+ end
762
+
763
+ def emit_jsx
764
+ start_pos = @pos
765
+ start_line = @line
766
+ node = parse_node
767
+ end_line = @line
768
+ ruby, static = @codegen.compile(node, start_line: start_line, end_line: end_line)
769
+ @jsx_spans << [start_pos, @pos, static]
770
+ write(ruby)
771
+ @prev = :value
772
+ end
773
+
774
+ def parse_node
775
+ line = @line
776
+ advance # <
777
+
778
+ if peek == ">"
779
+ advance
780
+ children = parse_children("")
781
+ expect_closing_tag("")
782
+ return Nodes::Fragment.new(children, line)
783
+ end
784
+
785
+ tag = read_tag_name
786
+ error("expected a tag name after `<`") if tag.empty?
787
+
788
+ attributes = parse_attributes(tag)
789
+
790
+ if lookahead(2) == "/>"
791
+ advance(2)
792
+ return build_node(tag, attributes, [], true, line)
793
+ end
794
+
795
+ error("expected `>` to close <#{tag}>") unless peek == ">"
796
+ advance
797
+
798
+ # Void elements are complete at ">": HTML gives them no closing tag.
799
+ return build_node(tag, attributes, [], true, line) if Attributes.void?(tag)
800
+
801
+ children = parse_children(tag)
802
+ expect_closing_tag(tag)
803
+ build_node(tag, attributes, children, false, line)
804
+ end
805
+
806
+ def build_node(tag, attributes, children, self_closing, line)
807
+ if tag == "Fragment" || tag == "React.Fragment" || tag == "RSX::Fragment"
808
+ return Nodes::Fragment.new(children, line)
809
+ end
810
+
811
+ if component_tag?(tag)
812
+ Nodes::Component.new(tag, attributes, children, line)
813
+ else
814
+ Nodes::Element.new(tag, attributes, children, self_closing, line)
815
+ end
816
+ end
817
+
818
+ def component_tag?(tag)
819
+ /\A[A-Z]/.match?(tag) || tag.include?(".") || tag.include?("::")
820
+ end
821
+
822
+ def read_tag_name
823
+ start = @pos
824
+ advance while !eof? && TAG_CHAR.match?(peek)
825
+ @src[start...@pos]
826
+ end
827
+
828
+ def parse_attributes(tag)
829
+ attributes = []
830
+
831
+ loop do
832
+ skip_tag_whitespace
833
+ error("unterminated <#{tag}> tag") if eof?
834
+ break if peek == ">" || lookahead(2) == "/>"
835
+
836
+ line = @line
837
+
838
+ if peek == "{"
839
+ attributes << Nodes::Attribute.new(nil, read_spread, :spread, line)
840
+ next
841
+ end
842
+
843
+ unless ATTR_START.match?(peek)
844
+ error("unexpected `#{peek}` in <#{tag}> attributes")
845
+ end
846
+
847
+ name = read_attribute_name
848
+ skip_tag_whitespace
849
+
850
+ unless peek == "="
851
+ attributes << Nodes::Attribute.new(name, true, :boolean, line)
852
+ next
853
+ end
854
+
855
+ advance
856
+ skip_tag_whitespace
857
+
858
+ case peek
859
+ when '"', "'"
860
+ attributes << Nodes::Attribute.new(name, read_attribute_string, :static, line)
861
+ when "{"
862
+ advance
863
+ source = read_expression
864
+ error("unterminated `{` in <#{tag}> attributes") unless peek == "}"
865
+ advance
866
+ attributes << Nodes::Attribute.new(name, source, :expression, line)
867
+ else
868
+ error("attribute `#{name}` needs a quoted string or a {ruby} expression")
869
+ end
870
+ end
871
+
872
+ attributes
873
+ end
874
+
875
+ # Whitespace plus the comment styles allowed inside a tag.
876
+ def skip_tag_whitespace
877
+ loop do
878
+ advance while !eof? && /\s/.match?(peek)
879
+
880
+ if peek == "#" || lookahead(2) == "//"
881
+ advance until eof? || peek == "\n"
882
+ elsif lookahead(2) == "/*"
883
+ advance(2)
884
+ advance until eof? || lookahead(2) == "*/"
885
+ error("unterminated comment") if eof?
886
+ advance(2)
887
+ else
888
+ return
889
+ end
890
+ end
891
+ end
892
+
893
+ def read_attribute_name
894
+ start = @pos
895
+ advance
896
+ advance while !eof? && ATTR_CHAR.match?(peek)
897
+ @src[start...@pos]
898
+ end
899
+
900
+ def read_attribute_string
901
+ quote = peek
902
+ advance
903
+ value = +""
904
+
905
+ until eof?
906
+ char = peek
907
+ if char == "\\" && (peek(1) == quote || peek(1) == "\\")
908
+ value << peek(1)
909
+ advance(2)
910
+ elsif char == quote
911
+ advance
912
+ return value
913
+ else
914
+ value << char
915
+ advance
916
+ end
917
+ end
918
+
919
+ error("unterminated attribute value")
920
+ end
921
+
922
+ # Reads a {ruby} container, stopping at the brace that closes it. Scanning
923
+ # always restarts in expression position, so `<` opens a nested tag and `/`
924
+ # opens a regexp no matter what token preceded the surrounding tag.
925
+ def read_expression
926
+ @prev = :start
927
+ capture { scan(:brace) }
928
+ end
929
+
930
+ def read_spread
931
+ advance # {
932
+ if lookahead(3) == "..."
933
+ advance(3)
934
+ elsif lookahead(2) == "**"
935
+ advance(2)
936
+ else
937
+ error("spread attributes must be written `{**props}` or `{...props}`")
938
+ end
939
+
940
+ source = read_expression
941
+ error("unterminated spread attribute") unless peek == "}"
942
+ advance
943
+ source
944
+ end
945
+
946
+ def parse_children(tag)
947
+ children = []
948
+ text = +""
949
+ text_line = @line
950
+
951
+ loop do
952
+ error("unterminated <#{tag || ""}> element") if eof?
953
+
954
+ if peek == "<"
955
+ flush_text(children, text, text_line)
956
+ text = +""
957
+ break if lookahead(2) == "</"
958
+
959
+ children << parse_node
960
+ text_line = @line
961
+ elsif peek == "{"
962
+ flush_text(children, text, text_line)
963
+ text = +""
964
+
965
+ if lookahead(3) == "{/*"
966
+ skip_jsx_comment
967
+ else
968
+ line = @line
969
+ advance
970
+ source = read_expression
971
+ error("unterminated `{` expression") unless peek == "}"
972
+ advance
973
+ children << Nodes::Expression.new(source, line) unless source.strip.empty?
974
+ end
975
+ text_line = @line
976
+ else
977
+ text << peek
978
+ advance
979
+ end
980
+ end
981
+
982
+ children
983
+ end
984
+
985
+ def skip_jsx_comment
986
+ advance(3)
987
+ until eof?
988
+ if lookahead(3) == "*/}"
989
+ advance(3)
990
+ return
991
+ end
992
+ advance
993
+ end
994
+ error("unterminated {/* comment */}")
995
+ end
996
+
997
+ def flush_text(children, text, line)
998
+ return if text.empty?
999
+
1000
+ normalized = self.class.normalize_text(text)
1001
+ children << Nodes::Text.new(normalized, line) unless normalized.empty?
1002
+ end
1003
+
1004
+ # JSX whitespace rules: indentation-only lines disappear, and remaining lines
1005
+ # are joined with a single space.
1006
+ def self.normalize_text(raw)
1007
+ lines = raw.split("\n", -1)
1008
+ return lines.first.to_s if lines.length == 1
1009
+
1010
+ kept = []
1011
+ lines.each_with_index do |line, index|
1012
+ text = line
1013
+ text = text.sub(/\A[ \t\r]+/, "") if index.positive?
1014
+ text = text.sub(/[ \t\r]+\z/, "") if index < lines.length - 1
1015
+ kept << text unless text.empty?
1016
+ end
1017
+ kept.join(" ")
1018
+ end
1019
+
1020
+ def expect_closing_tag(tag)
1021
+ error("expected `</#{tag}>`") unless lookahead(2) == "</"
1022
+ advance(2)
1023
+ closing = read_tag_name
1024
+ advance while !eof? && /\s/.match?(peek)
1025
+ error("expected `>` in closing tag `</#{closing}`") unless peek == ">"
1026
+ advance
1027
+
1028
+ return if closing == tag
1029
+ return if tag == "" && closing == ""
1030
+
1031
+ error("closing tag `</#{closing}>` does not match opening tag `<#{tag.empty? ? '' : tag}>`")
1032
+ end
1033
+ end
1034
+ end