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.
@@ -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