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.
- checksums.yaml +7 -0
- data/LICENSE.md +21 -0
- data/README.md +19 -0
- data/bin/erblite +53 -0
- data/lib/erblite/builtins.rb +1332 -0
- data/lib/erblite/error.rb +52 -0
- data/lib/erblite/format.rb +212 -0
- data/lib/erblite/interpreter.rb +486 -0
- data/lib/erblite/json.rb +331 -0
- data/lib/erblite/lexer.rb +649 -0
- data/lib/erblite/nodes.rb +46 -0
- data/lib/erblite/parser.rb +953 -0
- data/lib/erblite/sandbox.rb +192 -0
- data/lib/erblite/values.rb +139 -0
- data/lib/erblite/version.rb +13 -0
- data/lib/erblite.rb +134 -0
- metadata +57 -0
|
@@ -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
|