erblite 0.5.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,649 @@
1
+ # Copyright 2026 OpenC3, Inc.
2
+ # All Rights Reserved.
3
+ #
4
+ # This program is distributed in the hope that it will be useful,
5
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
6
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
7
+ # See LICENSE.md for more details.
8
+
9
+ # frozen_string_literal: true
10
+
11
+ require "strscan"
12
+
13
+ module ERBLite
14
+ # A lexical token. +type+ is one of:
15
+ # :text :ostart :oend :tagend :nl :semi :eof
16
+ # :int :float :str :sym :dsym :words :symwords :ident :const :label :kw :op
17
+ # +spc+ is true when whitespace came directly before the token.
18
+ # +neg+ marks a negative numeric literal ("-2").
19
+ class Token
20
+ attr_reader :type, :value, :line, :spc
21
+ attr_accessor :neg
22
+
23
+ def initialize(type, value, line, spc = false)
24
+ @type = type
25
+ @value = value
26
+ @line = line
27
+ @spc = spc
28
+ @neg = false
29
+ end
30
+
31
+ def inspect
32
+ "#<Token #{@type} #{@value.inspect} L#{@line}>"
33
+ end
34
+ end
35
+
36
+ # Splits a template into text and tags exactly like Ruby's ERB with
37
+ # trim_mode "-" (ERB::Compiler::ExplicitScanner), then tokenizes the code
38
+ # inside each tag. The result is one flat token stream for the whole
39
+ # template, so a statement can span several tags.
40
+ class Lexer
41
+ STAG_RE = /(.*?)(^[ \t]*<%-|<%-|<%%|<%=|<%#|<%|\z)/m
42
+ ETAG_RE = /(.*?)(-%>|%%>|%>|\z)/m
43
+
44
+ def self.tokenize(source, filename)
45
+ new(source, filename).run
46
+ end
47
+
48
+ def initialize(source, filename)
49
+ @src = source
50
+ @filename = filename
51
+ end
52
+
53
+ def run
54
+ toks = []
55
+ ss = StringScanner.new(@src)
56
+ line = 1
57
+ text = +""
58
+ text_line = 1
59
+ until ss.eos?
60
+ ss.scan(STAG_RE)
61
+ part = ss[1]
62
+ elem = ss[2]
63
+ text << part
64
+ line += part.count("\n")
65
+ break if elem.empty?
66
+
67
+ if elem == "<%%"
68
+ text << "<%"
69
+ next
70
+ end
71
+ elem = "<%" if elem.end_with?("<%-")
72
+ unless text.empty?
73
+ toks << Token.new(:text, text, text_line)
74
+ text = +""
75
+ end
76
+ tag_line = line
77
+ code = +""
78
+ loop do
79
+ ss.scan(ETAG_RE)
80
+ code << ss[1]
81
+ etag = ss[2]
82
+ case etag
83
+ when "%%>"
84
+ code << "%>"
85
+ when "-%>"
86
+ line += code.count("\n")
87
+ emit_tag(toks, elem, code, tag_line, line)
88
+ line += 1 if ss.scan(/\r?\n/)
89
+ break
90
+ when "%>"
91
+ line += code.count("\n")
92
+ emit_tag(toks, elem, code, tag_line, line)
93
+ break
94
+ else
95
+ raise Error.new("SyntaxError", "unterminated tag (missing %>)", @filename, tag_line)
96
+ end
97
+ end
98
+ text_line = line
99
+ end
100
+ toks << Token.new(:text, text, text_line) unless text.empty?
101
+ toks << Token.new(:eof, nil, line)
102
+ toks
103
+ end
104
+
105
+ private
106
+
107
+ def emit_tag(toks, stag, code, tag_line, end_line)
108
+ case stag
109
+ when "<%#"
110
+ nil
111
+ when "<%="
112
+ toks << Token.new(:ostart, nil, tag_line)
113
+ CodeLexer.new(code, tag_line, toks, @filename).run
114
+ toks << Token.new(:oend, nil, end_line)
115
+ else
116
+ CodeLexer.new(code, tag_line, toks, @filename).run
117
+ toks << Token.new(:tagend, nil, end_line)
118
+ end
119
+ end
120
+ end
121
+
122
+ # Tokenizes the Ruby-subset code inside one tag.
123
+ class CodeLexer
124
+ KEYWORDS = %w[if elsif else unless then end while until for in do case when
125
+ break next nil true false and or not begin self].freeze
126
+ # Ruby keywords that ERBLite rejects with a SyntaxError.
127
+ RESERVED = %w[def class module return yield super rescue ensure alias undef
128
+ redo retry defined? __FILE__ __LINE__ __ENCODING__].freeze
129
+ KEYWORD_SET = (KEYWORDS + RESERVED).to_h { |k| [k, true] }.freeze
130
+
131
+ OPS = %w[**= <=> === ... <<= >>= &&= ||= ** == != >= <= && || << >> += -= *= /=
132
+ %= |= &= ^= => .. :: &. -> =~ !~ + - * / % = < > ! & | ^ ~ ? : , . ( ) [ ] { }].freeze
133
+ OPS_BY_LEN = OPS.group_by(&:length).sort_by { |len, _| -len }.map { |len, ops| [len, ops.to_h { |o| [o, true] }] }.freeze
134
+
135
+ # A newline right after one of these does not end the statement.
136
+ NL_CONT_OPS = "+ - * / % ** == != < > <= >= <=> === && || & | ^ << >> = += -= *= /= %= **= ||= &&= " \
137
+ "<<= >>= |= &= ^= , ( [ { . &. ? : => :: ! ~".split.to_h { |o| [o, true] }.freeze
138
+
139
+ SYMBOL_OPS = %w[[]= [] <=> === == != ** +@ -@ << >> <= >= + - * / % < > ! & | ^ ~].freeze
140
+
141
+ ESCAPES = { "n" => "\n", "t" => "\t", "r" => "\r", "s" => " ", "e" => "\e", "a" => "\a",
142
+ "b" => "\b", "f" => "\f", "v" => "\v" }.freeze
143
+
144
+ WORD_DELIMS = { "[" => "]", "(" => ")", "{" => "}", "<" => ">" }.freeze
145
+
146
+ def initialize(src, line, out, filename)
147
+ @s = src
148
+ @i = 0
149
+ @line = line
150
+ @out = out
151
+ @filename = filename
152
+ @interp_depth = 0
153
+ end
154
+
155
+ def run
156
+ lex_until(false)
157
+ end
158
+
159
+ private
160
+
161
+ def error(msg, line = @line)
162
+ raise Error.new("SyntaxError", msg, @filename, line)
163
+ end
164
+
165
+ # Lexes tokens into @out. With +stop_at_brace+ it stops after the "}"
166
+ # that closes a string interpolation.
167
+ def lex_until(stop_at_brace)
168
+ depth = 0
169
+ spc = false
170
+ while @i < @s.length
171
+ c = @s[@i]
172
+ case c
173
+ when " ", "\t", "\r", "\f", "\v"
174
+ @i += 1
175
+ spc = true
176
+ next
177
+ when "\\"
178
+ nl = @s[@i + 1] == "\n" ? 2 : (@s[@i + 1] == "\r" && @s[@i + 2] == "\n" ? 3 : 0)
179
+ if nl.positive?
180
+ @i += nl
181
+ @line += 1
182
+ spc = true
183
+ next
184
+ end
185
+ error("unexpected backslash")
186
+ when "\n"
187
+ emit_nl
188
+ @i += 1
189
+ @line += 1
190
+ spc = true
191
+ next
192
+ when "#"
193
+ @i += 1 while @i < @s.length && @s[@i] != "\n"
194
+ next
195
+ when ";"
196
+ @out << Token.new(:semi, ";", @line, spc)
197
+ @i += 1
198
+ spc = false
199
+ next
200
+ end
201
+ if stop_at_brace
202
+ if c == "{"
203
+ depth += 1
204
+ elsif c == "}"
205
+ if depth.zero?
206
+ @i += 1
207
+ return
208
+ end
209
+ depth -= 1
210
+ end
211
+ end
212
+ lex_token(spc)
213
+ spc = false
214
+ end
215
+ error("unterminated string interpolation") if stop_at_brace
216
+ end
217
+
218
+ def emit_nl
219
+ last = @out.last
220
+ return if last.nil?
221
+ return if %i[nl semi tagend ostart text].include?(last.type)
222
+ return if last.type == :op && NL_CONT_OPS[last.value]
223
+ return if last.type == :kw && %w[and or not].include?(last.value)
224
+
225
+ # A line starting with ".foo" or "&.foo" continues the previous line.
226
+ j = @i + 1
227
+ j += 1 while j < @s.length && " \t\r\n".include?(@s[j])
228
+ return if @s[j] == "." && @s[j + 1] != "."
229
+ return if @s[j] == "&" && @s[j + 1] == "."
230
+
231
+ @out << Token.new(:nl, nil, @line)
232
+ end
233
+
234
+ def ends_value?(tok)
235
+ return false unless tok
236
+
237
+ case tok.type
238
+ when :int, :float, :str, :sym, :dsym, :words, :symwords, :ident, :const then true
239
+ when :op then tok.value == ")" || tok.value == "]" || tok.value == "}"
240
+ when :kw then %w[nil true false end].include?(tok.value)
241
+ else false
242
+ end
243
+ end
244
+
245
+ def push(type, value, spc, line = @line)
246
+ t = Token.new(type, value, line, spc)
247
+ @out << t
248
+ t
249
+ end
250
+
251
+ def lex_token(spc)
252
+ c = @s[@i]
253
+ if c.match?(/[0-9]/)
254
+ lex_number(false, spc)
255
+ elsif c == "-" && @s[@i + 1]&.match?(/[0-9]/) && !ends_value?(@out.last)
256
+ lex_number(true, spc)
257
+ elsif c.match?(/[a-z_]/)
258
+ lex_ident(spc)
259
+ elsif c.match?(/[A-Z]/)
260
+ lex_const(spc)
261
+ elsif c == '"'
262
+ @i += 1
263
+ line = @line
264
+ parts = lex_dstring_body('"')
265
+ string_token(parts, spc, line)
266
+ elsif c == "'"
267
+ lex_sstring(spc)
268
+ elsif c == ":"
269
+ lex_colon(spc)
270
+ elsif c == "%" && !ends_value?(@out.last) && (@s[@i + 1] == "w" || @s[@i + 1] == "i") && WORD_DELIMS[@s[@i + 2]]
271
+ lex_words(spc)
272
+ elsif c == "@"
273
+ error("instance variables are not supported")
274
+ elsif c == "$"
275
+ error("global variables are not supported")
276
+ elsif c == "`"
277
+ error("backticks (shell commands) are not supported")
278
+ else
279
+ lex_op(spc)
280
+ end
281
+ end
282
+
283
+ def lex_op(spc)
284
+ OPS_BY_LEN.each do |len, ops|
285
+ cand = @s[@i, len]
286
+ next unless cand && cand.length == len && ops[cand]
287
+
288
+ case cand
289
+ when "->" then error("lambdas are not supported")
290
+ when "=~", "!~" then error("regular expressions are not supported")
291
+ end
292
+ @i += len
293
+ push(:op, cand, spc)
294
+ return
295
+ end
296
+ error("unexpected character #{@s[@i].inspect}")
297
+ end
298
+
299
+ # -- numbers -------------------------------------------------------------
300
+
301
+ def lex_number(neg, spc)
302
+ line = @line
303
+ start = @i
304
+ @i += 1 if neg
305
+ s = @s
306
+ if s[@i] == "0" && s[@i + 1]&.match?(/[xXbBoO]/)
307
+ kind = s[@i + 1].downcase
308
+ @i += 2
309
+ digits_start = @i
310
+ @i += 1 while @i < s.length && s[@i].match?(/[0-9a-zA-Z_]/)
311
+ digits = s[digits_start...@i]
312
+ base, re = { "x" => [16, /\A[0-9a-f]+(_[0-9a-f]+)*\z/i], "b" => [2, /\A[01]+(_[01]+)*\z/],
313
+ "o" => [8, /\A[0-7]+(_[0-7]+)*\z/] }[kind]
314
+ error("invalid numeric literal") unless digits.match?(re)
315
+ value = digits.delete("_").to_i(base)
316
+ int_token(neg ? -value : value, neg, spc, line)
317
+ return
318
+ end
319
+ @i += 1 while @i < s.length && s[@i].match?(/[0-9_]/)
320
+ int_part = s[(neg ? start + 1 : start)...@i]
321
+ is_float = false
322
+ if s[@i] == "." && s[@i + 1]&.match?(/[0-9]/)
323
+ is_float = true
324
+ @i += 1
325
+ @i += 1 while @i < s.length && s[@i].match?(/[0-9_]/)
326
+ end
327
+ if (s[@i] == "e" || s[@i] == "E") &&
328
+ (s[@i + 1]&.match?(/[0-9]/) || (s[@i + 1]&.match?(/[+-]/) && s[@i + 2]&.match?(/[0-9]/)))
329
+ is_float = true
330
+ @i += 2
331
+ @i += 1 while @i < s.length && s[@i].match?(/[0-9_]/)
332
+ end
333
+ error("invalid numeric literal") if @i < s.length && s[@i].match?(/[A-Za-z_]/)
334
+ text = s[start...@i]
335
+ if is_float
336
+ body = text.sub(/\A-/, "")
337
+ mant = body.split(/[eE]/).first
338
+ error("invalid numeric literal") unless mant.match?(/\A\d+(_\d+)*(\.\d+(_\d+)*)?\z/)
339
+ value = Float(text.delete("_"))
340
+ t = push(:float, value, spc, line)
341
+ t.neg = neg
342
+ else
343
+ error("invalid numeric literal") unless int_part.match?(/\A\d+(_\d+)*\z/)
344
+ digits = int_part.delete("_")
345
+ if digits.length > 1 && digits.start_with?("0")
346
+ error("Invalid octal digit") unless digits.match?(/\A[0-7]+\z/)
347
+ value = digits.to_i(8)
348
+ else
349
+ value = digits.to_i
350
+ end
351
+ int_token(neg ? -value : value, neg, spc, line)
352
+ end
353
+ end
354
+
355
+ def int_token(value, neg, spc, line)
356
+ unless value.between?(Builtins::INT_MIN, Builtins::INT_MAX)
357
+ raise Error.new("RangeError", "integer literal out of 64-bit range", @filename, line)
358
+ end
359
+ t = push(:int, value, spc, line)
360
+ t.neg = neg
361
+ end
362
+
363
+ # -- identifiers ---------------------------------------------------------
364
+
365
+ def after_dot?
366
+ last = @out.last
367
+ last && last.type == :op && (last.value == "." || last.value == "&.")
368
+ end
369
+
370
+ def read_name
371
+ start = @i
372
+ @i += 1 while @i < @s.length && @s[@i].match?(/[A-Za-z0-9_]/)
373
+ if (@s[@i] == "?" || @s[@i] == "!") && !(@s[@i + 1] == "=" && !["=", "~", ">"].include?(@s[@i + 2]))
374
+ @i += 1
375
+ end
376
+ @s[start...@i]
377
+ end
378
+
379
+ def label_follows?
380
+ @s[@i] == ":" && @s[@i + 1] != ":"
381
+ end
382
+
383
+ def lex_ident(spc)
384
+ line = @line
385
+ name = read_name
386
+ dot = after_dot?
387
+ if !dot && label_follows? && !ternary_context?
388
+ @i += 1
389
+ push(:label, name, spc, line)
390
+ elsif !dot && KEYWORD_SET[name]
391
+ push(:kw, name, spc, line)
392
+ else
393
+ push(:ident, name, spc, line)
394
+ end
395
+ end
396
+
397
+ def lex_const(spc)
398
+ line = @line
399
+ start = @i
400
+ @i += 1 while @i < @s.length && @s[@i].match?(/[A-Za-z0-9_]/)
401
+ name = @s[start...@i]
402
+ if after_dot?
403
+ if (@s[@i] == "?" || @s[@i] == "!") && @s[@i + 1] != "="
404
+ name += @s[@i]
405
+ @i += 1
406
+ end
407
+ push(:ident, name, spc, line)
408
+ elsif label_follows? && !ternary_context?
409
+ @i += 1
410
+ push(:label, name, spc, line)
411
+ else
412
+ push(:const, name, spc, line)
413
+ end
414
+ end
415
+
416
+ def ternary_context?
417
+ last = @out.last
418
+ last && last.type == :op && last.value == "?"
419
+ end
420
+
421
+ # -- strings -------------------------------------------------------------
422
+
423
+ def lex_sstring(spc)
424
+ line = @line
425
+ @i += 1
426
+ buf = +""
427
+ loop do
428
+ c = @s[@i]
429
+ error("unterminated string", line) if c.nil?
430
+ if c == "\\" && (@s[@i + 1] == "\\" || @s[@i + 1] == "'")
431
+ buf << @s[@i + 1]
432
+ @i += 2
433
+ elsif c == "'"
434
+ @i += 1
435
+ break
436
+ elsif c == "\r" && @s[@i + 1] == "\n"
437
+ @i += 1 # Ruby reads CRLF in source code as LF
438
+ else
439
+ @line += 1 if c == "\n"
440
+ buf << c
441
+ @i += 1
442
+ end
443
+ end
444
+ string_token([buf], spc, line)
445
+ end
446
+
447
+ def string_token(parts, spc, line)
448
+ last = @out.last
449
+ if label_follows? && parts.all?(String) && last && last.type == :op && ["{", ",", "("].include?(last.value)
450
+ @i += 1
451
+ push(:label, parts.join, spc, line)
452
+ else
453
+ push(:str, parts, spc, line)
454
+ end
455
+ end
456
+
457
+ # Reads a double-quoted string body (after the opening quote) up to
458
+ # +term+. Returns an Array of String parts and [:code, tokens, line]
459
+ # interpolation parts.
460
+ def lex_dstring_body(term)
461
+ start_line = @line
462
+ parts = []
463
+ buf = (+"").force_encoding(Encoding::BINARY)
464
+ flush = lambda do
465
+ unless buf.empty?
466
+ str = buf.dup.force_encoding(Encoding::UTF_8)
467
+ error("invalid UTF-8 in string literal", start_line) unless str.valid_encoding?
468
+ parts << str
469
+ buf.clear
470
+ end
471
+ end
472
+ loop do
473
+ c = @s[@i]
474
+ error("unterminated string", start_line) if c.nil?
475
+ if c == term
476
+ @i += 1
477
+ break
478
+ elsif c == "\\"
479
+ read_escape(buf)
480
+ elsif c == "#" && @s[@i + 1] == "{"
481
+ flush.call
482
+ @i += 2
483
+ line = @line
484
+ saved = @out
485
+ @out = []
486
+ @interp_depth += 1
487
+ if @interp_depth > Parser::MAX_DEPTH
488
+ raise Error.new("LimitError", "string interpolation is nested too deeply", @filename, line)
489
+ end
490
+
491
+ lex_until(true)
492
+ @interp_depth -= 1
493
+ parts << [:code, @out, line]
494
+ @out = saved
495
+ elsif c == "#" && ((@s[@i + 1] == "@" && @s[@i + 2]&.match?(/[A-Za-z_@]/)) ||
496
+ (@s[@i + 1] == "$" && @s[@i + 2]&.match?(%r{[A-Za-z0-9_!@&`'+~=/\\,;.<>*$?:-]})))
497
+ error("instance/global variable interpolation is not supported")
498
+ elsif c == "\r" && @s[@i + 1] == "\n"
499
+ @i += 1 # Ruby reads CRLF in source code as LF
500
+ else
501
+ @line += 1 if c == "\n"
502
+ buf << c.b
503
+ @i += 1
504
+ end
505
+ end
506
+ flush.call
507
+ parts
508
+ end
509
+
510
+ def read_escape(buf)
511
+ d = @s[@i + 1]
512
+ error("unterminated string") if d.nil?
513
+ @i += 2
514
+ if ESCAPES[d]
515
+ buf << ESCAPES[d]
516
+ elsif d.match?(/[0-7]/)
517
+ digits = d
518
+ while digits.length < 3 && @s[@i]&.match?(/[0-7]/)
519
+ digits << @s[@i]
520
+ @i += 1
521
+ end
522
+ buf << (digits.to_i(8) & 0xFF).chr
523
+ elsif d == "x"
524
+ digits = +""
525
+ while digits.length < 2 && @s[@i]&.match?(/[0-9a-fA-F]/)
526
+ digits << @s[@i]
527
+ @i += 1
528
+ end
529
+ error("invalid hex escape") if digits.empty?
530
+ buf << digits.to_i(16).chr
531
+ elsif d == "u"
532
+ if @s[@i] == "{"
533
+ close = @s.index("}", @i)
534
+ error("unterminated Unicode escape") unless close
535
+ codes = @s[(@i + 1)...close].strip.split(/[ \t]+/)
536
+ error("invalid Unicode escape") if codes.empty?
537
+ codes.each { |h| buf << codepoint(h) }
538
+ @i = close + 1
539
+ else
540
+ h = @s[@i, 4]
541
+ error("invalid Unicode escape") unless h&.match?(/\A[0-9a-fA-F]{4}\z/)
542
+ buf << codepoint(h)
543
+ @i += 4
544
+ end
545
+ elsif d == "\n"
546
+ @line += 1
547
+ elsif d == "\r" && @s[@i] == "\n"
548
+ @i += 1
549
+ @line += 1
550
+ else
551
+ buf << d.b
552
+ end
553
+ end
554
+
555
+ def codepoint(hex)
556
+ error("invalid Unicode escape") unless hex.match?(/\A[0-9a-fA-F]{1,6}\z/)
557
+ cp = hex.to_i(16)
558
+ error("invalid Unicode codepoint") if cp > 0x10FFFF || (0xD800..0xDFFF).cover?(cp)
559
+ [cp].pack("U").b
560
+ end
561
+
562
+ # -- symbols -------------------------------------------------------------
563
+
564
+ def lex_colon(spc)
565
+ line = @line
566
+ nxt = @s[@i + 1]
567
+ if nxt == ":"
568
+ @i += 2
569
+ push(:op, "::", spc, line)
570
+ elsif nxt == '"'
571
+ @i += 2
572
+ parts = lex_dstring_body('"')
573
+ push(:dsym, parts, spc, line)
574
+ elsif nxt == "'"
575
+ @i += 1
576
+ start_count = @out.size
577
+ lex_sstring(false)
578
+ t = @out.pop
579
+ error("invalid symbol") unless @out.size == start_count && t.type == :str
580
+ push(:dsym, t.value, spc, line)
581
+ elsif nxt&.match?(/[A-Za-z_]/) || (nxt == "@" || nxt == "$") && @s[@i + 2]&.match?(/[A-Za-z_@]/)
582
+ @i += 1
583
+ start = @i
584
+ @i += 1 while @s[@i] == "@" || @s[@i] == "$"
585
+ @i += 1 while @i < @s.length && @s[@i].match?(/[A-Za-z0-9_]/)
586
+ if @s[@i] == "?" || @s[@i] == "!"
587
+ @i += 1 unless @s[@i + 1] == "="
588
+ elsif @s[@i] == "=" && !["=", "~", ">"].include?(@s[@i + 1])
589
+ @i += 1
590
+ end
591
+ push(:sym, @s[start...@i], spc, line)
592
+ else
593
+ op = SYMBOL_OPS.find { |o| @s[@i + 1, o.length] == o }
594
+ if op
595
+ @i += 1 + op.length
596
+ push(:sym, op, spc, line)
597
+ else
598
+ @i += 1
599
+ push(:op, ":", spc, line)
600
+ end
601
+ end
602
+ end
603
+
604
+ # -- %w[] / %i[] -----------------------------------------------------------
605
+
606
+ def lex_words(spc)
607
+ line = @line
608
+ kind = @s[@i + 1]
609
+ open = @s[@i + 2]
610
+ close = WORD_DELIMS[open]
611
+ @i += 3
612
+ words = []
613
+ cur = nil
614
+ depth = 0
615
+ loop do
616
+ c = @s[@i]
617
+ error("unterminated %#{kind} list", line) if c.nil?
618
+ if c == "\\" && @s[@i + 1]
619
+ d = @s[@i + 1]
620
+ cur ||= +""
621
+ if d.match?(/\s/) || d == open || d == close || d == "\\"
622
+ cur << d
623
+ else
624
+ cur << c << d
625
+ end
626
+ @line += 1 if d == "\n"
627
+ @i += 2
628
+ next
629
+ end
630
+ if c == close && depth.zero?
631
+ @i += 1
632
+ break
633
+ end
634
+ if c.match?(/\s/)
635
+ @line += 1 if c == "\n"
636
+ words << cur if cur
637
+ cur = nil
638
+ else
639
+ depth += 1 if c == open
640
+ depth -= 1 if c == close
641
+ (cur ||= +"") << c
642
+ end
643
+ @i += 1
644
+ end
645
+ words << cur if cur
646
+ push(kind == "w" ? :words : :symwords, words, spc, line)
647
+ end
648
+ end
649
+ end
@@ -0,0 +1,46 @@
1
+ # Copyright 2026 OpenC3, Inc.
2
+ # All Rights Reserved.
3
+ #
4
+ # This program is distributed in the hope that it will be useful,
5
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
6
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
7
+ # See LICENSE.md for more details.
8
+
9
+ # frozen_string_literal: true
10
+
11
+ module ERBLite
12
+ # AST node types. Every node carries the 1-based template line it came from.
13
+ module Nodes
14
+ Text = Struct.new(:text, :line) # literal template text
15
+ Output = Struct.new(:expr, :line) # <%= expr %>
16
+ Seq = Struct.new(:stmts, :line) # statement list
17
+ Lit = Struct.new(:value, :line) # nil/true/false/Integer/Float/Symbol
18
+ Str = Struct.new(:parts, :line) # parts: String | node
19
+ DSym = Struct.new(:parts, :line) # :"..."
20
+ Arr = Struct.new(:elems, :line)
21
+ HashLit = Struct.new(:pairs, :line) # [[key_node, value_node], ...]
22
+ RangeLit = Struct.new(:lo, :hi, :excl, :line)
23
+ LVar = Struct.new(:name, :depth, :line) # local variable read
24
+ LAsgn = Struct.new(:name, :depth, :value, :line)
25
+ MAsgn = Struct.new(:targets, :values, :line) # targets: [[name, depth]]
26
+ OpAsgn = Struct.new(:target, :op, :value, :line) # a op= b, a ||= b, h[k] += 1
27
+ IndexAsgn = Struct.new(:recv, :args, :value, :line) # a[i] = v
28
+ AttrAsgn = Struct.new(:recv, :name, :value, :line) # a.b = v
29
+ Call = Struct.new(:recv, :name, :args, :block, :safe, :line)
30
+ FCall = Struct.new(:name, :args, :block, :line) # receiverless call: format(...)
31
+ VCall = Struct.new(:name, :line) # bare identifier, not a local
32
+ Const = Struct.new(:name, :line)
33
+ Colon2 = Struct.new(:scope, :name, :line) # Math::PI
34
+ And = Struct.new(:left, :right, :line)
35
+ Or = Struct.new(:left, :right, :line)
36
+ Not = Struct.new(:expr, :line)
37
+ If = Struct.new(:cond, :thn, :els, :line)
38
+ While = Struct.new(:cond, :body, :neg, :line) # neg: until
39
+ For = Struct.new(:vars, :iter, :body, :line) # vars: [[name, depth]]
40
+ Case = Struct.new(:subject, :whens, :els, :line) # whens: [[conds, body]]
41
+ Break = Struct.new(:value, :kind, :line) # kind: :loop or :block
42
+ Next = Struct.new(:value, :kind, :line)
43
+ BlockNode = Struct.new(:params, :body, :line) # params: String | nested Array
44
+ BlockPass = Struct.new(:name, :line) # &:sym
45
+ end
46
+ end