@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,274 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ /**
6
+ * Hand-built ParsedExpr evaluator demonstrations, ported from
7
+ * packages/adapter-perl/t/evaluator.t (see also the Python port's
8
+ * test_evaluator.py).
9
+ *
10
+ * Mirrors the Go/Perl/Python evaluator test demonstrations so all backends
11
+ * prove the SAME restriction-lifting on the SAME shapes (a
12
+ * reducer/comparator/predicate body the fixed bf.reduce/bf.sort/bf.filter
13
+ * catalogues can't express, but the evaluator handles as just another pure
14
+ * expression). Nodes are hand-built as plain PHP associative arrays (the
15
+ * Evaluator tolerates this shape as well as a decoded stdClass -- see
16
+ * Evaluator.php's docstring).
17
+ */
18
+
19
+ require_once __DIR__ . '/_harness.php';
20
+ bf_require_runtime();
21
+ bf_reset();
22
+
23
+ use Barefoot\Evaluator;
24
+
25
+ function bft_id(string $name): array
26
+ {
27
+ return ['kind' => 'identifier', 'name' => $name];
28
+ }
29
+
30
+ function bft_mem(array $obj, string $prop): array
31
+ {
32
+ return ['kind' => 'member', 'object' => $obj, 'property' => $prop, 'computed' => false];
33
+ }
34
+
35
+ function bft_bin(string $op, array $left, array $right): array
36
+ {
37
+ return ['kind' => 'binary', 'op' => $op, 'left' => $left, 'right' => $right];
38
+ }
39
+
40
+ function bft_str($value): array
41
+ {
42
+ return ['kind' => 'literal', 'value' => $value, 'literalType' => 'string'];
43
+ }
44
+
45
+ function bft_num($value): array
46
+ {
47
+ return ['kind' => 'literal', 'value' => $value, 'literalType' => 'number'];
48
+ }
49
+
50
+ function bft_call_math(string $fn, array $arg): array
51
+ {
52
+ return ['kind' => 'call', 'callee' => bft_mem(bft_id('Math'), $fn), 'args' => [$arg]];
53
+ }
54
+
55
+ function bft_includes(array $obj, array $needle): array
56
+ {
57
+ return ['kind' => 'array-method', 'method' => 'includes', 'object' => $obj, 'args' => [$needle]];
58
+ }
59
+
60
+ function bft_join(array $obj, ?array $sep = null): array
61
+ {
62
+ return ['kind' => 'array-method', 'method' => 'join', 'object' => $obj, 'args' => $sep === null ? [] : [$sep]];
63
+ }
64
+
65
+ function bft_arrow(array $params, array $body): array
66
+ {
67
+ return ['kind' => 'arrow', 'params' => $params, 'body' => $body];
68
+ }
69
+
70
+ function bft_nested(string $method, array $object, array $arrow): array
71
+ {
72
+ return [
73
+ 'kind' => 'call',
74
+ 'callee' => ['kind' => 'member', 'object' => $object, 'property' => $method, 'computed' => false],
75
+ 'args' => [$arrow],
76
+ ];
77
+ }
78
+
79
+ bf_test('fold: arbitrary reducer body (acc + item.price * item.qty)', function () {
80
+ $body = bft_bin('+', bft_id('acc'), bft_bin('*', bft_mem(bft_id('item'), 'price'), bft_mem(bft_id('item'), 'qty')));
81
+ $items = [['price' => 5, 'qty' => 3], ['price' => 2, 'qty' => 4]];
82
+ bf_assert_eq(Evaluator::fold($items, $body, 'acc', 'item', 0, 'left'), 23.0);
83
+ });
84
+
85
+ bf_test('fold: direction is observable for string concat', function () {
86
+ $body = bft_bin('+', bft_id('acc'), bft_id('item'));
87
+ $items = ['a', 'b', 'c'];
88
+ bf_assert_eq(Evaluator::fold($items, $body, 'acc', 'item', '', 'left'), 'abc');
89
+ bf_assert_eq(Evaluator::fold($items, $body, 'acc', 'item', '', 'right'), 'cba');
90
+ });
91
+
92
+ bf_test('sort_by: arbitrary comparator (abs of field difference)', function () {
93
+ $cmp = bft_bin('-', bft_call_math('abs', bft_mem(bft_id('a'), 'v')), bft_call_math('abs', bft_mem(bft_id('b'), 'v')));
94
+ $items = [['v' => -5], ['v' => 3], ['v' => -1]];
95
+ $sorted = Evaluator::sortBy($items, $cmp, 'a', 'b');
96
+ bf_assert_eq(array_map(fn ($x) => $x['v'], $sorted), [-1, 3, -5]);
97
+ });
98
+
99
+ bf_test('sort_by: descending via reversed comparator', function () {
100
+ $cmp = bft_bin('-', bft_mem(bft_id('b'), 'x'), bft_mem(bft_id('a'), 'x'));
101
+ $items = [['x' => 10], ['x' => 30], ['x' => 20]];
102
+ $sorted = Evaluator::sortBy($items, $cmp, 'a', 'b');
103
+ bf_assert_eq(array_map(fn ($x) => $x['x'], $sorted), [30, 20, 10]);
104
+ });
105
+
106
+ bf_test('non-finite division and JS stringification', function () {
107
+ $div = fn ($a, $b) => Evaluator::evaluate(bft_bin('/', bft_id('a'), bft_id('b')), ['a' => $a, 'b' => $b]);
108
+ bf_assert_eq($div(1, 0), INF);
109
+ bf_assert_eq($div(-1, 0), -INF);
110
+ $nan = $div(0, 0);
111
+ bf_assert(is_float($nan) && is_nan($nan), 'expected NaN for 0/0');
112
+
113
+ // _to_string is private; exercise the same paths via `String()` builtin call.
114
+ $toString = fn ($v) => Evaluator::evaluate(['kind' => 'call', 'callee' => bft_id('String'), 'args' => [bft_num($v)]], []);
115
+ bf_assert_eq($toString(INF), 'Infinity');
116
+ bf_assert_eq($toString(-INF), '-Infinity');
117
+ bf_assert_eq($toString(INF - INF), 'NaN');
118
+ });
119
+
120
+ bf_test('captured free vars via base_env', function () {
121
+ $body = bft_bin('+', bft_id('acc'), bft_bin('*', bft_id('item'), bft_id('factor')));
122
+ $total = Evaluator::fold([1, 2, 3], $body, 'acc', 'item', 0, 'left', ['factor' => 10]);
123
+ bf_assert_eq($total, 60.0);
124
+
125
+ $cmp = bft_bin(
126
+ '-',
127
+ bft_call_math('abs', bft_bin('-', bft_id('a'), bft_id('pivot'))),
128
+ bft_call_math('abs', bft_bin('-', bft_id('b'), bft_id('pivot')))
129
+ );
130
+ $sorted = Evaluator::sortBy([1, 8, 4], $cmp, 'a', 'b', ['pivot' => 5]);
131
+ bf_assert_eq($sorted, [4, 8, 1]);
132
+ });
133
+
134
+ bf_test('boolean-valued ops return real booleans', function () {
135
+ $lt = Evaluator::evaluate(bft_bin('<', bft_id('a'), bft_id('b')), ['a' => 1, 'b' => 2]);
136
+ bf_assert(is_bool($lt), 'expected a real bool from <');
137
+ bf_assert_eq($lt, true);
138
+
139
+ $cat = Evaluator::evaluate(bft_bin('+', bft_str('x'), bft_bin('<', bft_id('a'), bft_id('b'))), ['a' => 1, 'b' => 2]);
140
+ bf_assert_eq($cat, 'xtrue');
141
+
142
+ $eq = Evaluator::evaluate(bft_bin('===', bft_id('a'), bft_id('b')), ['a' => 1, 'b' => 1]);
143
+ bf_assert(is_bool($eq), 'expected a real bool from ===');
144
+ bf_assert_eq($eq, true);
145
+
146
+ $not = Evaluator::evaluate(['kind' => 'unary', 'op' => '!', 'argument' => bft_str('')], []);
147
+ bf_assert_eq($not, true);
148
+
149
+ $b = Evaluator::evaluate(['kind' => 'call', 'callee' => bft_id('Boolean'), 'args' => [bft_str('')]], []);
150
+ bf_assert(is_bool($b), 'expected a real bool from Boolean()');
151
+ bf_assert_eq($b, false);
152
+
153
+ // `.length` is a string/array property only; a numeric scalar has none.
154
+ $length = Evaluator::evaluate(bft_mem(bft_id('n'), 'length'), ['n' => 123]);
155
+ bf_assert_eq($length, null);
156
+ });
157
+
158
+ bf_test('array-method .includes dispatches by receiver type', function () {
159
+ $hit = Evaluator::evaluate(bft_includes(bft_id('tags'), bft_str('go')), ['tags' => ['perl', 'go']]);
160
+ bf_assert(is_bool($hit) && $hit === true, 'expected true for a matching tag');
161
+
162
+ $miss = Evaluator::evaluate(bft_includes(bft_id('tags'), bft_str('rust')), ['tags' => ['perl', 'go']]);
163
+ bf_assert(is_bool($miss) && $miss === false, 'expected false for a missing tag');
164
+
165
+ // SameValueZero, not loose equality: numeric 2 matches numeric needle 2,
166
+ // but the string needle "2" (a different JS type) does not.
167
+ $numHit = Evaluator::evaluate(bft_includes(bft_id('nums'), bft_num(2)), ['nums' => [1, 2, 3]]);
168
+ bf_assert_eq($numHit, true);
169
+ $numVsString = Evaluator::evaluate(bft_includes(bft_id('nums'), bft_str('2')), ['nums' => [1, 2, 3]]);
170
+ bf_assert_eq($numVsString, false);
171
+
172
+ $sub = Evaluator::evaluate(bft_includes(bft_id('name'), bft_str('ar')), ['name' => 'bare']);
173
+ bf_assert_eq($sub, true);
174
+
175
+ // A non-array, non-string receiver (number, null, object) is not a JS
176
+ // `.includes` target; the evaluator degrades to false rather than raising.
177
+ $scalarRecv = Evaluator::evaluate(bft_includes(bft_id('n'), bft_num(1)), ['n' => 42]);
178
+ bf_assert_eq($scalarRecv, false);
179
+ });
180
+
181
+ bf_test('strictEq: int/float unify numerically', function () {
182
+ bf_assert_eq(Evaluator::strictEq(1, 1.0), true);
183
+ bf_assert_eq(Evaluator::strictEq(1, 2), false);
184
+ bf_assert_eq(Evaluator::strictEq('1', 1), false, 'no string<->number coercion');
185
+ bf_assert_eq(Evaluator::strictEq(null, null), true);
186
+ bf_assert_eq(Evaluator::strictEq(null, false), false);
187
+ bf_assert_eq(Evaluator::strictEq(NAN, NAN), false, 'NaN !== NaN');
188
+ });
189
+
190
+ bf_test('sameValueZero: NaN matches NaN, otherwise same as strictEq', function () {
191
+ bf_assert_eq(Evaluator::sameValueZero(NAN, NAN), true);
192
+ bf_assert_eq(Evaluator::sameValueZero(1, 1.0), true);
193
+ bf_assert_eq(Evaluator::sameValueZero('2', 2), false);
194
+ });
195
+
196
+ bf_test('array-method .join: default separator, custom separator, null element', function () {
197
+ $default = Evaluator::evaluate(bft_join(bft_id('a')), ['a' => [1, 2, 3]]);
198
+ bf_assert_eq($default, '1,2,3');
199
+
200
+ $custom = Evaluator::evaluate(bft_join(bft_id('a'), bft_str(' - ')), ['a' => ['x', 'y']]);
201
+ bf_assert_eq($custom, 'x - y');
202
+
203
+ // A null element joins as '', not the string "null".
204
+ $withNull = Evaluator::evaluate(bft_join(bft_id('a'), bft_str(',')), ['a' => ['x', null, 'z']]);
205
+ bf_assert_eq($withNull, 'x,,z');
206
+
207
+ $empty = Evaluator::evaluate(bft_join(bft_id('a')), ['a' => []]);
208
+ bf_assert_eq($empty, '');
209
+ });
210
+
211
+ bf_test('nested .map: string-prefix projection inside a callback body', function () {
212
+ $body = bft_bin('+', bft_str('#'), bft_id('t'));
213
+ $node = bft_nested('map', bft_id('tags'), bft_arrow(['t'], $body));
214
+ $out = Evaluator::evaluate($node, ['tags' => ['go', 'php']]);
215
+ bf_assert_eq($out, ['#go', '#php']);
216
+ });
217
+
218
+ bf_test('nested .map: 2-param arrow (value, index)', function () {
219
+ $body = bft_bin('+', bft_id('t'), bft_id('i'));
220
+ $node = bft_nested('map', bft_id('xs'), bft_arrow(['t', 'i'], $body));
221
+ $out = Evaluator::evaluate($node, ['xs' => [10, 20, 30]]);
222
+ bf_assert_eq($out, [10.0, 21.0, 32.0]);
223
+ });
224
+
225
+ bf_test('nested .filter: predicate over the callback param', function () {
226
+ $body = bft_bin('>', bft_id('n'), bft_num(1));
227
+ $node = bft_nested('filter', bft_id('xs'), bft_arrow(['n'], $body));
228
+ $out = Evaluator::evaluate($node, ['xs' => [1, 2, 3]]);
229
+ bf_assert_eq($out, [2, 3]);
230
+ });
231
+
232
+ bf_test('nested .map/.filter: child scope does not leak into the parent env', function () {
233
+ // If the callback's env were shared (not copied), binding "t" inside the
234
+ // nested call would corrupt an outer "t" in the parent scope.
235
+ $body = bft_bin('+', bft_str('#'), bft_id('t'));
236
+ $node = bft_nested('map', bft_id('tags'), bft_arrow(['t'], $body));
237
+ $env = ['tags' => ['a', 'b'], 't' => 'OUTER'];
238
+ $out = Evaluator::evaluate($node, $env);
239
+ bf_assert_eq($out, ['#a', '#b']);
240
+ bf_assert_eq($env['t'], 'OUTER', 'outer env must be untouched after the nested call');
241
+ });
242
+
243
+ bf_test('.length works on both arrays and strings (code points, not bytes)', function () {
244
+ $arrLen = Evaluator::evaluate(bft_mem(bft_id('xs'), 'length'), ['xs' => [1, 2, 3]]);
245
+ bf_assert_eq($arrLen, 3);
246
+
247
+ $strLen = Evaluator::evaluate(bft_mem(bft_id('s'), 'length'), ['s' => 'abc']);
248
+ bf_assert_eq($strLen, 3);
249
+
250
+ // Multi-byte UTF-8 string: 3 code points, more than 3 bytes.
251
+ $utf8Len = Evaluator::evaluate(bft_mem(bft_id('s'), 'length'), ['s' => 'héllo']);
252
+ bf_assert_eq($utf8Len, 5);
253
+ });
254
+
255
+ bf_test('nested .filter composed with .length', function () {
256
+ $pred = bft_bin('>', bft_id('n'), bft_num(1));
257
+ $filtered = bft_nested('filter', bft_id('xs'), bft_arrow(['n'], $pred));
258
+ $lengthGtZero = bft_bin('>', bft_mem($filtered, 'length'), bft_num(0));
259
+ $out = Evaluator::evaluate($lengthGtZero, ['xs' => [1, 2, 3]]);
260
+ bf_assert_eq($out, true);
261
+
262
+ $outEmpty = Evaluator::evaluate($lengthGtZero, ['xs' => [1]]);
263
+ bf_assert_eq($outEmpty, false);
264
+ });
265
+
266
+ bf_test('formatNumber: shortest round-trip, JS integral spelling', function () {
267
+ bf_assert_eq(Evaluator::formatNumber(1.0), '1');
268
+ bf_assert_eq(Evaluator::formatNumber(0.1), '0.1');
269
+ bf_assert_eq(Evaluator::formatNumber(0.30000000000000004), '0.30000000000000004');
270
+ bf_assert_eq(Evaluator::formatNumber(0.0), '0');
271
+ bf_assert_eq(Evaluator::formatNumber(-0.0), '0');
272
+ });
273
+
274
+ return bf_finish();
@@ -0,0 +1,334 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ /**
6
+ * Golden helper-vector conformance, ported from
7
+ * packages/adapter-perl/t/helper_vectors.t (see also the Python port's
8
+ * test_helper_vectors.py).
9
+ *
10
+ * Runs packages/adapter-tests/vectors/vectors.json -- generated from
11
+ * the JS reference implementations (spec/template-helpers.md) -- against
12
+ * this package's BarefootJS runtime. One binding per canonical helper id in
13
+ * the spec catalogue, bound to the exact code shape a compiled Twig
14
+ * template would execute (a `bf.<method>(...)` call, or the native PHP
15
+ * operator the adapter emits for add/sub/mul/div/neg).
16
+ *
17
+ * Per spec/template-helpers.md's "Adapter status model", this backend's
18
+ * divergences from the JS-normative expect live in
19
+ * `tests/vector-divergences.json` (package-local, next to this file) --
20
+ * keyed by fn/note, mirroring the Perl/Python harnesses' divergence tables
21
+ * (values differ where PHP's actual behaviour differs from Perl's/Python's,
22
+ * all independently re-derived and verified against a live PHP 8.4
23
+ * interpreter -- see the runtime source docstrings). This harness fails on
24
+ * stale or dead declarations in that file; it's checked again, centrally,
25
+ * by packages/adapter-tests/src/__tests__/divergences.test.ts.
26
+ *
27
+ * Unlike the Python/Perl/Ruby ports, a missing golden-vector corpus or a
28
+ * missing vector-divergences.json is a LOUD failure here, not a silent
29
+ * skip -- a silent skip after the corpus moved from
30
+ * packages/adapter-tests/helper-vectors/ to packages/adapter-tests/vectors/
31
+ * (#2084) let the suite quietly shrink from ~387 cases to 64 with zero
32
+ * reported failures.
33
+ */
34
+
35
+ require_once __DIR__ . '/_harness.php';
36
+ bf_require_runtime();
37
+ bf_reset();
38
+
39
+ use Barefoot\BarefootJS;
40
+ use Barefoot\Evaluator;
41
+
42
+ $VECTORS_PATH = __DIR__ . '/../../adapter-tests/vectors/vectors.json';
43
+ $DIVERGENCES_PATH = __DIR__ . '/vector-divergences.json';
44
+
45
+ if (!is_file($VECTORS_PATH)) {
46
+ bf_test('golden vectors corpus is present', function () use ($VECTORS_PATH) {
47
+ bf_assert(false, "vectors.json not found at {$VECTORS_PATH} -- regenerate it (cd packages/adapter-tests && bun run generate:helper-vectors) or check this path after a corpus move");
48
+ });
49
+ return bf_finish();
50
+ }
51
+
52
+ if (!is_file($DIVERGENCES_PATH)) {
53
+ bf_test('vector-divergences.json is present', function () use ($DIVERGENCES_PATH) {
54
+ bf_assert(false, "vector-divergences.json not found at {$DIVERGENCES_PATH} -- this backend's divergence declarations are required, see packages/adapter-tests/vectors/README.md");
55
+ });
56
+ return bf_finish();
57
+ }
58
+
59
+ $backend = new class {
60
+ public function encode_json($data): string
61
+ {
62
+ return \Barefoot\Json::canonicalEncode($data);
63
+ }
64
+
65
+ public function mark_raw($s)
66
+ {
67
+ return $s;
68
+ }
69
+
70
+ public function materialize($value)
71
+ {
72
+ return is_callable($value) ? $value() : $value;
73
+ }
74
+
75
+ public function render_named(...$args)
76
+ {
77
+ return '';
78
+ }
79
+ };
80
+
81
+ $bf = new BarefootJS(null, ['backend' => $backend]);
82
+
83
+ // -----------------------------------------------------------------
84
+ // Predicate builders for the projection-form vector cases (spec: items +
85
+ // field [+ value]) -- rebuilds the predicate closure the adapter compiles.
86
+ // -----------------------------------------------------------------
87
+
88
+ function bfv_truthy_pred(BarefootJS $bf, string $field): callable
89
+ {
90
+ return function ($item) use ($bf, $field) {
91
+ if (!is_array($item) && !($item instanceof \stdClass)) {
92
+ return false;
93
+ }
94
+ $v = $item instanceof \stdClass ? ($item->$field ?? null) : ($item[$field] ?? null);
95
+ return $bf->truthy($v);
96
+ };
97
+ }
98
+
99
+ function bfv_field_eq_pred(string $field, $value): callable
100
+ {
101
+ return function ($item) use ($field, $value) {
102
+ if (!is_array($item) && !($item instanceof \stdClass)) {
103
+ return false;
104
+ }
105
+ $v = $item instanceof \stdClass ? ($item->$field ?? null) : ($item[$field] ?? null);
106
+ return Evaluator::strictEq($v, $value);
107
+ };
108
+ }
109
+
110
+ function bfv_bind_sort(BarefootJS $bf, $recv, ...$spec)
111
+ {
112
+ $keys = [];
113
+ while (count($spec) >= 4) {
114
+ [$kind, $name, $compareType, $direction] = array_splice($spec, 0, 4);
115
+ $keys[] = ['key_kind' => $kind, 'key' => $name, 'compare_type' => $compareType, 'direction' => $direction];
116
+ }
117
+ return $bf->sort($recv, ['keys' => $keys]);
118
+ }
119
+
120
+ function bfv_bind_reduce(BarefootJS $bf, $recv, $op, $keyKind, $key, $rtype, $init, $direction)
121
+ {
122
+ $seed = $rtype === 'numeric' ? (float) $init : $init;
123
+ return $bf->reduce($recv, [
124
+ 'op' => $op, 'key_kind' => $keyKind, 'key' => $key, 'type' => $rtype, 'init' => $seed, 'direction' => $direction,
125
+ ]);
126
+ }
127
+
128
+ /**
129
+ * Dispatch one vector case's `fn` to the exact runtime call shape a
130
+ * compiled template would emit. Throws for helper ids with no PHP binding
131
+ * (fail loudly rather than silently skip) and lets underlying runtime
132
+ * exceptions (e.g. DivisionByZeroError) propagate to the caller.
133
+ */
134
+ function bfv_call(BarefootJS $bf, string $fn, array $args)
135
+ {
136
+ switch ($fn) {
137
+ case 'add': return $args[0] + $args[1];
138
+ case 'sub': return $args[0] - $args[1];
139
+ case 'mul': return $args[0] * $args[1];
140
+ case 'div': return $args[0] / $args[1];
141
+ case 'mod': return $bf->mod($args[0], $args[1]);
142
+ case 'neg': return -$args[0];
143
+ case 'string': return $bf->string($args[0]);
144
+ case 'json': return $bf->json($args[0]);
145
+ case 'number': return $bf->number($args[0]);
146
+ case 'floor': return $bf->floor($args[0]);
147
+ case 'ceil': return $bf->ceil($args[0]);
148
+ case 'round': return $bf->round($args[0]);
149
+ case 'to_fixed': return $bf->to_fixed(...$args);
150
+ case 'lower': return $bf->lc($args[0]);
151
+ case 'upper': return $bf->uc($args[0]);
152
+ case 'trim': return $bf->trim($args[0]);
153
+ case 'starts_with': return $bf->starts_with(...$args);
154
+ case 'ends_with': return $bf->ends_with(...$args);
155
+ case 'replace': return $bf->replace(...$args);
156
+ case 'repeat': return $bf->repeat(...$args);
157
+ case 'pad_start': return $bf->pad_start(...$args);
158
+ case 'pad_end': return $bf->pad_end(...$args);
159
+ case 'split': return $bf->split(...$args);
160
+ case 'len': return $bf->length($args[0]);
161
+ case 'at': return $bf->at(...$args);
162
+ case 'includes': return $bf->includes(...$args);
163
+ case 'index_of': return $bf->index_of(...$args);
164
+ case 'last_index_of': return $bf->last_index_of(...$args);
165
+ case 'concat': return $bf->concat($args[0], $args[1]);
166
+ case 'slice': return $bf->slice($args[0], $args[1], $args[2] ?? null);
167
+ case 'reverse': return $bf->reverse($args[0]);
168
+ case 'flat': return $bf->flat(...$args);
169
+ case 'flat_dynamic': return $bf->flat_dynamic(...$args);
170
+ case 'join': return $bf->join(...$args);
171
+ case 'arr': return $args; // variadic array-literal elements, in order
172
+ case 'filter_truthy': return array_values(array_filter($args[0], fn ($x) => $bf->truthy($x)));
173
+ case 'search_params_get': return BarefootJS::search_params($args[0])->get($args[1]);
174
+ case 'query': return $bf->query(...$args);
175
+ case 'every': return $bf->every($args[0], bfv_truthy_pred($bf, $args[1]));
176
+ case 'some': return $bf->some($args[0], bfv_truthy_pred($bf, $args[1]));
177
+ case 'filter': return $bf->filter($args[0], bfv_field_eq_pred($args[1], $args[2]));
178
+ case 'find': return $bf->find($args[0], bfv_field_eq_pred($args[1], $args[2]));
179
+ case 'find_index': return $bf->find_index($args[0], bfv_field_eq_pred($args[1], $args[2]));
180
+ case 'find_last': return $bf->find_last($args[0], bfv_field_eq_pred($args[1], $args[2]));
181
+ case 'find_last_index': return $bf->find_last_index($args[0], bfv_field_eq_pred($args[1], $args[2]));
182
+ case 'sort': return bfv_bind_sort($bf, ...$args);
183
+ case 'reduce': return bfv_bind_reduce($bf, ...$args);
184
+ case 'flat_map': return $bf->flat_map(...$args);
185
+ case 'flat_map_tuple': return $bf->flat_map_tuple(...$args);
186
+ default:
187
+ throw new \RuntimeException("no PHP binding for helper '{$fn}' -- add it to bfv_call()");
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Per-backend status declarations (spec/template-helpers.md "Adapter status
193
+ * model"), loaded from tests/vector-divergences.json (package-local, next
194
+ * to this file). Divergence entry forms (JSON schema, see
195
+ * packages/adapter-tests/vectors/README.md):
196
+ * {"expect": <value>} assert the pinned value (exact --
197
+ * deliberately stricter than bfv_match's
198
+ * value-compat so an int-vs-float
199
+ * rounding accident can't hide behind the
200
+ * comparison)
201
+ * {"expect": {"$num": "NaN"}} assert a real NaN result
202
+ * {"throws": true, "exception": <FQCN>} assert the call throws that
203
+ * exception class (defaults to
204
+ * \Throwable if `exception` is absent)
205
+ *
206
+ * Every entry was independently re-derived against a live PHP 8.4
207
+ * interpreter (see php -r probes referenced in the runtime source
208
+ * docstrings), not copy-pasted from the Perl/Python tables -- several of
209
+ * PHP's actual divergent VALUES differ from Perl's even though the REASON
210
+ * is the same class of divergence (e.g. PHP's `sort` produces the same
211
+ * ["B","a"] ordering Python does, not Perl's differently-cased fixture).
212
+ */
213
+ $divergencesDoc = json_decode(file_get_contents($DIVERGENCES_PATH), true);
214
+ $DIVERGENCES = $divergencesDoc['divergences'] ?? [];
215
+ $UNSUPPORTED = $divergencesDoc['unsupported'] ?? [];
216
+
217
+ function bfv_match($got, $expect): bool
218
+ {
219
+ if ($expect === null) {
220
+ return $got === null;
221
+ }
222
+ if (is_array($expect) && array_key_exists('$num', $expect) && count($expect) === 1) {
223
+ $kind = $expect['$num'];
224
+ if (is_bool($got) || !(is_int($got) || is_float($got))) {
225
+ return false;
226
+ }
227
+ $g = (float) $got;
228
+ if ($kind === 'NaN') {
229
+ return is_nan($g);
230
+ }
231
+ return $g === ($kind === 'Infinity' ? INF : -INF);
232
+ }
233
+ if (is_bool($expect)) {
234
+ return (bool) $got === $expect;
235
+ }
236
+ if (is_array($expect) && array_is_list($expect)) {
237
+ if (!is_array($got) || !array_is_list($got) || count($got) !== count($expect)) {
238
+ return false;
239
+ }
240
+ foreach ($expect as $i => $e) {
241
+ if (!bfv_match($got[$i] ?? null, $e)) {
242
+ return false;
243
+ }
244
+ }
245
+ return true;
246
+ }
247
+ if (is_array($expect)) { // JSON object
248
+ $gotArr = $got instanceof \stdClass ? get_object_vars($got) : (is_array($got) ? $got : null);
249
+ if ($gotArr === null || count($gotArr) !== count($expect)) {
250
+ return false;
251
+ }
252
+ foreach ($expect as $k => $v) {
253
+ if (!array_key_exists($k, $gotArr) || !bfv_match($gotArr[$k], $v)) {
254
+ return false;
255
+ }
256
+ }
257
+ return true;
258
+ }
259
+ if ($got === null || is_array($got) || $got instanceof \stdClass) {
260
+ return false;
261
+ }
262
+ if ((is_int($expect) || is_float($expect))) {
263
+ if (is_bool($got) || !(is_int($got) || is_float($got))) {
264
+ return false;
265
+ }
266
+ if (is_int($got) && is_int($expect)) {
267
+ return $got === $expect;
268
+ }
269
+ return (float) $got === (float) $expect;
270
+ }
271
+ return $got === $expect; // strings
272
+ }
273
+
274
+ $doc = json_decode(file_get_contents($VECTORS_PATH), true);
275
+ bf_assert(!empty($doc['cases']), 'vectors.json contains no cases');
276
+
277
+ $seenDeclarations = [];
278
+
279
+ foreach ($doc['cases'] as $case) {
280
+ $fn = $case['fn'];
281
+ $note = $case['note'];
282
+ $args = $case['args'];
283
+ $expect = $case['expect'];
284
+ $key = "{$fn}/{$note}";
285
+
286
+ if (isset($UNSUPPORTED[$fn])) {
287
+ bf_skip($key, "unsupported on this backend: {$UNSUPPORTED[$fn]}");
288
+ continue;
289
+ }
290
+
291
+ $divergence = $DIVERGENCES[$key] ?? null;
292
+
293
+ bf_test($key, function () use ($bf, $fn, $args, $expect, $key, $divergence, &$seenDeclarations) {
294
+ if ($divergence !== null && !empty($divergence['throws'])) {
295
+ $seenDeclarations[$key] = true;
296
+ $exceptionClass = $divergence['exception'] ?? \Throwable::class;
297
+ try {
298
+ bfv_call($bf, $fn, $args);
299
+ bf_assert(false, "expected {$exceptionClass} but no exception was thrown ({$divergence['reason']})");
300
+ } catch (\Throwable $e) {
301
+ bf_assert(
302
+ $e instanceof $exceptionClass,
303
+ "expected {$exceptionClass}, got " . get_class($e) . ": {$e->getMessage()}"
304
+ );
305
+ }
306
+ return;
307
+ }
308
+
309
+ $got = bfv_call($bf, $fn, $args);
310
+
311
+ if ($divergence !== null) {
312
+ $seenDeclarations[$key] = true;
313
+ bf_assert(!bfv_match($got, $expect), "stale divergence declaration for '{$key}' -- the backend now matches JS; remove it");
314
+ $want = $divergence['expect'];
315
+ if (is_array($want) && array_key_exists('$num', $want) && count($want) === 1) {
316
+ bf_assert(bfv_match($got, $want), "{$key} (declared divergence: {$divergence['reason']}): got " . bf_fmt($got) . ', want ' . bf_fmt($want));
317
+ } elseif ((is_int($want) || is_float($want)) && (is_int($got) || is_float($got))) {
318
+ bf_assert((float) $got === (float) $want, "{$key} (declared divergence): expected " . bf_fmt($want) . ', got ' . bf_fmt($got));
319
+ } else {
320
+ bf_assert_eq($got, $want, "{$key} (declared divergence: {$divergence['reason']})");
321
+ }
322
+ return;
323
+ }
324
+
325
+ bf_assert(bfv_match($got, $expect), "{$key}: got " . bf_fmt($got) . ', want ' . bf_fmt($expect));
326
+ });
327
+ }
328
+
329
+ $stale = array_diff(array_keys($DIVERGENCES), array_keys($seenDeclarations));
330
+ bf_test('no stale divergence declarations', function () use ($stale) {
331
+ bf_assert($stale === [], 'divergence declarations match no vector case -- renamed note? ' . implode(', ', $stale));
332
+ });
333
+
334
+ return bf_finish();
@@ -0,0 +1,57 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ /**
6
+ * `BarefootJS::omit` -- object-rest destructure residual (#2087 Phase B).
7
+ *
8
+ * Backs the Twig adapter's `.map(({ id, title, ...rest }) => ...)` lowering
9
+ * (`twig-adapter.ts`'s `renderLoop`): each loop iteration binds `rest` to
10
+ * `bf.omit(item, [excludeKeys])`, a TRUE residual hash (every key of the
11
+ * item except the ones the pattern destructured explicitly), not an alias
12
+ * of the whole item. Every template-adapter runtime ships the same helper
13
+ * under the same contract (#2087 Phase B: Go `bf_omit`, shared Perl
14
+ * `BarefootJS::omit`, Python/Rust `bf.omit`) -- except ERB, which uses
15
+ * Ruby's native `Hash#except`. Only the JS/CSR side has no `omit`: it
16
+ * materializes the residual with a real destructure IIFE instead.
17
+ */
18
+
19
+ require_once __DIR__ . '/_harness.php';
20
+ bf_require_runtime();
21
+ bf_reset();
22
+
23
+ use Barefoot\BarefootJS;
24
+
25
+ $bf = new BarefootJS(null, ['backend' => new class {
26
+ public function mark_raw($s)
27
+ {
28
+ return $s;
29
+ }
30
+ }]);
31
+
32
+ bf_test('basic shapes', function () use ($bf) {
33
+ bf_assert_eq($bf->omit(null, ['id']), []);
34
+ bf_assert_eq($bf->omit('not a bag', ['id']), []);
35
+ bf_assert_eq($bf->omit([], ['id']), []);
36
+ bf_assert_eq($bf->omit(['id' => 'a', 'flag' => 'x'], []), ['id' => 'a', 'flag' => 'x']);
37
+ });
38
+
39
+ bf_test('excludes the destructured sibling keys, keeps the rest', function () use ($bf) {
40
+ $item = ['id' => 't1', 'title' => 'one', 'data-priority' => 'high', 'tag' => 'urgent'];
41
+ bf_assert_eq($bf->omit($item, ['id', 'title']), ['data-priority' => 'high', 'tag' => 'urgent']);
42
+ });
43
+
44
+ bf_test('accepts a stdClass bag (JSON-decoded loop item shape)', function () use ($bf) {
45
+ $item = json_decode('{"id": "t1", "title": "one", "flag": "a"}');
46
+ bf_assert_eq($bf->omit($item, ['id', 'title']), ['flag' => 'a']);
47
+ });
48
+
49
+ bf_test('excluding every key yields an empty residual', function () use ($bf) {
50
+ bf_assert_eq($bf->omit(['id' => 'a'], ['id']), []);
51
+ });
52
+
53
+ bf_test('an exclude key absent from the bag is a no-op', function () use ($bf) {
54
+ bf_assert_eq($bf->omit(['id' => 'a'], ['nope']), ['id' => 'a']);
55
+ });
56
+
57
+ return bf_finish();