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,486 @@
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
+ # Render-wide state shared by a template and all of its partials.
13
+ class State
14
+ attr_accessor :steps
15
+ attr_reader :max_steps, :base_dir, :real_base, :cache
16
+
17
+ def initialize(base_dir, max_steps)
18
+ @steps = 0
19
+ @max_steps = max_steps
20
+ @base_dir = base_dir
21
+ @real_base = nil
22
+ if base_dir
23
+ begin
24
+ @real_base = File.realpath(base_dir.to_s)
25
+ rescue SystemCallError => e
26
+ raise Error.new("IOError", "base_dir is not accessible: #{e.message}")
27
+ end
28
+ end
29
+ @cache = {}
30
+ end
31
+ end
32
+
33
+ # Tree-walking interpreter. Every method call on a template value goes
34
+ # through call_method, which only dispatches through Builtins' allowlist
35
+ # tables.
36
+ class Interpreter
37
+ include Nodes
38
+
39
+ NO_RECV = Object.new.freeze
40
+ EMPTY = [].freeze
41
+ MAX_RENDER_DEPTH = 32
42
+ # Methods that ignore what their block returns.
43
+ BLOCK_RESULT_UNUSED = %w[each each_with_index each_with_object each_slice each_cons each_char each_line
44
+ each_pair each_key each_value each_index reverse_each times upto downto step
45
+ loop tap].to_h { |m| [m, true] }.freeze
46
+
47
+ attr_reader :state, :filename, :depth
48
+
49
+ def initialize(state, filename, depth = 0)
50
+ @state = state
51
+ @filename = filename
52
+ @depth = depth
53
+ @loops = []
54
+ @line = 1
55
+ @out = +""
56
+ end
57
+
58
+ def run(ast, vars)
59
+ @want = false
60
+ env = Env.new(nil)
61
+ vars.each { |k, v| env.vars[k] = v }
62
+ ev(ast, env)
63
+ @out
64
+ end
65
+
66
+ def step!
67
+ st = @state
68
+ st.steps += 1
69
+ return unless st.max_steps && st.steps > st.max_steps
70
+
71
+ raise Error.new("LimitError", "step limit of #{st.max_steps} exceeded", @filename, @line)
72
+ end
73
+
74
+ def truthy(v)
75
+ !(v.nil? || false.equal?(v))
76
+ end
77
+
78
+ # A Ruby block that forwards to a template block, keeping the exact
79
+ # number of yielded values.
80
+ def bp(blk)
81
+ return nil unless blk
82
+
83
+ proc { |*a| blk.call(*a) }
84
+ end
85
+
86
+ # -- evaluation ------------------------------------------------------------
87
+
88
+ attr_writer :want
89
+
90
+ # @want is true when the value of the node being evaluated can be used.
91
+ # Text and <%= %> statements only build their value (a snapshot of the
92
+ # output buffer [ERBLite]) when it is wanted, so loops that end in text
93
+ # do not copy the whole buffer on every iteration.
94
+ def ev(n, env)
95
+ step!
96
+ w = @want
97
+ @want = true
98
+ case n
99
+ when Call then ev_call(n, env)
100
+ when LVar then env_at(env, n.depth).vars[n.name]
101
+ when Lit then n.value
102
+ when Str then ev_str(n.parts, env)
103
+ when Seq
104
+ r = nil
105
+ last = n.stmts.length - 1
106
+ n.stmts.each_with_index do |s, i|
107
+ @want = i == last ? w : false
108
+ r = ev(s, env)
109
+ end
110
+ r
111
+ when Text
112
+ @out << n.text
113
+ w ? @out.dup : nil
114
+ when Output
115
+ v = ev(n.expr, env)
116
+ @line = n.line
117
+ @out << Builtins.to_s(v)
118
+ w ? @out.dup : nil
119
+ when LAsgn then env_at(env, n.depth).vars[n.name] = ev(n.value, env)
120
+ when If
121
+ c = truthy(ev(n.cond, env))
122
+ @want = w
123
+ if c
124
+ n.thn ? ev(n.thn, env) : nil
125
+ else
126
+ n.els ? ev(n.els, env) : nil
127
+ end
128
+ when And
129
+ l = ev(n.left, env)
130
+ truthy(l) ? ev(n.right, env) : l
131
+ when Or
132
+ l = ev(n.left, env)
133
+ truthy(l) ? l : ev(n.right, env)
134
+ when Not then !truthy(ev(n.expr, env))
135
+ when FCall then ev_fcall(n, env)
136
+ when VCall then ev_vcall(n, env)
137
+ when Arr then n.elems.map { |e| ev(e, env) }
138
+ when HashLit
139
+ h = {}
140
+ n.pairs.each { |k, v| h[ev(k, env)] = ev(v, env) }
141
+ h
142
+ when RangeLit
143
+ lo = ev(n.lo, env)
144
+ hi = n.hi ? ev(n.hi, env) : nil
145
+ @line = n.line
146
+ Builtins.make_range(lo, hi, n.excl)
147
+ when OpAsgn then ev_opasgn(n, env)
148
+ when IndexAsgn
149
+ r = ev(n.recv, env)
150
+ args = n.args.map { |a| ev(a, env) }
151
+ v = ev(n.value, env)
152
+ @line = n.line
153
+ invoke(r, "[]=", args + [v], nil, env, n.line)
154
+ v
155
+ when AttrAsgn
156
+ r = ev(n.recv, env)
157
+ v = ev(n.value, env)
158
+ @line = n.line
159
+ invoke(r, "#{n.name}=", [v], nil, env, n.line)
160
+ v
161
+ when MAsgn then ev_masgn(n, env)
162
+ when While then ev_while(n, env)
163
+ when For then ev_for(n, env)
164
+ when Case then ev_case(n, env, w)
165
+ when Break
166
+ v = n.value ? ev(n.value, env) : nil
167
+ throw(n.kind == :loop ? @loops.last[0] : env.blk, v)
168
+ when Next
169
+ v = n.value ? ev(n.value, env) : nil
170
+ throw(n.kind == :loop ? @loops.last[1] : env.blk.next_tag, v)
171
+ when Const
172
+ @line = n.line
173
+ Builtins.lookup_constant(n.name) ||
174
+ raise(Error.new("NameError", "uninitialized constant #{n.name}", @filename, n.line))
175
+ when Colon2
176
+ scope = ev(n.scope, env)
177
+ @line = n.line
178
+ Builtins.scoped_constant(scope, n.name, @filename, n.line)
179
+ when DSym then ev_str(n.parts, env).to_sym
180
+ else
181
+ raise Error.new("SyntaxError", "unsupported construct", @filename, @line)
182
+ end
183
+ rescue Error => e
184
+ e.fill(@filename, n.line)
185
+ raise
186
+ end
187
+
188
+ def env_at(env, depth)
189
+ depth.times { env = env.parent }
190
+ env
191
+ end
192
+
193
+ def ev_str(parts, env)
194
+ return parts[0].dup if parts.length == 1 && parts[0].is_a?(String)
195
+
196
+ s = +""
197
+ parts.each { |p| s << (p.is_a?(String) ? p : Builtins.to_s(ev(p, env))) }
198
+ s
199
+ end
200
+
201
+ def ev_call(n, env)
202
+ return ev_shovel(n, env)[0] if n.name == "<<" && n.args.length == 1 && n.block.nil?
203
+
204
+ recv = ev(n.recv, env)
205
+ return nil if n.safe && recv.nil?
206
+
207
+ args = n.args.empty? ? EMPTY : n.args.map { |a| ev(a, env) }
208
+ @line = n.line
209
+ invoke(recv, n.name, args, n.block, env, n.line)
210
+ end
211
+
212
+ def ev_fcall(n, env)
213
+ args = n.args.empty? ? EMPTY : n.args.map { |a| ev(a, env) }
214
+ @line = n.line
215
+ invoke(NO_RECV, n.name, args, n.block, env, n.line)
216
+ end
217
+
218
+ def ev_vcall(n, env)
219
+ @line = n.line
220
+ return invoke(NO_RECV, n.name, EMPTY, nil, env, n.line) if Builtins::FUNCS[n.name]
221
+
222
+ raise Error.new("NameError", "undefined local variable or method '#{n.name}' for main", @filename, n.line)
223
+ end
224
+
225
+ # Calls +name+ on +recv+ (or the function +name+ when recv is NO_RECV),
226
+ # setting up the template block if there is one. `break` inside the
227
+ # block is a throw to the UserBlock, caught here.
228
+ def invoke(recv, name, args, bnode, env, line)
229
+ if bnode.is_a?(BlockNode)
230
+ blk = UserBlock.new(self, bnode, env, !(BLOCK_RESULT_UNUSED[name] && !recv.is_a?(Enum)))
231
+ catch(blk) { dispatch(recv, name, args, blk) }
232
+ elsif bnode
233
+ dispatch(recv, name, args, SymBlock.new(self, bnode.name))
234
+ else
235
+ dispatch(recv, name, args, nil)
236
+ end
237
+ rescue Error => e
238
+ e.fill(@filename, line)
239
+ raise
240
+ end
241
+
242
+ def dispatch(recv, name, args, blk)
243
+ recv.equal?(NO_RECV) ? call_function(name, args, blk) : call_method(recv, name, args, blk)
244
+ end
245
+
246
+ def call_function(name, args, blk)
247
+ f = Builtins::FUNCS[name]
248
+ raise Error.new("NoMethodError", "undefined method '#{name}' for main:Object") unless f
249
+
250
+ Builtins.check_arity(f, args.length)
251
+ f.fn.call(self, nil, args, blk)
252
+ rescue Error
253
+ raise
254
+ rescue ::StandardError => e
255
+ raise Builtins.map_error(e)
256
+ end
257
+
258
+ def call_method(recv, name, args, blk)
259
+ m = Builtins.lookup(recv, name)
260
+ unless m
261
+ raise Error.new("NoMethodError", "undefined method '#{name}' for #{Builtins.describe(recv)}")
262
+ end
263
+
264
+ Builtins.check_arity(m, args.length)
265
+ return Enum.new(recv, name, args.dup) if blk.nil? && Builtins.enumerator_call?(recv, name, args)
266
+
267
+ m.fn.call(self, recv, args, blk)
268
+ rescue Error
269
+ raise
270
+ rescue ::StandardError => e
271
+ raise Builtins.map_error(e)
272
+ end
273
+
274
+ def responds?(recv, name)
275
+ !Builtins.lookup(recv, name).nil?
276
+ end
277
+
278
+ # -- assignment helpers ------------------------------------------------------
279
+
280
+ # Evaluates an expression that may be written back to (for `<<` on
281
+ # strings and op-assignment). Returns [value, setter or nil].
282
+ def lvalue(node, env)
283
+ case node
284
+ when LVar
285
+ target = env_at(env, node.depth)
286
+ [target.vars[node.name], ->(x) { target.vars[node.name] = x }]
287
+ when Call
288
+ if node.name == "[]" && node.recv && !node.safe && node.block.nil?
289
+ r = ev(node.recv, env)
290
+ a = node.args.map { |x| ev(x, env) }
291
+ @line = node.line
292
+ v = invoke(r, "[]", a, nil, env, node.line)
293
+ [v, lambda { |x|
294
+ invoke(r, "[]=", a + [x], nil, env, node.line)
295
+ x
296
+ }]
297
+ elsif node.name == "<<" && node.args.length == 1 && node.block.nil?
298
+ ev_shovel(node, env)
299
+ else
300
+ [ev(node, env), nil]
301
+ end
302
+ else
303
+ [ev(node, env), nil]
304
+ end
305
+ end
306
+
307
+ # `a << b`. Strings are values [ERBLite]: when +a+ is a String, the new
308
+ # string is assigned back to whatever +a+ was read from.
309
+ def ev_shovel(n, env)
310
+ step!
311
+ v, setter = lvalue(n.recv, env)
312
+ arg = ev(n.args[0], env)
313
+ @line = n.line
314
+ res = invoke(v, "<<", [arg], nil, env, n.line)
315
+ if v.is_a?(String)
316
+ setter&.call(res)
317
+ [res, setter]
318
+ else
319
+ [res, nil]
320
+ end
321
+ end
322
+
323
+ def ev_opasgn(n, env)
324
+ t = n.target
325
+ if t.is_a?(Call) && t.name != "[]"
326
+ r = ev(t.recv, env)
327
+ @line = n.line
328
+ v = invoke(r, t.name, EMPTY, nil, env, n.line)
329
+ setter = lambda { |x|
330
+ invoke(r, "#{t.name}=", [x], nil, env, n.line)
331
+ x
332
+ }
333
+ else
334
+ v, setter = lvalue(t, env)
335
+ end
336
+ case n.op
337
+ when "||" then truthy(v) ? v : setter.call(ev(n.value, env))
338
+ when "&&" then truthy(v) ? setter.call(ev(n.value, env)) : v
339
+ else
340
+ rhs = ev(n.value, env)
341
+ @line = n.line
342
+ setter.call(invoke(v, n.op, [rhs], nil, env, n.line))
343
+ end
344
+ end
345
+
346
+ def ev_masgn(n, env)
347
+ if n.values.length == 1
348
+ result = ev(n.values[0], env)
349
+ arr = result.is_a?(Array) ? result : [result]
350
+ else
351
+ result = arr = n.values.map { |v| ev(v, env) }
352
+ end
353
+ n.targets.each_with_index { |(name, depth), i| env_at(env, depth).vars[name] = arr[i] }
354
+ result
355
+ end
356
+
357
+ # -- control flow ------------------------------------------------------------
358
+
359
+ def ev_while(n, env)
360
+ brk = Object.new
361
+ nxt = Object.new
362
+ @loops.push([brk, nxt])
363
+ begin
364
+ catch(brk) do
365
+ while true # rubocop:disable Style/InfiniteLoop
366
+ c = truthy(ev(n.cond, env))
367
+ break if n.neg ? c : !c
368
+
369
+ step!
370
+ @want = false
371
+ catch(nxt) { ev(n.body, env) }
372
+ end
373
+ nil
374
+ end
375
+ ensure
376
+ @loops.pop
377
+ end
378
+ end
379
+
380
+ def ev_for(n, env)
381
+ iter = ev(n.iter, env)
382
+ brk = Object.new
383
+ nxt = Object.new
384
+ @loops.push([brk, nxt])
385
+ begin
386
+ catch(brk) do
387
+ blk = NativeBlock.new do |*vals|
388
+ step!
389
+ v = vals.length == 1 ? vals[0] : vals
390
+ if n.vars.length == 1
391
+ name, depth = n.vars[0]
392
+ env_at(env, depth).vars[name] = v
393
+ else
394
+ arr = v.is_a?(Array) ? v : [v]
395
+ n.vars.each_with_index { |(name, depth), i| env_at(env, depth).vars[name] = arr[i] }
396
+ end
397
+ @want = false
398
+ catch(nxt) { ev(n.body, env) }
399
+ nil
400
+ end
401
+ @line = n.line
402
+ begin
403
+ call_method(iter, "each", EMPTY, blk)
404
+ rescue Error => e
405
+ e.fill(@filename, n.line)
406
+ raise
407
+ end
408
+ iter
409
+ end
410
+ ensure
411
+ @loops.pop
412
+ end
413
+ end
414
+
415
+ def ev_case(n, env, want)
416
+ v = n.subject ? ev(n.subject, env) : nil
417
+ n.whens.each do |conds, body|
418
+ conds.each do |c|
419
+ hit = n.subject ? case_eq(ev(c, env), v) : truthy(ev(c, env))
420
+ next unless hit
421
+
422
+ @want = want
423
+ return ev(body, env)
424
+ end
425
+ end
426
+ @want = want
427
+ n.els ? ev(n.els, env) : nil
428
+ end
429
+
430
+ def case_eq(pat, v)
431
+ Builtins.case_eq(pat, v)
432
+ end
433
+
434
+ # -- enumerators -------------------------------------------------------------
435
+
436
+ def enum_each(e, blk)
437
+ call_method(e.recv, e.meth, e.args, blk)
438
+ end
439
+
440
+ def enum_to_a(e)
441
+ res = []
442
+ enum_each(e, NativeBlock.new { |*v|
443
+ res << (v.length == 1 ? v[0] : v)
444
+ nil
445
+ })
446
+ res
447
+ end
448
+
449
+ # The collection used by the shared Enumerable methods.
450
+ def coll(r)
451
+ case r
452
+ when Enum then enum_to_a(r)
453
+ when Range
454
+ # Native Enumerable methods walk the whole range without counting
455
+ # steps, so bound the work like any other generated collection.
456
+ Builtins.cap(r.size) if r.begin.is_a?(Integer) && r.end.is_a?(Integer)
457
+ r
458
+ else r
459
+ end
460
+ end
461
+
462
+ # -- partials ------------------------------------------------------------------
463
+
464
+ def render_partial(path, locals)
465
+ if @depth + 1 > MAX_RENDER_DEPTH
466
+ raise Error.new("LimitError", "render nesting is deeper than #{MAX_RENDER_DEPTH}")
467
+ end
468
+ raise Error.new("TypeError", "render locals must be a Hash") unless locals.is_a?(Hash)
469
+
470
+ vars = {}
471
+ locals.each do |k, v|
472
+ unless k.is_a?(Symbol) || k.is_a?(String)
473
+ raise Error.new("TypeError", "render local names must be Symbols or Strings")
474
+ end
475
+ vars[k.to_s] = v
476
+ end
477
+ source = Sandbox.read(@state, path)
478
+ key = [Sandbox.resolve(@state, path), vars.keys.sort]
479
+ ast = @state.cache[key] ||= begin
480
+ src = ERBLite.normalize_source(source, path)
481
+ Parser.parse(Lexer.tokenize(src, path), path, vars.keys)
482
+ end
483
+ Interpreter.new(@state, path, @depth + 1).run(ast, vars)
484
+ end
485
+ end
486
+ end