@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.
- package/README.md +69 -0
- package/composer.json +21 -0
- package/package.json +26 -0
- package/src/BarefootJS.php +1705 -0
- package/src/Evaluator.php +968 -0
- package/src/Json.php +67 -0
- package/src/SearchParams.php +77 -0
- package/tests/_harness.php +164 -0
- package/tests/run.php +72 -0
- package/tests/test_eval_vectors.php +119 -0
- package/tests/test_evaluator.php +274 -0
- package/tests/test_helper_vectors.php +334 -0
- package/tests/test_omit.php +57 -0
- package/tests/test_props_attr.php +63 -0
- package/tests/test_query.php +47 -0
- package/tests/test_render_child.php +123 -0
- package/tests/test_search_params.php +56 -0
- package/tests/test_spread_attrs.php +98 -0
- package/tests/test_template_primitives.php +370 -0
- package/tests/vector-divergences.json +42 -0
|
@@ -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();
|