@barefootjs/php 0.1.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,968 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Barefoot;
6
+
7
+ /**
8
+ * Port of packages/adapter-perl/lib/BarefootJS/Evaluator.pm (see also the
9
+ * Python port packages/adapter-jinja/python/barefootjs/evaluator.py).
10
+ *
11
+ * Lightweight evaluator for the pure `ParsedExpr` subset, scoped to
12
+ * higher-order callback bodies (reduce / sort / map / filter / find
13
+ * `(...) => expr`) -- issue #2018. Templates cannot carry a lambda in
14
+ * expression position, so the callback BODY rides as a pure `ParsedExpr`
15
+ * subtree (the structured IR the compiler already produces) and is
16
+ * evaluated here against an environment (`[acc, item, ...captured free
17
+ * vars]`).
18
+ *
19
+ * ONE shared implementation for the Twig backend (mirroring the two Perl
20
+ * backends sharing Evaluator.pm, and the Jinja Python port's evaluator.py).
21
+ * The accepted subset and its semantics are documented in
22
+ * spec/compiler.md ("ParsedExpr Evaluator Semantics") and pinned
23
+ * isomorphically by the cross-language golden vectors
24
+ * (packages/adapter-tests/vectors/eval-vectors.json), shared with the
25
+ * Go, Perl and Python evaluators -- same input -> same output.
26
+ *
27
+ * Node access: unlike Perl/Python (whose JSON decoders always produce a
28
+ * hashref/dict for a JSON object), PHP's canonical `json_decode($s)` (no
29
+ * `assoc`) decodes a JSON object into `stdClass` so that `{}` and `[]`
30
+ * round-trip distinctly (see the design doc's "canonical value convention").
31
+ * `evaluate()` therefore accepts a ParsedExpr node as EITHER a decoded
32
+ * `stdClass`/array value OR a hand-built PHP associative array (the
33
+ * ergonomic shape `php/tests/test_evaluator.php` hand-builds, mirroring how
34
+ * the Perl/Python ports hand-build hashref/dict trees) -- `get()` below is
35
+ * the single point that tolerates both shapes for structural node access.
36
+ * JSON *arrays* (`args`, `elements`, `parts`, `properties`, ...) always
37
+ * decode as plain PHP lists regardless of the `assoc` flag, so no such
38
+ * dual-shape handling is needed for those.
39
+ */
40
+ final class Evaluator
41
+ {
42
+ private function __construct()
43
+ {
44
+ }
45
+
46
+ private const NUM_RE = '/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/';
47
+ private const INF_NAN_RE = '/^[+-]?(inf(inity)?|nan)$/i';
48
+
49
+ /** Mirrors Scalar::Util::looks_like_number / the Python `looks_like_number`. */
50
+ public static function looksLikeNumber(string $s): bool
51
+ {
52
+ $t = trim($s);
53
+ if ($t === '') {
54
+ return false;
55
+ }
56
+ return (bool) preg_match(self::NUM_RE, $t) || (bool) preg_match(self::INF_NAN_RE, $t);
57
+ }
58
+
59
+ /** Parse a string already known to satisfy looksLikeNumber() into a float. */
60
+ public static function parseNumberLiteral(string $s): float
61
+ {
62
+ $t = trim($s);
63
+ if (preg_match(self::INF_NAN_RE, $t)) {
64
+ $low = strtolower($t);
65
+ if (str_contains($low, 'nan')) {
66
+ return NAN;
67
+ }
68
+ return str_starts_with($low, '-') ? -INF : INF;
69
+ }
70
+ return (float) $t;
71
+ }
72
+
73
+ /**
74
+ * Shared JS `Number.prototype.toString` formatting for a FINITE, non-NaN
75
+ * float (callers handle NaN/Infinity first). Needed because PHP's plain
76
+ * `(string)` cast on a float is lossy -- bounded by the `precision` ini
77
+ * setting (default 14 significant digits) -- unlike Perl, whose native
78
+ * stringification is already shortest-round-trip. `serialize_precision`
79
+ * (default -1 since PHP 7.1, shortest round-trip, matching V8) is what
80
+ * `var_export()`/`json_encode()` honour but plain `(string)` casts do
81
+ * not, hence the explicit routine (mirrors runtime.py's
82
+ * `_format_js_number` / this module's own `_format_number` in the
83
+ * Python port -- two independent copies there too, not shared, since
84
+ * runtime.py and evaluator.py are standalone modules; here it is
85
+ * shared between BarefootJS::string() and self::toStringJs() since nothing
86
+ * requires them to be independent implementations in PHP).
87
+ */
88
+ public static function formatNumber(float $n): string
89
+ {
90
+ if ($n === 0.0) {
91
+ return '0'; // normalises -0.0 to JS's "0" spelling
92
+ }
93
+ if ($n == floor($n) && abs($n) < 1e21) {
94
+ return sprintf('%.0f', $n);
95
+ }
96
+ // Shortest round-trip decimal representation (serialize_precision=-1).
97
+ return var_export($n, true);
98
+ }
99
+
100
+ private static function get($node, string $key)
101
+ {
102
+ if (is_array($node)) {
103
+ return $node[$key] ?? null;
104
+ }
105
+ if (is_object($node)) {
106
+ return $node->$key ?? null;
107
+ }
108
+ return null;
109
+ }
110
+
111
+ private static function kind($node): string
112
+ {
113
+ if (is_array($node)) {
114
+ return (string) ($node['kind'] ?? '');
115
+ }
116
+ if (is_object($node)) {
117
+ return (string) ($node->kind ?? '');
118
+ }
119
+ return '';
120
+ }
121
+
122
+ /** True for a JSON *array* value under the canonical value convention:
123
+ * a plain PHP list (empty arrays count as lists -- see the design doc). */
124
+ public static function isJsArray($v): bool
125
+ {
126
+ return is_array($v) && array_is_list($v);
127
+ }
128
+
129
+ /**
130
+ * Evaluate a decoded ParsedExpr node against the environment array,
131
+ * returning a PHP value (float, string, bool, null for JS null/undefined,
132
+ * list array, stdClass). The matching JSON entry point is evalJson().
133
+ */
134
+ public static function evaluate($node, array $env)
135
+ {
136
+ if (!is_array($node) && !is_object($node)) {
137
+ return null;
138
+ }
139
+ $kind = self::kind($node);
140
+
141
+ if ($kind === 'literal') {
142
+ return self::get($node, 'value');
143
+ }
144
+ if ($kind === 'identifier') {
145
+ $name = self::get($node, 'name');
146
+ return $env[$name] ?? null;
147
+ }
148
+ if ($kind === 'binary') {
149
+ return self::binary(
150
+ (string) self::get($node, 'op'),
151
+ self::evaluate(self::get($node, 'left'), $env),
152
+ self::evaluate(self::get($node, 'right'), $env)
153
+ );
154
+ }
155
+ if ($kind === 'unary') {
156
+ return self::unary((string) self::get($node, 'op'), self::evaluate(self::get($node, 'argument'), $env));
157
+ }
158
+ if ($kind === 'logical') {
159
+ $op = self::get($node, 'op');
160
+ $left = self::evaluate(self::get($node, 'left'), $env);
161
+ if ($op === '&&') {
162
+ return self::truthy($left) ? self::evaluate(self::get($node, 'right'), $env) : $left;
163
+ }
164
+ if ($op === '||') {
165
+ return self::truthy($left) ? $left : self::evaluate(self::get($node, 'right'), $env);
166
+ }
167
+ // `??`
168
+ return $left !== null ? $left : self::evaluate(self::get($node, 'right'), $env);
169
+ }
170
+ if ($kind === 'conditional') {
171
+ return self::truthy(self::evaluate(self::get($node, 'test'), $env))
172
+ ? self::evaluate(self::get($node, 'consequent'), $env)
173
+ : self::evaluate(self::get($node, 'alternate'), $env);
174
+ }
175
+ if ($kind === 'member') {
176
+ return self::readProperty(
177
+ self::evaluate(self::get($node, 'object'), $env),
178
+ self::get($node, 'property')
179
+ );
180
+ }
181
+ if ($kind === 'index-access') {
182
+ return self::readIndex(
183
+ self::evaluate(self::get($node, 'object'), $env),
184
+ self::evaluate(self::get($node, 'index'), $env)
185
+ );
186
+ }
187
+ if ($kind === 'call') {
188
+ $callback = self::arrayCallbackCall($node);
189
+ if ($callback !== null) {
190
+ [$method, $objectNode, $arrowNode] = $callback;
191
+ return self::evalArrayCallback($method, $objectNode, $arrowNode, $env);
192
+ }
193
+ $name = self::builtinName(self::get($node, 'callee'));
194
+ if ($name === '') {
195
+ return null;
196
+ }
197
+ $argsNode = self::get($node, 'args');
198
+ $args = [];
199
+ foreach ((is_array($argsNode) ? $argsNode : []) as $a) {
200
+ $args[] = self::evaluate($a, $env);
201
+ }
202
+ return self::callBuiltin($name, $args);
203
+ }
204
+ if ($kind === 'template-literal') {
205
+ $out = '';
206
+ $partsNode = self::get($node, 'parts');
207
+ foreach ((is_array($partsNode) ? $partsNode : []) as $p) {
208
+ $type = self::get($p, 'type') ?? '';
209
+ if ($type === 'string') {
210
+ $out .= self::get($p, 'value') ?? '';
211
+ } else {
212
+ $out .= self::toStringJs(self::evaluate(self::get($p, 'expr'), $env));
213
+ }
214
+ }
215
+ return $out;
216
+ }
217
+ if ($kind === 'array-literal') {
218
+ $out = [];
219
+ $elementsNode = self::get($node, 'elements');
220
+ foreach ((is_array($elementsNode) ? $elementsNode : []) as $e) {
221
+ $out[] = self::evaluate($e, $env);
222
+ }
223
+ return $out;
224
+ }
225
+ if ($kind === 'object-literal') {
226
+ $out = new \stdClass();
227
+ $propsNode = self::get($node, 'properties');
228
+ foreach ((is_array($propsNode) ? $propsNode : []) as $prop) {
229
+ $key = (string) self::get($prop, 'key');
230
+ $out->$key = self::evaluate(self::get($prop, 'value'), $env);
231
+ }
232
+ return $out;
233
+ }
234
+ if ($kind === 'array-method') {
235
+ $method = (string) (self::get($node, 'method') ?? '');
236
+ $argsNode = self::get($node, 'args');
237
+ $argsArr = is_array($argsNode) ? $argsNode : [];
238
+ if ($method === 'includes' && count($argsArr) === 1) {
239
+ // `.includes(x)` (#2075) -- the one `array-method` in the
240
+ // evaluator subset, shared between `Array.prototype.includes`
241
+ // (SameValueZero membership) and `String.prototype.includes`
242
+ // (substring search).
243
+ $obj = self::evaluate(self::get($node, 'object'), $env);
244
+ $needle = self::evaluate($argsArr[0], $env);
245
+ if (self::isJsArray($obj)) {
246
+ foreach ($obj as $el) {
247
+ if (self::sameValueZero($el, $needle)) {
248
+ return true;
249
+ }
250
+ }
251
+ return false;
252
+ }
253
+ if (is_string($obj)) {
254
+ return str_contains($obj, self::toStringJs($needle));
255
+ }
256
+ // Any other receiver is not a JS `.includes` target -- degrade
257
+ // to false rather than raising, mirroring the reference.
258
+ return false;
259
+ }
260
+ if ($method === 'join' && count($argsArr) <= 1) {
261
+ // `.join(sep?)` (#2094) -- default separator is `,`; a
262
+ // `null`/`undefined` element joins as `''`, not the string
263
+ // "null" (mirrors evalJoin in the Go reference).
264
+ $sep = count($argsArr) === 1
265
+ ? self::toStringJs(self::evaluate($argsArr[0], $env))
266
+ : ',';
267
+ $obj = self::evaluate(self::get($node, 'object'), $env);
268
+ return self::evalJoin($obj, $sep);
269
+ }
270
+ }
271
+
272
+ // arrow-fn / higher-order / unsupported array-method: a callback
273
+ // body containing these is refused upstream (BF101); never reached.
274
+ return null;
275
+ }
276
+
277
+ /**
278
+ * Recognize a nested `.map(cb)` / `.filter(cb)` callback call inside a
279
+ * `call` node (#2094): `callee` is a non-computed `member` node whose
280
+ * `property` is `map`/`filter`, and the first argument is an `arrow`
281
+ * node. Returns `[method, objectNode, arrowNode]` or `null` when the
282
+ * shape doesn't match (mirrors Go's `evalArrayCallbackCall`). Everything
283
+ * else nested (`.some`/`.find`/`.every`/`.sort`/`.reduce`/`.flat`/
284
+ * `.flatMap`, standalone arrows) stays refused upstream (BF101) -- this
285
+ * function only widens the two cases the compiler now allows to nest.
286
+ */
287
+ private static function arrayCallbackCall($node): ?array
288
+ {
289
+ $callee = self::get($node, 'callee');
290
+ if ((!is_array($callee) && !is_object($callee)) || self::kind($callee) !== 'member') {
291
+ return null;
292
+ }
293
+ if (self::get($callee, 'computed')) {
294
+ return null;
295
+ }
296
+ $prop = (string) (self::get($callee, 'property') ?? '');
297
+ if ($prop !== 'map' && $prop !== 'filter') {
298
+ return null;
299
+ }
300
+ $argsNode = self::get($node, 'args');
301
+ $argsArr = is_array($argsNode) ? $argsNode : [];
302
+ if (count($argsArr) === 0) {
303
+ return null;
304
+ }
305
+ $arrowNode = $argsArr[0];
306
+ if ((!is_array($arrowNode) && !is_object($arrowNode)) || self::kind($arrowNode) !== 'arrow') {
307
+ return null;
308
+ }
309
+ return [$prop, self::get($callee, 'object'), $arrowNode];
310
+ }
311
+
312
+ /**
313
+ * Evaluate a nested `.map`/`.filter` callback call: evaluate the
314
+ * receiver, then invoke the arrow body once per element against a FRESH
315
+ * CHILD ENV (a copy of the parent env with the param(s) bound) --
316
+ * `$env` is passed by value here, and PHP arrays are copy-on-write, so
317
+ * mutating `$inner` never leaks back into the caller's `$env`. The
318
+ * arrow's 1st param binds the element, the 2nd (if present) binds the
319
+ * integer index -- mirrors Go's `evalArrayCallback`.
320
+ */
321
+ private static function evalArrayCallback(string $method, $objectNode, $arrowNode, array $env)
322
+ {
323
+ $arr = self::evaluate($objectNode, $env);
324
+ if (!self::isJsArray($arr)) {
325
+ return null;
326
+ }
327
+ $paramsNode = self::get($arrowNode, 'params');
328
+ $params = [];
329
+ foreach ((is_array($paramsNode) ? $paramsNode : []) as $p) {
330
+ $params[] = (string) $p;
331
+ }
332
+ $body = self::get($arrowNode, 'body');
333
+ $callCb = function ($item, int $index) use ($body, $params, $env) {
334
+ $inner = $env; // copy: fresh child scope per invocation
335
+ if (count($params) > 0) {
336
+ $inner[$params[0]] = $item;
337
+ }
338
+ if (count($params) > 1) {
339
+ $inner[$params[1]] = $index;
340
+ }
341
+ return self::evaluate($body, $inner);
342
+ };
343
+ if ($method === 'map') {
344
+ $out = [];
345
+ $i = 0;
346
+ foreach ($arr as $item) {
347
+ $out[] = $callCb($item, $i);
348
+ ++$i;
349
+ }
350
+ return $out;
351
+ }
352
+ $out = [];
353
+ $i = 0;
354
+ foreach ($arr as $item) {
355
+ if (self::truthy($callCb($item, $i))) {
356
+ $out[] = $item;
357
+ }
358
+ ++$i;
359
+ }
360
+ return $out;
361
+ }
362
+
363
+ /** `Array.prototype.join(sep?)` -- default separator `,`; a `null`
364
+ * element joins as `''`, not the string "null" (#2094). */
365
+ private static function evalJoin($obj, string $sep): string
366
+ {
367
+ if (!self::isJsArray($obj)) {
368
+ return '';
369
+ }
370
+ $parts = [];
371
+ foreach ($obj as $el) {
372
+ $parts[] = $el === null ? '' : self::toStringJs($el);
373
+ }
374
+ return implode($sep, $parts);
375
+ }
376
+
377
+ /** Decode a ParsedExpr JSON string and evaluate it. Mirrors the Go
378
+ * EvalExpr entry point and Perl/Python's eval_json. */
379
+ public static function evalJson(string $json, array $env)
380
+ {
381
+ return self::evaluate(json_decode($json), $env);
382
+ }
383
+
384
+ // -----------------------------------------------------------------
385
+ // JS coercion primitives (ToNumber / ToString / ToBoolean).
386
+ // -----------------------------------------------------------------
387
+
388
+ private static function toNumber($v): float
389
+ {
390
+ if ($v === null) {
391
+ return 0.0;
392
+ }
393
+ if (is_bool($v)) {
394
+ return $v ? 1.0 : 0.0;
395
+ }
396
+ if (is_int($v) || is_float($v)) {
397
+ return (float) $v;
398
+ }
399
+ if (is_string($v)) {
400
+ $t = trim($v);
401
+ if ($t === '') {
402
+ return 0.0;
403
+ }
404
+ return self::looksLikeNumber($t) ? self::parseNumberLiteral($t) : NAN;
405
+ }
406
+ return NAN; // array / object
407
+ }
408
+
409
+ private static function toStringJs($v): string
410
+ {
411
+ if ($v === null) {
412
+ return 'null';
413
+ }
414
+ if (is_bool($v)) {
415
+ return $v ? 'true' : 'false';
416
+ }
417
+ if (is_int($v) || is_float($v)) {
418
+ $n = (float) $v;
419
+ if (is_nan($n)) {
420
+ return 'NaN';
421
+ }
422
+ if ($n === INF) {
423
+ return 'Infinity';
424
+ }
425
+ if ($n === -INF) {
426
+ return '-Infinity';
427
+ }
428
+ return self::formatNumber($n);
429
+ }
430
+ if (is_string($v)) {
431
+ return $v;
432
+ }
433
+ return ''; // arrays/objects -- not exercised by the evaluator subset's tested paths
434
+ }
435
+
436
+ /** Public JS truthiness -- shared by BarefootJS::truthy() so both the
437
+ * evaluator and the runtime helper agree on one implementation. */
438
+ public static function truthy($v): bool
439
+ {
440
+ if ($v === null || $v === false) {
441
+ return false;
442
+ }
443
+ if ($v === true) {
444
+ return true;
445
+ }
446
+ if (is_int($v) || is_float($v)) {
447
+ $n = (float) $v;
448
+ return $n === $n && $n != 0.0; // not NaN, and nonzero
449
+ }
450
+ if (is_string($v)) {
451
+ return $v !== ''; // incl. the JS-truthy "0"
452
+ }
453
+ return true; // arrays / objects are always truthy in JS
454
+ }
455
+
456
+ // -----------------------------------------------------------------
457
+ // Operators
458
+ // -----------------------------------------------------------------
459
+
460
+ private static function binary(string $op, $l, $r)
461
+ {
462
+ if ($op === '+') {
463
+ // JS `+`: string concatenation once either operand is a string,
464
+ // numeric addition otherwise.
465
+ if (is_string($l) || is_string($r)) {
466
+ return self::toStringJs($l) . self::toStringJs($r);
467
+ }
468
+ return self::toNumber($l) + self::toNumber($r);
469
+ }
470
+ if ($op === '-') {
471
+ return self::toNumber($l) - self::toNumber($r);
472
+ }
473
+ if ($op === '*') {
474
+ return self::toNumber($l) * self::toNumber($r);
475
+ }
476
+ if ($op === '/') {
477
+ $ln = self::toNumber($l);
478
+ $rn = self::toNumber($r);
479
+ if ($rn === 0.0) {
480
+ if ($ln === 0.0 || is_nan($ln)) {
481
+ return NAN;
482
+ }
483
+ return $ln > 0 ? INF : -INF;
484
+ }
485
+ return $ln / $rn;
486
+ }
487
+ if ($op === '%') {
488
+ $rn = self::toNumber($r);
489
+ if ($rn === 0.0) {
490
+ return NAN;
491
+ }
492
+ return fmod(self::toNumber($l), $rn);
493
+ }
494
+ if ($op === '<' || $op === '<=' || $op === '>' || $op === '>=') {
495
+ return self::relational($op, $l, $r);
496
+ }
497
+ if ($op === '===') {
498
+ return self::strictEq($l, $r);
499
+ }
500
+ if ($op === '!==') {
501
+ return !self::strictEq($l, $r);
502
+ }
503
+ // Loose equality / bitwise / shift are out of the subset.
504
+ return null;
505
+ }
506
+
507
+ private static function relational(string $op, $l, $r): bool
508
+ {
509
+ // JS Abstract Relational Comparison: both strings -> compare by code
510
+ // unit; otherwise coerce both to numbers (a NaN operand -> false).
511
+ // MUST use strcmp(), not PHP's `<=>`/`<` operators: PHP applies
512
+ // "smart" numeric-string comparison when both operands look
513
+ // numeric ("10" <=> "9" compares 10 > 9 numerically), which would
514
+ // silently defeat the eval-vectors.json pin that two numeric
515
+ // strings compare LEXICALLY under JS `<` (see the golden vector
516
+ // "two numeric strings compare lexically, not numerically").
517
+ // strcmp() always does a raw byte comparison.
518
+ if (is_string($l) && is_string($r)) {
519
+ $c = strcmp($l, $r) <=> 0;
520
+ } else {
521
+ $ln = self::toNumber($l);
522
+ $rn = self::toNumber($r);
523
+ if (is_nan($ln) || is_nan($rn)) {
524
+ return false;
525
+ }
526
+ $c = $ln <=> $rn;
527
+ }
528
+ return match ($op) {
529
+ '<' => $c < 0,
530
+ '<=' => $c <= 0,
531
+ '>' => $c > 0,
532
+ '>=' => $c >= 0,
533
+ default => false,
534
+ };
535
+ }
536
+
537
+ /** Strict `===`: equal JS type and value, no coercion. Also the shared
538
+ * semantics behind `BarefootJS::eq()`/`neq()` (design doc "NEW helpers"
539
+ * section) -- int/float unify numerically (JS has one number type),
540
+ * bool/null strict, NaN !== NaN, arrays/objects fall back to PHP `===`
541
+ * (value-equality for arrays, identity for stdClass -- a documented
542
+ * divergence, same territory as the Perl port's refaddr comparison). */
543
+ public static function strictEq($l, $r): bool
544
+ {
545
+ $ln = is_int($l) || is_float($l);
546
+ $rn = is_int($r) || is_float($r);
547
+ if ($ln && $rn) {
548
+ $lf = (float) $l;
549
+ $rf = (float) $r;
550
+ if (is_nan($lf) || is_nan($rf)) {
551
+ return false;
552
+ }
553
+ return $lf === $rf;
554
+ }
555
+ if ($ln !== $rn) {
556
+ return false; // one numeric, one not
557
+ }
558
+ if ($l === null) {
559
+ return $r === null;
560
+ }
561
+ if ($r === null) {
562
+ return false;
563
+ }
564
+ $lb = is_bool($l);
565
+ $rb = is_bool($r);
566
+ if ($lb || $rb) {
567
+ if (!($lb && $rb)) {
568
+ return false;
569
+ }
570
+ return $l === $r;
571
+ }
572
+ if (is_string($l) && is_string($r)) {
573
+ return $l === $r;
574
+ }
575
+ // arrays/objects: PHP `===` fallback (see docstring above).
576
+ return $l === $r;
577
+ }
578
+
579
+ /** `Array.prototype.includes` membership test -- `===` except `NaN`
580
+ * equals itself. Reuses strictEq()'s type/value rules and only
581
+ * special-cases the two-NaN case strictEq (deliberately, for `===`)
582
+ * reports as unequal. */
583
+ public static function sameValueZero($l, $r): bool
584
+ {
585
+ if ((is_int($l) || is_float($l)) && (is_int($r) || is_float($r))) {
586
+ $lf = (float) $l;
587
+ $rf = (float) $r;
588
+ if (is_nan($lf) && is_nan($rf)) {
589
+ return true;
590
+ }
591
+ }
592
+ return self::strictEq($l, $r);
593
+ }
594
+
595
+ private static function unary(string $op, $v)
596
+ {
597
+ if ($op === '!') {
598
+ return !self::truthy($v);
599
+ }
600
+ if ($op === '-') {
601
+ return -self::toNumber($v);
602
+ }
603
+ if ($op === '+') {
604
+ return self::toNumber($v);
605
+ }
606
+ return null;
607
+ }
608
+
609
+ // -----------------------------------------------------------------
610
+ // Built-in calls (the deterministic allowlist). Locale-sensitive
611
+ // builtins (localeCompare) are deliberately excluded to keep the
612
+ // backends isomorphic.
613
+ // -----------------------------------------------------------------
614
+
615
+ private static function builtinName($callee): string
616
+ {
617
+ if (!is_array($callee) && !is_object($callee)) {
618
+ return '';
619
+ }
620
+ $kind = self::get($callee, 'kind') ?? '';
621
+ if ($kind === 'identifier') {
622
+ return (string) (self::get($callee, 'name') ?? '');
623
+ }
624
+ if ($kind === 'member' && !self::get($callee, 'computed')) {
625
+ $obj = self::get($callee, 'object');
626
+ if ((!is_array($obj) && !is_object($obj)) || self::get($obj, 'kind') !== 'identifier') {
627
+ return '';
628
+ }
629
+ return (string) (self::get($obj, 'name') ?? '') . '.' . (string) (self::get($callee, 'property') ?? '');
630
+ }
631
+ return '';
632
+ }
633
+
634
+ private static function safeFloor(float $n): float
635
+ {
636
+ return (is_nan($n) || is_infinite($n)) ? $n : floor($n);
637
+ }
638
+
639
+ private static function safeCeil(float $n): float
640
+ {
641
+ return (is_nan($n) || is_infinite($n)) ? $n : ceil($n);
642
+ }
643
+
644
+ /** Half rounds toward +Infinity (JS Math.round: 2.5 -> 3, -2.5 -> -2). */
645
+ private static function mathRound(float $n): float
646
+ {
647
+ return (is_nan($n) || is_infinite($n)) ? $n : floor($n + 0.5);
648
+ }
649
+
650
+ private static function callBuiltin(string $name, array $args)
651
+ {
652
+ $arg = fn (int $i) => $args[$i] ?? null;
653
+
654
+ if ($name === 'Math.max') {
655
+ $m = -INF; // JS Math.max() with no args is -Infinity
656
+ foreach ($args as $a) {
657
+ $n = self::toNumber($a);
658
+ if (is_nan($n)) {
659
+ return $n; // any NaN argument -> NaN
660
+ }
661
+ if ($n > $m) {
662
+ $m = $n;
663
+ }
664
+ }
665
+ return $m;
666
+ }
667
+ if ($name === 'Math.min') {
668
+ $m = INF; // JS Math.min() with no args is +Infinity
669
+ foreach ($args as $a) {
670
+ $n = self::toNumber($a);
671
+ if (is_nan($n)) {
672
+ return $n;
673
+ }
674
+ if ($n < $m) {
675
+ $m = $n;
676
+ }
677
+ }
678
+ return $m;
679
+ }
680
+ if ($name === 'Math.abs') {
681
+ return abs(self::toNumber($arg(0)));
682
+ }
683
+ if ($name === 'Math.floor') {
684
+ return self::safeFloor(self::toNumber($arg(0)));
685
+ }
686
+ if ($name === 'Math.ceil') {
687
+ return self::safeCeil(self::toNumber($arg(0)));
688
+ }
689
+ if ($name === 'Math.round') {
690
+ return self::mathRound(self::toNumber($arg(0)));
691
+ }
692
+ if ($name === 'String') {
693
+ return self::toStringJs($arg(0));
694
+ }
695
+ if ($name === 'Number') {
696
+ return self::toNumber($arg(0));
697
+ }
698
+ if ($name === 'Boolean') {
699
+ return self::truthy($arg(0));
700
+ }
701
+ // Any other callee is outside the subset (refused upstream).
702
+ return null;
703
+ }
704
+
705
+ // -----------------------------------------------------------------
706
+ // Member / index access
707
+ // -----------------------------------------------------------------
708
+
709
+ /** Read a property from a JS-shaped value. Per the canonical value
710
+ * convention, an "object" is a stdClass OR a non-list (associative)
711
+ * PHP array -- both are treated as objects for member access. */
712
+ private static function readProperty($obj, $key)
713
+ {
714
+ if ($obj === null) {
715
+ return null;
716
+ }
717
+ if ($obj instanceof \stdClass) {
718
+ return property_exists($obj, $key) ? $obj->$key : null;
719
+ }
720
+ if (is_array($obj)) {
721
+ if (array_is_list($obj)) {
722
+ return $key === 'length' ? count($obj) : null;
723
+ }
724
+ return array_key_exists($key, $obj) ? $obj[$key] : null;
725
+ }
726
+ if (is_string($obj) && $key === 'length') {
727
+ // `.length` is a string property only -- a numeric scalar has
728
+ // no `.length` in the subset (matches the Go/Perl/Python
729
+ // evaluators). Code-point length (mirrors BarefootJS::length()).
730
+ return mb_strlen($obj, 'UTF-8');
731
+ }
732
+ return null;
733
+ }
734
+
735
+ private static function readIndex($obj, $index)
736
+ {
737
+ if (is_array($obj) && array_is_list($obj)) {
738
+ $f = self::toNumber($index);
739
+ if (is_nan($f) || is_infinite($f)) {
740
+ return null;
741
+ }
742
+ $i = (int) $f;
743
+ if ((float) $i !== $f || $i < 0 || $i >= count($obj)) {
744
+ return null;
745
+ }
746
+ return $obj[$i];
747
+ }
748
+ if ($obj instanceof \stdClass) {
749
+ $k = self::toStringJs($index);
750
+ return property_exists($obj, $k) ? $obj->$k : null;
751
+ }
752
+ if (is_array($obj)) { // assoc, treated as object
753
+ $k = self::toStringJs($index);
754
+ return array_key_exists($k, $obj) ? $obj[$k] : null;
755
+ }
756
+ return null;
757
+ }
758
+
759
+ // -----------------------------------------------------------------
760
+ // Evaluator-driven higher-order folds / predicates (#2018).
761
+ // -----------------------------------------------------------------
762
+
763
+ /** Fold an array into a value via the evaluator. Non-array receiver ->
764
+ * the seed `$init` unchanged (mirrors the Perl/Python nil-tolerant
765
+ * convention -- an empty fold degenerates to the seed). */
766
+ public static function fold($items, $body, string $accName, string $itemName, $init, string $direction = 'left', array $baseEnv = [])
767
+ {
768
+ $arr = self::isJsArray($items) ? $items : [];
769
+ if ($direction === 'right') {
770
+ $arr = array_reverse($arr);
771
+ }
772
+ $env = $baseEnv;
773
+ $acc = $init;
774
+ foreach ($arr as $item) {
775
+ $env[$accName] = $acc;
776
+ $env[$itemName] = $item;
777
+ $acc = self::evaluate($body, $env);
778
+ }
779
+ return $acc;
780
+ }
781
+
782
+ /** Return a new array ordered by a ParsedExpr comparator. Non-mutating,
783
+ * stable (PHP's usort() is a stable sort as of PHP 8.0; an original-index
784
+ * tie-break is decorated in anyway for defensiveness/portability,
785
+ * mirroring the Perl port). */
786
+ public static function sortBy($items, $cmp, string $paramA, string $paramB, array $baseEnv = []): array
787
+ {
788
+ if (!self::isJsArray($items)) {
789
+ return [];
790
+ }
791
+ $env = $baseEnv;
792
+ $decorated = [];
793
+ foreach (array_values($items) as $i => $v) {
794
+ $decorated[] = [$i, $v];
795
+ }
796
+ usort($decorated, function ($a, $b) use ($cmp, $paramA, $paramB, &$env) {
797
+ $env[$paramA] = $a[1];
798
+ $env[$paramB] = $b[1];
799
+ $c = self::toNumber(self::evaluate($cmp, $env));
800
+ if (is_nan($c)) {
801
+ return $a[0] <=> $b[0]; // NaN comparator result -> keep input order
802
+ }
803
+ $sign = $c <=> 0.0;
804
+ return $sign !== 0 ? $sign : ($a[0] <=> $b[0]);
805
+ });
806
+ return array_map(fn ($d) => $d[1], $decorated);
807
+ }
808
+
809
+ public static function filter($items, $pred, string $param, array $baseEnv = []): array
810
+ {
811
+ if (!self::isJsArray($items)) {
812
+ return [];
813
+ }
814
+ $env = $baseEnv;
815
+ $out = [];
816
+ foreach ($items as $item) {
817
+ $env[$param] = $item;
818
+ if (self::truthy(self::evaluate($pred, $env))) {
819
+ $out[] = $item;
820
+ }
821
+ }
822
+ return $out;
823
+ }
824
+
825
+ public static function every($items, $pred, string $param, array $baseEnv = []): bool
826
+ {
827
+ $arr = self::isJsArray($items) ? $items : [];
828
+ $env = $baseEnv;
829
+ foreach ($arr as $item) {
830
+ $env[$param] = $item;
831
+ if (!self::truthy(self::evaluate($pred, $env))) {
832
+ return false;
833
+ }
834
+ }
835
+ return true;
836
+ }
837
+
838
+ public static function some($items, $pred, string $param, array $baseEnv = []): bool
839
+ {
840
+ $arr = self::isJsArray($items) ? $items : [];
841
+ $env = $baseEnv;
842
+ foreach ($arr as $item) {
843
+ $env[$param] = $item;
844
+ if (self::truthy(self::evaluate($pred, $env))) {
845
+ return true;
846
+ }
847
+ }
848
+ return false;
849
+ }
850
+
851
+ public static function find($items, $pred, string $param, bool $forward = true, array $baseEnv = [])
852
+ {
853
+ $arr = self::isJsArray($items) ? $items : [];
854
+ if (!$forward) {
855
+ $arr = array_reverse($arr);
856
+ }
857
+ $env = $baseEnv;
858
+ foreach ($arr as $item) {
859
+ $env[$param] = $item;
860
+ if (self::truthy(self::evaluate($pred, $env))) {
861
+ return $item;
862
+ }
863
+ }
864
+ return null;
865
+ }
866
+
867
+ public static function findIndex($items, $pred, string $param, bool $forward = true, array $baseEnv = []): int
868
+ {
869
+ $arr = self::isJsArray($items) ? array_values($items) : [];
870
+ $n = count($arr);
871
+ if ($n === 0) {
872
+ return -1;
873
+ }
874
+ $env = $baseEnv;
875
+ $indices = $forward ? range(0, $n - 1) : range($n - 1, 0);
876
+ foreach ($indices as $i) {
877
+ $env[$param] = $arr[$i];
878
+ if (self::truthy(self::evaluate($pred, $env))) {
879
+ return $i;
880
+ }
881
+ }
882
+ return -1;
883
+ }
884
+
885
+ public static function flatMap($items, $proj, string $param, array $baseEnv = []): array
886
+ {
887
+ $arr = self::isJsArray($items) ? $items : [];
888
+ $env = $baseEnv;
889
+ $out = [];
890
+ foreach ($arr as $item) {
891
+ $env[$param] = $item;
892
+ $v = self::evaluate($proj, $env);
893
+ if (self::isJsArray($v)) {
894
+ foreach ($v as $x) {
895
+ $out[] = $x;
896
+ }
897
+ } else {
898
+ $out[] = $v;
899
+ }
900
+ }
901
+ return $out;
902
+ }
903
+
904
+ /** Value-producing `.map(cb)` (#2073): project each element, one result
905
+ * per element (no flatten). */
906
+ public static function mapItems($items, $proj, string $param, array $baseEnv = []): array
907
+ {
908
+ $arr = self::isJsArray($items) ? $items : [];
909
+ $env = $baseEnv;
910
+ $out = [];
911
+ foreach ($arr as $item) {
912
+ $env[$param] = $item;
913
+ $out[] = self::evaluate($proj, $env);
914
+ }
915
+ return $out;
916
+ }
917
+
918
+ // -----------------------------------------------------------------
919
+ // JSON-string seams -- the adapter emits `bf.filter_eval(recv, '<json>', ...)`;
920
+ // the predicate body arrives as a JSON string, decoded then handed to
921
+ // the helper above.
922
+ // -----------------------------------------------------------------
923
+
924
+ public static function foldJson($items, string $bodyJson, string $accName, string $itemName, $init, string $direction = 'left', array $baseEnv = [])
925
+ {
926
+ return self::fold($items, json_decode($bodyJson), $accName, $itemName, $init, $direction, $baseEnv);
927
+ }
928
+
929
+ public static function sortByJson($items, string $cmpJson, string $paramA, string $paramB, array $baseEnv = []): array
930
+ {
931
+ return self::sortBy($items, json_decode($cmpJson), $paramA, $paramB, $baseEnv);
932
+ }
933
+
934
+ public static function filterJson($items, string $predJson, string $param, array $baseEnv = []): array
935
+ {
936
+ return self::filter($items, json_decode($predJson), $param, $baseEnv);
937
+ }
938
+
939
+ public static function everyJson($items, string $predJson, string $param, array $baseEnv = []): bool
940
+ {
941
+ return self::every($items, json_decode($predJson), $param, $baseEnv);
942
+ }
943
+
944
+ public static function someJson($items, string $predJson, string $param, array $baseEnv = []): bool
945
+ {
946
+ return self::some($items, json_decode($predJson), $param, $baseEnv);
947
+ }
948
+
949
+ public static function findJson($items, string $predJson, string $param, bool $forward = true, array $baseEnv = [])
950
+ {
951
+ return self::find($items, json_decode($predJson), $param, $forward, $baseEnv);
952
+ }
953
+
954
+ public static function findIndexJson($items, string $predJson, string $param, bool $forward = true, array $baseEnv = []): int
955
+ {
956
+ return self::findIndex($items, json_decode($predJson), $param, $forward, $baseEnv);
957
+ }
958
+
959
+ public static function flatMapJson($items, string $projJson, string $param, array $baseEnv = []): array
960
+ {
961
+ return self::flatMap($items, json_decode($projJson), $param, $baseEnv);
962
+ }
963
+
964
+ public static function mapJson($items, string $projJson, string $param, array $baseEnv = []): array
965
+ {
966
+ return self::mapItems($items, json_decode($projJson), $param, $baseEnv);
967
+ }
968
+ }