barefoot_js 0.18.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/lib/barefoot_js/backend/erb.rb +123 -0
- data/lib/barefoot_js/dev_reload.rb +159 -0
- data/lib/barefoot_js/evaluator.rb +714 -0
- data/lib/barefoot_js/search_params.rb +63 -0
- data/lib/barefoot_js.rb +1155 -0
- metadata +51 -0
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module BarefootJS
|
|
6
|
+
# Lightweight evaluator for the pure `ParsedExpr` subset, scoped to
|
|
7
|
+
# higher-order callback bodies (reduce / sort / map / filter / find
|
|
8
|
+
# `(...) => expr`) -- issue #2018. Templates cannot carry a lambda in
|
|
9
|
+
# expression position, which is why the adapters historically special-cased
|
|
10
|
+
# these callbacks into fixed shapes (bf.sort's comparator catalogue,
|
|
11
|
+
# bf.reduce's +/* fold). Instead, the callback BODY rides as a pure
|
|
12
|
+
# `ParsedExpr` subtree (the structured IR the compiler already produces) and
|
|
13
|
+
# is evaluated here against an environment (`{acc, item, ...captured free
|
|
14
|
+
# vars}`).
|
|
15
|
+
#
|
|
16
|
+
# Ruby port of BarefootJS::Evaluator (Perl), sharing the same contract as
|
|
17
|
+
# the Go evaluator (bf.go). The accepted subset and its semantics are
|
|
18
|
+
# documented in spec/compiler.md ("ParsedExpr Evaluator Semantics") and
|
|
19
|
+
# pinned isomorphically by the cross-language golden vectors
|
|
20
|
+
# (packages/adapter-tests/vectors/eval-vectors.json). The literal
|
|
21
|
+
# JS reference implementation is eval-reference.ts -- this port follows it
|
|
22
|
+
# node-for-node, including its refusal behaviour (EvalUnsupported), which
|
|
23
|
+
# is a closer contract match than the Perl port's silent-nil shortcuts
|
|
24
|
+
# (Perl blurs strings/numbers and can't cheaply enforce every refusal;
|
|
25
|
+
# Ruby's real type distinctions make strict refusal free).
|
|
26
|
+
#
|
|
27
|
+
# Value domain: JSON-shaped Ruby data with SYMBOL hash keys throughout
|
|
28
|
+
# (object literals, environments, member/index results). AST nodes
|
|
29
|
+
# (ParsedExpr, decoded from JSON) also use symbol keys -- `node[:kind]`,
|
|
30
|
+
# `node[:left]`, etc. String KEYS from the AST that name environment
|
|
31
|
+
# bindings or object fields (identifier names, `member.property`,
|
|
32
|
+
# `object-literal` property keys) are plain Ruby Strings coming out of the
|
|
33
|
+
# parser; they are converted to Symbols at the point they touch a
|
|
34
|
+
# SYMBOL-keyed Hash (env or object value).
|
|
35
|
+
module Evaluator
|
|
36
|
+
# Thrown when a node/operator/builtin/identifier is outside the subset.
|
|
37
|
+
class EvalUnsupported < StandardError; end
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# evaluate(node, env) -> a Ruby value (Integer/Float, String, true/false,
|
|
42
|
+
# nil, Array, Hash-with-symbol-keys) per the ParsedExpr AST node kind.
|
|
43
|
+
def evaluate(node, env)
|
|
44
|
+
return nil unless node.is_a?(Hash)
|
|
45
|
+
kind = node[:kind]
|
|
46
|
+
|
|
47
|
+
case kind
|
|
48
|
+
when 'literal'
|
|
49
|
+
node[:value]
|
|
50
|
+
when 'identifier'
|
|
51
|
+
name = node[:name]
|
|
52
|
+
key = name.to_sym
|
|
53
|
+
raise EvalUnsupported, "unbound identifier '#{name}'" unless env.key?(key)
|
|
54
|
+
env[key]
|
|
55
|
+
when 'binary'
|
|
56
|
+
binary(node[:op], evaluate(node[:left], env), evaluate(node[:right], env))
|
|
57
|
+
when 'unary'
|
|
58
|
+
unary(node[:op], evaluate(node[:argument], env))
|
|
59
|
+
when 'logical'
|
|
60
|
+
op = node[:op]
|
|
61
|
+
left = evaluate(node[:left], env)
|
|
62
|
+
case op
|
|
63
|
+
when '&&' then truthy?(left) ? evaluate(node[:right], env) : left
|
|
64
|
+
when '||' then truthy?(left) ? left : evaluate(node[:right], env)
|
|
65
|
+
else left.nil? ? evaluate(node[:right], env) : left # '??'
|
|
66
|
+
end
|
|
67
|
+
when 'conditional'
|
|
68
|
+
truthy?(evaluate(node[:test], env)) ? evaluate(node[:consequent], env) : evaluate(node[:alternate], env)
|
|
69
|
+
when 'member'
|
|
70
|
+
read_property(evaluate(node[:object], env), node[:property])
|
|
71
|
+
when 'index-access'
|
|
72
|
+
read_index(evaluate(node[:object], env), evaluate(node[:index], env))
|
|
73
|
+
when 'call'
|
|
74
|
+
# A nested `.map(cb)` / `.filter(cb)` callback call (#2094):
|
|
75
|
+
# syntactically a `call` whose callee is `<recv>.map`/`<recv>.filter`
|
|
76
|
+
# and whose first argument is an `arrow` node -- the same shape
|
|
77
|
+
# `asCallbackMethodCall` recognizes at compile time, and the shape
|
|
78
|
+
# the `eval-vectors.json` golden corpus itself carries. Checked
|
|
79
|
+
# BEFORE the builtin-name check below, since `<recv>.map` would
|
|
80
|
+
# otherwise resolve to a non-builtin member callee and raise.
|
|
81
|
+
method, object_node, arrow_node = array_callback_call(node)
|
|
82
|
+
if method
|
|
83
|
+
array_callback(method, object_node, arrow_node, env)
|
|
84
|
+
else
|
|
85
|
+
name = builtin_name(node[:callee])
|
|
86
|
+
raise EvalUnsupported, 'only built-in calls (Math.*, String/Number/Boolean) are in the subset' if name.nil?
|
|
87
|
+
args = (node[:args] || []).map { |a| evaluate(a, env) }
|
|
88
|
+
call_builtin(name, args)
|
|
89
|
+
end
|
|
90
|
+
when 'template-literal'
|
|
91
|
+
out = +''
|
|
92
|
+
(node[:parts] || []).each do |p|
|
|
93
|
+
out << if p[:type] == 'string'
|
|
94
|
+
(p[:value] || '')
|
|
95
|
+
else
|
|
96
|
+
to_string(evaluate(p[:expr], env))
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
out
|
|
100
|
+
when 'array-literal'
|
|
101
|
+
(node[:elements] || []).map { |e| evaluate(e, env) }
|
|
102
|
+
when 'object-literal'
|
|
103
|
+
out = {}
|
|
104
|
+
(node[:properties] || []).each { |prop| out[prop[:key].to_sym] = evaluate(prop[:value], env) }
|
|
105
|
+
out
|
|
106
|
+
when 'array-method'
|
|
107
|
+
args = node[:args] || []
|
|
108
|
+
if node[:method] == 'includes' && args.length == 1
|
|
109
|
+
# `.includes(x)` (#2075) -- the one `array-method` in the
|
|
110
|
+
# evaluator subset, shared between `Array.prototype.includes`
|
|
111
|
+
# (SameValueZero membership) and `String.prototype.includes`
|
|
112
|
+
# (substring search), matching the receiver-type dispatch the SSR
|
|
113
|
+
# template lowering does at runtime (`bf.includes`). Mirrors the
|
|
114
|
+
# JS reference's `includes()` (eval-reference.ts).
|
|
115
|
+
includes_value(evaluate(node[:object], env), evaluate(args[0], env))
|
|
116
|
+
elsif node[:method] == 'join' && args.length <= 1
|
|
117
|
+
# `.join(sep?)` (#2094) -- a nested `.flatMap(p => p.tags.map(...))
|
|
118
|
+
# .join(...)` projection composes a `.join` on top of a nested
|
|
119
|
+
# `.map`, so it must be executable in the same evaluator subset as
|
|
120
|
+
# `.includes`. Default separator "," (JS); a `nil` element joins
|
|
121
|
+
# as "" (not the string "null"). Mirrors Go's `evalJoin`.
|
|
122
|
+
sep = args.empty? ? ',' : to_string(evaluate(args[0], env))
|
|
123
|
+
array_join(evaluate(node[:object], env), sep)
|
|
124
|
+
else
|
|
125
|
+
# Every other array/string method (`slice`, `flat`, ...) is
|
|
126
|
+
# outside the subset; a callback body containing one is refused
|
|
127
|
+
# upstream (BF101) and should never reach here, but the evaluator
|
|
128
|
+
# refuses explicitly rather than falling through silently,
|
|
129
|
+
# matching the JS reference.
|
|
130
|
+
raise EvalUnsupported, "array-method '#{node[:method]}' is not in the evaluator subset"
|
|
131
|
+
end
|
|
132
|
+
else
|
|
133
|
+
raise EvalUnsupported, "node kind '#{kind}' is not in the evaluator subset"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# eval_json(json, env): decode a ParsedExpr JSON string and evaluate it.
|
|
138
|
+
# `env` is a plain Ruby Hash with symbol keys (caller's responsibility,
|
|
139
|
+
# matching the SYMBOL-keys-throughout value convention).
|
|
140
|
+
def eval_json(json, env)
|
|
141
|
+
evaluate(JSON.parse(json, symbolize_names: true), env)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------
|
|
145
|
+
# JS coercion primitives (ToNumber / ToString / ToBoolean).
|
|
146
|
+
# ---------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def to_number(v)
|
|
149
|
+
return 0 if v.nil?
|
|
150
|
+
return v ? 1 : 0 if v.is_a?(TrueClass) || v.is_a?(FalseClass)
|
|
151
|
+
return v if v.is_a?(Numeric)
|
|
152
|
+
if v.is_a?(String)
|
|
153
|
+
t = v.strip
|
|
154
|
+
return 0 if t.empty?
|
|
155
|
+
return parse_numeric_string(t)
|
|
156
|
+
end
|
|
157
|
+
raise EvalUnsupported, "cannot coerce #{v.class} to number"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def to_string(v)
|
|
161
|
+
return v if v.is_a?(String)
|
|
162
|
+
return number_to_string(v) if v.is_a?(Numeric)
|
|
163
|
+
return v ? 'true' : 'false' if v.is_a?(TrueClass) || v.is_a?(FalseClass)
|
|
164
|
+
return 'null' if v.nil?
|
|
165
|
+
raise EvalUnsupported, "cannot coerce #{v.class} to string"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def truthy?(v)
|
|
169
|
+
return false if v.nil? || v.is_a?(FalseClass)
|
|
170
|
+
return true if v.is_a?(TrueClass)
|
|
171
|
+
if v.is_a?(Numeric)
|
|
172
|
+
f = v.to_f
|
|
173
|
+
return false if f.nan? || f.zero?
|
|
174
|
+
return true
|
|
175
|
+
end
|
|
176
|
+
return v != '' if v.is_a?(String)
|
|
177
|
+
true # arrays / objects are always truthy in JS
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------
|
|
181
|
+
# Number <-> String helpers
|
|
182
|
+
# ---------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
HEX_STRING_RE = /\A0[xX][0-9a-fA-F]+\z/
|
|
185
|
+
NUMERIC_STRING_RE = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\z/
|
|
186
|
+
|
|
187
|
+
def parse_numeric_string(t)
|
|
188
|
+
return Float::INFINITY if t == 'Infinity' || t == '+Infinity'
|
|
189
|
+
return -Float::INFINITY if t == '-Infinity'
|
|
190
|
+
return Integer(t, 16) if t =~ HEX_STRING_RE
|
|
191
|
+
return Float(t) if t =~ NUMERIC_STRING_RE
|
|
192
|
+
|
|
193
|
+
Float::NAN
|
|
194
|
+
end
|
|
195
|
+
private_class_method :parse_numeric_string
|
|
196
|
+
|
|
197
|
+
# JS Number#toString. Integral finite values (however they arrived --
|
|
198
|
+
# Integer or an integral Float) render without a decimal point
|
|
199
|
+
# ("1.0" -> "1"); non-finite values use the JS spellings ("NaN" /
|
|
200
|
+
# "Infinity" / "-Infinity"), which Ruby's own Float#to_s does not use.
|
|
201
|
+
# Non-integral floats fall back to Ruby's shortest-round-trip Float#to_s
|
|
202
|
+
# (the same class of algorithm V8 uses), reformatted to JS's exponent
|
|
203
|
+
# style. This is not the full ECMA-262 Number::toString grammar (no
|
|
204
|
+
# attempt to match JS's exact exponential-notation thresholds), but it
|
|
205
|
+
# is exact for every value the golden vectors exercise.
|
|
206
|
+
def number_to_string(n)
|
|
207
|
+
f = n.to_f
|
|
208
|
+
return 'NaN' if f.nan?
|
|
209
|
+
return f.negative? ? '-Infinity' : 'Infinity' if f.infinite?
|
|
210
|
+
return '0' if f.zero?
|
|
211
|
+
return n.to_i.to_s if f == f.to_i && f.abs < 1e21
|
|
212
|
+
|
|
213
|
+
s = f.to_s
|
|
214
|
+
if s.include?('e')
|
|
215
|
+
mantissa, exp = s.split('e')
|
|
216
|
+
mantissa = mantissa.sub(/\.0\z/, '')
|
|
217
|
+
sign = exp.start_with?('-') ? '-' : '+'
|
|
218
|
+
digits = exp.sub(/\A[+-]/, '').sub(/\A0+(?=\d)/, '')
|
|
219
|
+
"#{mantissa}e#{sign}#{digits}"
|
|
220
|
+
else
|
|
221
|
+
s
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------
|
|
226
|
+
# Operators
|
|
227
|
+
# ---------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def binary(op, l, r)
|
|
230
|
+
case op
|
|
231
|
+
when '+'
|
|
232
|
+
# JS `+`: string concatenation once either operand is a string,
|
|
233
|
+
# numeric addition otherwise.
|
|
234
|
+
return to_string(l) + to_string(r) if l.is_a?(String) || r.is_a?(String)
|
|
235
|
+
|
|
236
|
+
to_number(l) + to_number(r)
|
|
237
|
+
when '-' then to_number(l) - to_number(r)
|
|
238
|
+
when '*' then to_number(l) * to_number(r)
|
|
239
|
+
when '/'
|
|
240
|
+
ln = to_number(l).to_f
|
|
241
|
+
rn = to_number(r).to_f
|
|
242
|
+
if rn.zero?
|
|
243
|
+
# JS division by zero is finite-valued, not an error.
|
|
244
|
+
if ln.zero? || ln.nan?
|
|
245
|
+
Float::NAN
|
|
246
|
+
else
|
|
247
|
+
ln.positive? ? Float::INFINITY : -Float::INFINITY
|
|
248
|
+
end
|
|
249
|
+
else
|
|
250
|
+
ln / rn
|
|
251
|
+
end
|
|
252
|
+
when '%'
|
|
253
|
+
rn = to_number(r).to_f
|
|
254
|
+
rn.zero? ? Float::NAN : to_number(l).to_f.remainder(rn)
|
|
255
|
+
when '<', '<=', '>', '>=' then relational(op, l, r)
|
|
256
|
+
when '===' then strict_eq(l, r)
|
|
257
|
+
when '!==' then !strict_eq(l, r)
|
|
258
|
+
else
|
|
259
|
+
raise EvalUnsupported, "binary operator '#{op}' is not in the evaluator subset"
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
private_class_method :binary
|
|
263
|
+
|
|
264
|
+
def relational(op, l, r)
|
|
265
|
+
# JS Abstract Relational Comparison: both strings -> compare by code
|
|
266
|
+
# unit; otherwise coerce both to numbers (a NaN operand is false).
|
|
267
|
+
c =
|
|
268
|
+
if l.is_a?(String) && r.is_a?(String)
|
|
269
|
+
l < r ? -1 : (l > r ? 1 : 0)
|
|
270
|
+
else
|
|
271
|
+
ln = to_number(l).to_f
|
|
272
|
+
rn = to_number(r).to_f
|
|
273
|
+
return false if ln.nan? || rn.nan?
|
|
274
|
+
|
|
275
|
+
ln < rn ? -1 : (ln > rn ? 1 : 0)
|
|
276
|
+
end
|
|
277
|
+
case op
|
|
278
|
+
when '<' then c < 0
|
|
279
|
+
when '<=' then c <= 0
|
|
280
|
+
when '>' then c > 0
|
|
281
|
+
when '>=' then c >= 0
|
|
282
|
+
else false
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
private_class_method :relational
|
|
286
|
+
|
|
287
|
+
def strict_eq(l, r)
|
|
288
|
+
if non_primitive?(l) || non_primitive?(r)
|
|
289
|
+
raise EvalUnsupported, '=== on a non-primitive is not in the evaluator subset'
|
|
290
|
+
end
|
|
291
|
+
return true if l.nil? && r.nil?
|
|
292
|
+
return false if l.nil? || r.nil?
|
|
293
|
+
return l == r if l.is_a?(Numeric) && r.is_a?(Numeric)
|
|
294
|
+
return l == r if boolean?(l) && boolean?(r)
|
|
295
|
+
return l == r if l.is_a?(String) && r.is_a?(String)
|
|
296
|
+
|
|
297
|
+
false
|
|
298
|
+
end
|
|
299
|
+
private_class_method :strict_eq
|
|
300
|
+
|
|
301
|
+
def non_primitive?(v)
|
|
302
|
+
v.is_a?(Array) || v.is_a?(Hash)
|
|
303
|
+
end
|
|
304
|
+
private_class_method :non_primitive?
|
|
305
|
+
|
|
306
|
+
# same_value_zero?(l, r): `Array.prototype.includes` membership test --
|
|
307
|
+
# `===` except `NaN` equals itself (and +0/-0 are not distinguished,
|
|
308
|
+
# which the JSON-decoded values here can't represent anyway). Reuses
|
|
309
|
+
# `strict_eq`'s type/value rules for the primitive cases and only
|
|
310
|
+
# special-cases the two-NaN case that `strict_eq` (deliberately, for
|
|
311
|
+
# `===`) reports as unequal. Unlike `strict_eq`, never raises for a
|
|
312
|
+
# non-primitive operand -- the JS reference's `sameValueZero` uses
|
|
313
|
+
# native `===` directly (reference equality for objects/arrays, never a
|
|
314
|
+
# throw), not the subset's throwing `strictEquals`; two freshly
|
|
315
|
+
# JSON-decoded structures are never the same object, so this degrades to
|
|
316
|
+
# `false` rather than raising. Public (unlike `strict_eq`) because
|
|
317
|
+
# `BarefootJS::Context#includes` (barefoot_js.rb) calls it directly,
|
|
318
|
+
# matching the Perl port's cross-module `_same_value_zero` use.
|
|
319
|
+
def same_value_zero?(l, r)
|
|
320
|
+
return true if l.is_a?(Numeric) && r.is_a?(Numeric) && l.to_f.nan? && r.to_f.nan?
|
|
321
|
+
|
|
322
|
+
strict_eq(l, r)
|
|
323
|
+
rescue EvalUnsupported
|
|
324
|
+
false
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# includes_value(obj, needle): the receiver-dispatch behind the
|
|
328
|
+
# `array-method` `includes` node above, factored out so
|
|
329
|
+
# `BarefootJS::Context#includes` (the runtime helper compiled templates
|
|
330
|
+
# call directly, outside any evaluator subtree) can share it too --
|
|
331
|
+
# mirrors `BarefootJS.pm::includes` delegating to
|
|
332
|
+
# `BarefootJS::Evaluator::_same_value_zero`.
|
|
333
|
+
def includes_value(obj, needle)
|
|
334
|
+
return obj.any? { |el| same_value_zero?(el, needle) } if obj.is_a?(Array)
|
|
335
|
+
return obj.include?(to_string(needle)) if obj.is_a?(String)
|
|
336
|
+
|
|
337
|
+
# Any other receiver is not a JS `.includes` target -- degrade to
|
|
338
|
+
# false rather than raising, mirroring the reference.
|
|
339
|
+
false
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# array_join(obj, sep): `.join(sep)` (#2094) -- elements ToString'd and
|
|
343
|
+
# joined; a `nil` element ToStrings to the empty string (matching JS
|
|
344
|
+
# `Array.prototype.join`, which skips null/undefined rather than
|
|
345
|
+
# rendering the literal string "null"/"undefined"). A non-array receiver
|
|
346
|
+
# degrades to the empty string (unreachable for a validated body).
|
|
347
|
+
# Mirrors Go's `evalJoin`. Private -- unlike `includes_value`, no
|
|
348
|
+
# runtime helper outside the evaluator needs this JS-strict variant
|
|
349
|
+
# (`Context#join` in barefoot_js.rb has its own SSR-oriented coercion).
|
|
350
|
+
def array_join(obj, sep)
|
|
351
|
+
return '' unless obj.is_a?(Array)
|
|
352
|
+
|
|
353
|
+
obj.map { |el| el.nil? ? '' : to_string(el) }.join(sep)
|
|
354
|
+
end
|
|
355
|
+
private_class_method :array_join
|
|
356
|
+
|
|
357
|
+
def boolean?(v)
|
|
358
|
+
v.is_a?(TrueClass) || v.is_a?(FalseClass)
|
|
359
|
+
end
|
|
360
|
+
private_class_method :boolean?
|
|
361
|
+
|
|
362
|
+
def unary(op, v)
|
|
363
|
+
case op
|
|
364
|
+
when '!' then !truthy?(v)
|
|
365
|
+
when '-' then -to_number(v)
|
|
366
|
+
when '+' then to_number(v)
|
|
367
|
+
else raise EvalUnsupported, "unary operator '#{op}' is not in the evaluator subset"
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
private_class_method :unary
|
|
371
|
+
|
|
372
|
+
# ---------------------------------------------------------------------
|
|
373
|
+
# Nested `.map`/`.filter` callback calls (#2094) -- the evaluator
|
|
374
|
+
# widening that lets a callback body itself contain a `.map`/`.filter`
|
|
375
|
+
# over another array (e.g. `.flatMap(p => p.tags.map(...))`).
|
|
376
|
+
# ---------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
# array_callback_call(node) -- reports whether the decoded `call` node
|
|
379
|
+
# is a nested `.map(cb)` / `.filter(cb)` callback call (#2094): its
|
|
380
|
+
# callee is a non-computed member `<recv>.map`/`<recv>.filter` and its
|
|
381
|
+
# first argument is an `arrow` node. Returns
|
|
382
|
+
# `[method, object_node, arrow_node]` (the still-encoded receiver and
|
|
383
|
+
# arrow), or `[nil, nil, nil]` when the node doesn't match the shape.
|
|
384
|
+
# Mirrors Go's `evalArrayCallbackCall`.
|
|
385
|
+
def array_callback_call(node)
|
|
386
|
+
callee = node[:callee]
|
|
387
|
+
return [nil, nil, nil] unless callee.is_a?(Hash) && callee[:kind] == 'member'
|
|
388
|
+
return [nil, nil, nil] if callee[:computed]
|
|
389
|
+
|
|
390
|
+
prop = callee[:property]
|
|
391
|
+
return [nil, nil, nil] unless %w[map filter].include?(prop)
|
|
392
|
+
|
|
393
|
+
raw_args = node[:args] || []
|
|
394
|
+
return [nil, nil, nil] if raw_args.empty?
|
|
395
|
+
|
|
396
|
+
arrow_node = raw_args[0]
|
|
397
|
+
return [nil, nil, nil] unless arrow_node.is_a?(Hash) && arrow_node[:kind] == 'arrow'
|
|
398
|
+
|
|
399
|
+
[prop, callee[:object], arrow_node]
|
|
400
|
+
end
|
|
401
|
+
private_class_method :array_callback_call
|
|
402
|
+
|
|
403
|
+
# array_callback(method, object_node, arrow_node, env) -- executes a
|
|
404
|
+
# nested `.map`/`.filter` callback call: evaluates the receiver, then
|
|
405
|
+
# evaluates the arrow body per element in a CHILD env (a COPY of the
|
|
406
|
+
# parent, never mutated in place across iterations) that binds the
|
|
407
|
+
# arrow's first param to the element and, when the arrow declares a
|
|
408
|
+
# second param, the second to the integer index. `map` keeps one result
|
|
409
|
+
# per element (order-preserving); `filter` keeps the elements whose body
|
|
410
|
+
# evaluates truthy. A non-array receiver degrades to `nil` (unreachable
|
|
411
|
+
# for a body the compiler validated). Mirrors Go's `evalArrayCallback`.
|
|
412
|
+
def array_callback(method, object_node, arrow_node, env)
|
|
413
|
+
arr = evaluate(object_node, env)
|
|
414
|
+
return nil unless arr.is_a?(Array)
|
|
415
|
+
|
|
416
|
+
params = arrow_node[:params] || []
|
|
417
|
+
body = arrow_node[:body]
|
|
418
|
+
call_cb = lambda do |item, index|
|
|
419
|
+
inner = env.dup
|
|
420
|
+
inner[params[0].to_sym] = item if params[0]
|
|
421
|
+
inner[params[1].to_sym] = index if params[1]
|
|
422
|
+
evaluate(body, inner)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
if method == 'map'
|
|
426
|
+
arr.each_with_index.map { |item, i| call_cb.call(item, i) }
|
|
427
|
+
else
|
|
428
|
+
arr.each_with_index.select { |item, i| truthy?(call_cb.call(item, i)) }.map { |item, _| item }
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
private_class_method :array_callback
|
|
432
|
+
|
|
433
|
+
# ---------------------------------------------------------------------
|
|
434
|
+
# Built-in calls (the deterministic allowlist). Locale-sensitive
|
|
435
|
+
# builtins (localeCompare) are deliberately excluded to keep the
|
|
436
|
+
# backends isomorphic.
|
|
437
|
+
# ---------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
def builtin_name(callee)
|
|
440
|
+
return nil unless callee.is_a?(Hash)
|
|
441
|
+
kind = callee[:kind]
|
|
442
|
+
return callee[:name] if kind == 'identifier'
|
|
443
|
+
if kind == 'member' && !callee[:computed]
|
|
444
|
+
obj = callee[:object]
|
|
445
|
+
return nil unless obj.is_a?(Hash) && obj[:kind] == 'identifier'
|
|
446
|
+
|
|
447
|
+
return "#{obj[:name]}.#{callee[:property]}"
|
|
448
|
+
end
|
|
449
|
+
nil
|
|
450
|
+
end
|
|
451
|
+
private_class_method :builtin_name
|
|
452
|
+
|
|
453
|
+
# Math.round rounds a half toward +Infinity (2.5 -> 3, -2.5 -> -2),
|
|
454
|
+
# matching the shared `round` helper rather than half-away-from-zero.
|
|
455
|
+
def math_round(n)
|
|
456
|
+
return n if n.nan? || n.infinite?
|
|
457
|
+
|
|
458
|
+
(n + 0.5).floor
|
|
459
|
+
end
|
|
460
|
+
private_class_method :math_round
|
|
461
|
+
|
|
462
|
+
def call_builtin(name, args)
|
|
463
|
+
case name
|
|
464
|
+
when 'Math.max'
|
|
465
|
+
return -Float::INFINITY if args.empty?
|
|
466
|
+
|
|
467
|
+
nums = args.map { |a| to_number(a).to_f }
|
|
468
|
+
return Float::NAN if nums.any?(&:nan?)
|
|
469
|
+
|
|
470
|
+
nums.max
|
|
471
|
+
when 'Math.min'
|
|
472
|
+
return Float::INFINITY if args.empty?
|
|
473
|
+
|
|
474
|
+
nums = args.map { |a| to_number(a).to_f }
|
|
475
|
+
return Float::NAN if nums.any?(&:nan?)
|
|
476
|
+
|
|
477
|
+
nums.min
|
|
478
|
+
when 'Math.abs' then to_number(args[0]).abs
|
|
479
|
+
when 'Math.floor'
|
|
480
|
+
n = to_number(args[0]).to_f
|
|
481
|
+
n.finite? ? n.floor : n
|
|
482
|
+
when 'Math.ceil'
|
|
483
|
+
n = to_number(args[0]).to_f
|
|
484
|
+
n.finite? ? n.ceil : n
|
|
485
|
+
when 'Math.round' then math_round(to_number(args[0]).to_f)
|
|
486
|
+
when 'String' then to_string(args[0])
|
|
487
|
+
when 'Number' then to_number(args[0])
|
|
488
|
+
when 'Boolean' then truthy?(args[0])
|
|
489
|
+
else
|
|
490
|
+
raise EvalUnsupported, "builtin '#{name}' is not in the evaluator subset"
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
private_class_method :call_builtin
|
|
494
|
+
|
|
495
|
+
# ---------------------------------------------------------------------
|
|
496
|
+
# Member / index access
|
|
497
|
+
# ---------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
def read_property(obj, key)
|
|
500
|
+
if obj.is_a?(String)
|
|
501
|
+
return obj.length if key == 'length'
|
|
502
|
+
|
|
503
|
+
raise EvalUnsupported, "property '#{key}' on a string is not in the evaluator subset"
|
|
504
|
+
end
|
|
505
|
+
if obj.is_a?(Array)
|
|
506
|
+
return obj.length if key == 'length'
|
|
507
|
+
|
|
508
|
+
raise EvalUnsupported, "property '#{key}' on an array is not in the evaluator subset"
|
|
509
|
+
end
|
|
510
|
+
if obj.is_a?(Hash)
|
|
511
|
+
sym = key.to_sym
|
|
512
|
+
return obj.key?(sym) ? obj[sym] : nil
|
|
513
|
+
end
|
|
514
|
+
raise EvalUnsupported, "cannot read property '#{key}' of #{obj.nil? ? 'null' : obj.class}"
|
|
515
|
+
end
|
|
516
|
+
private_class_method :read_property
|
|
517
|
+
|
|
518
|
+
def read_index(obj, index)
|
|
519
|
+
if obj.is_a?(Array)
|
|
520
|
+
f = to_number(index).to_f
|
|
521
|
+
return nil unless f.finite? && f == f.to_i
|
|
522
|
+
|
|
523
|
+
i = f.to_i
|
|
524
|
+
return nil if i.negative? || i >= obj.length
|
|
525
|
+
|
|
526
|
+
obj[i]
|
|
527
|
+
elsif obj.is_a?(Hash)
|
|
528
|
+
read_property(obj, to_string(index))
|
|
529
|
+
else
|
|
530
|
+
raise EvalUnsupported, "cannot index #{obj.nil? ? 'null' : obj.class}"
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
private_class_method :read_index
|
|
534
|
+
|
|
535
|
+
# ---------------------------------------------------------------------
|
|
536
|
+
# Evaluator-driven higher-order folds -- the runtime half `bf.rb` calls
|
|
537
|
+
# into for sort_eval / reduce_eval / filter_eval / etc.
|
|
538
|
+
# ---------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
# fold(items, body, acc_name, item_name, init, direction, base_env)
|
|
541
|
+
#
|
|
542
|
+
# Fold an array into a value via the evaluator. `body` is a pure
|
|
543
|
+
# ParsedExpr node evaluated against `{acc_name => acc, item_name =>
|
|
544
|
+
# item}` plus the captured free vars in `base_env`, per element.
|
|
545
|
+
# `direction` is "left" (reduce) or "right" (reduceRight).
|
|
546
|
+
def fold(items, body, acc_name, item_name, init, direction = 'left', base_env = nil)
|
|
547
|
+
arr = items.is_a?(Array) ? items : []
|
|
548
|
+
arr = arr.reverse if direction == 'right'
|
|
549
|
+
env = base_env ? base_env.dup : {}
|
|
550
|
+
acc = init
|
|
551
|
+
acc_key = acc_name.to_sym
|
|
552
|
+
item_key = item_name.to_sym
|
|
553
|
+
arr.each do |item|
|
|
554
|
+
env[acc_key] = acc
|
|
555
|
+
env[item_key] = item
|
|
556
|
+
acc = evaluate(body, env)
|
|
557
|
+
end
|
|
558
|
+
acc
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def fold_json(items, body_json, acc_name, item_name, init, direction = 'left', base_env = nil)
|
|
562
|
+
fold(items, JSON.parse(body_json, symbolize_names: true), acc_name, item_name, init, direction, base_env)
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# sort_by(items, cmp, param_a, param_b, base_env)
|
|
566
|
+
#
|
|
567
|
+
# Return a new array ordered by a ParsedExpr comparator `cmp` evaluated
|
|
568
|
+
# against `{param_a => a, param_b => b}` plus `base_env`. Stable
|
|
569
|
+
# (ties break on original index) and non-mutating.
|
|
570
|
+
def sort_by(items, cmp, param_a, param_b, base_env = nil)
|
|
571
|
+
return [] unless items.is_a?(Array)
|
|
572
|
+
|
|
573
|
+
env = base_env ? base_env.dup : {}
|
|
574
|
+
a_key = param_a.to_sym
|
|
575
|
+
b_key = param_b.to_sym
|
|
576
|
+
decorated = items.each_with_index.map { |item, i| [i, item] }
|
|
577
|
+
sorted = decorated.sort do |x, y|
|
|
578
|
+
env[a_key] = x[1]
|
|
579
|
+
env[b_key] = y[1]
|
|
580
|
+
c = to_number(evaluate(cmp, env)).to_f
|
|
581
|
+
sign = c.nan? ? 0 : (c <=> 0)
|
|
582
|
+
sign.zero? ? (x[0] <=> y[0]) : sign
|
|
583
|
+
end
|
|
584
|
+
sorted.map { |pair| pair[1] }
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def sort_by_json(items, cmp_json, param_a, param_b, base_env = nil)
|
|
588
|
+
sort_by(items, JSON.parse(cmp_json, symbolize_names: true), param_a, param_b, base_env)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# ---------------------------------------------------------------------
|
|
592
|
+
# Higher-order predicates -- the generalization of filter / find /
|
|
593
|
+
# find_index / every / some onto the evaluator. `pred` is a pure
|
|
594
|
+
# ParsedExpr evaluated against `{param => item}` plus `base_env`.
|
|
595
|
+
# ---------------------------------------------------------------------
|
|
596
|
+
|
|
597
|
+
def filter(items, pred, param, base_env = nil)
|
|
598
|
+
return [] unless items.is_a?(Array)
|
|
599
|
+
|
|
600
|
+
env = base_env ? base_env.dup : {}
|
|
601
|
+
key = param.to_sym
|
|
602
|
+
items.select do |item|
|
|
603
|
+
env[key] = item
|
|
604
|
+
truthy?(evaluate(pred, env))
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def every(items, pred, param, base_env = nil)
|
|
609
|
+
arr = items.is_a?(Array) ? items : []
|
|
610
|
+
env = base_env ? base_env.dup : {}
|
|
611
|
+
key = param.to_sym
|
|
612
|
+
arr.all? do |item|
|
|
613
|
+
env[key] = item
|
|
614
|
+
truthy?(evaluate(pred, env))
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def some(items, pred, param, base_env = nil)
|
|
619
|
+
arr = items.is_a?(Array) ? items : []
|
|
620
|
+
env = base_env ? base_env.dup : {}
|
|
621
|
+
key = param.to_sym
|
|
622
|
+
arr.any? do |item|
|
|
623
|
+
env[key] = item
|
|
624
|
+
truthy?(evaluate(pred, env))
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# find -- first matching element, or nil. `forward` false searches from
|
|
629
|
+
# the end (findLast).
|
|
630
|
+
def find(items, pred, param, forward = true, base_env = nil)
|
|
631
|
+
arr = items.is_a?(Array) ? items : []
|
|
632
|
+
arr = arr.reverse unless forward
|
|
633
|
+
env = base_env ? base_env.dup : {}
|
|
634
|
+
key = param.to_sym
|
|
635
|
+
arr.each do |item|
|
|
636
|
+
env[key] = item
|
|
637
|
+
return item if truthy?(evaluate(pred, env))
|
|
638
|
+
end
|
|
639
|
+
nil
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
# find_index -- index of the first matching element, or -1. `forward`
|
|
643
|
+
# false -> findLastIndex (the index is into the original array either
|
|
644
|
+
# way).
|
|
645
|
+
def find_index(items, pred, param, forward = true, base_env = nil)
|
|
646
|
+
arr = items.is_a?(Array) ? items : []
|
|
647
|
+
env = base_env ? base_env.dup : {}
|
|
648
|
+
key = param.to_sym
|
|
649
|
+
idxs = forward ? (0...arr.length) : (0...arr.length).to_a.reverse
|
|
650
|
+
idxs.each do |i|
|
|
651
|
+
env[key] = arr[i]
|
|
652
|
+
return i if truthy?(evaluate(pred, env))
|
|
653
|
+
end
|
|
654
|
+
-1
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# flat_map -- project each element through `proj` and flatten one level.
|
|
658
|
+
# A projection yielding an array contributes its elements; any other
|
|
659
|
+
# value contributes itself.
|
|
660
|
+
def flat_map(items, proj, param, base_env = nil)
|
|
661
|
+
arr = items.is_a?(Array) ? items : []
|
|
662
|
+
env = base_env ? base_env.dup : {}
|
|
663
|
+
key = param.to_sym
|
|
664
|
+
out = []
|
|
665
|
+
arr.each do |item|
|
|
666
|
+
env[key] = item
|
|
667
|
+
v = evaluate(proj, env)
|
|
668
|
+
v.is_a?(Array) ? out.concat(v) : out.push(v)
|
|
669
|
+
end
|
|
670
|
+
out
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# map_items -- project each element through `proj`, keeping each result
|
|
674
|
+
# as one element (no flatten): value-producing `.map(cb)`. Named
|
|
675
|
+
# `map_items` (not `map`) to stay clear of Ruby's own Enumerable#map.
|
|
676
|
+
def map_items(items, proj, param, base_env = nil)
|
|
677
|
+
arr = items.is_a?(Array) ? items : []
|
|
678
|
+
env = base_env ? base_env.dup : {}
|
|
679
|
+
key = param.to_sym
|
|
680
|
+
arr.map do |item|
|
|
681
|
+
env[key] = item
|
|
682
|
+
evaluate(proj, env)
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def filter_json(items, pred_json, param, base_env = nil)
|
|
687
|
+
filter(items, JSON.parse(pred_json, symbolize_names: true), param, base_env)
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def every_json(items, pred_json, param, base_env = nil)
|
|
691
|
+
every(items, JSON.parse(pred_json, symbolize_names: true), param, base_env)
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def some_json(items, pred_json, param, base_env = nil)
|
|
695
|
+
some(items, JSON.parse(pred_json, symbolize_names: true), param, base_env)
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def find_json(items, pred_json, param, forward = true, base_env = nil)
|
|
699
|
+
find(items, JSON.parse(pred_json, symbolize_names: true), param, forward, base_env)
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def find_index_json(items, pred_json, param, forward = true, base_env = nil)
|
|
703
|
+
find_index(items, JSON.parse(pred_json, symbolize_names: true), param, forward, base_env)
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def flat_map_json(items, proj_json, param, base_env = nil)
|
|
707
|
+
flat_map(items, JSON.parse(proj_json, symbolize_names: true), param, base_env)
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def map_json(items, proj_json, param, base_env = nil)
|
|
711
|
+
map_items(items, JSON.parse(proj_json, symbolize_names: true), param, base_env)
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
end
|