@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
package/src/Json.php
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Barefoot;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Canonical (sorted-key, JS-`JSON.stringify`-parity) JSON encoding, shared by
|
|
9
|
+
* every engine backend (`TwigBackend`, and future `BladeBackend` et al.).
|
|
10
|
+
* Extracted from `TwigBackend::defaultJsonEncoder` / `prepareForJson` so the
|
|
11
|
+
* engine-agnostic runtime (`BarefootJS`) and any backend can depend on one
|
|
12
|
+
* canonical encoder without depending on a specific template engine.
|
|
13
|
+
*/
|
|
14
|
+
final class Json
|
|
15
|
+
{
|
|
16
|
+
/**
|
|
17
|
+
* `sort_keys` parity with the Python backend's `default_json_encoder`
|
|
18
|
+
* (`sort_keys=True`) / the Xslate backend's `JSON::PP->canonical`: keys
|
|
19
|
+
* are recursively sorted so output is deterministic. `JSON_UNESCAPED_SLASHES`
|
|
20
|
+
* matches `JSON.stringify`'s un-escaped `/`. Non-ASCII is `\uXXXX`-escaped
|
|
21
|
+
* (PHP's default, matching Python's default `ensure_ascii`).
|
|
22
|
+
*/
|
|
23
|
+
public static function canonicalEncode($data): string
|
|
24
|
+
{
|
|
25
|
+
$prepared = self::prepareForJson($data);
|
|
26
|
+
$json = json_encode($prepared, JSON_UNESCAPED_SLASHES);
|
|
27
|
+
if ($json === false) {
|
|
28
|
+
throw new \RuntimeException('encode_json failed: ' . json_last_error_msg());
|
|
29
|
+
}
|
|
30
|
+
return $json;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Recursively replace non-finite floats with `null` (JSON has no
|
|
35
|
+
* NaN/Infinity -- matches `JSON.stringify(NaN)` at any depth) and sort
|
|
36
|
+
* object keys (stdClass or a non-list/assoc array) for canonical,
|
|
37
|
+
* deterministic output. List arrays are recursed element-wise without
|
|
38
|
+
* reordering.
|
|
39
|
+
*/
|
|
40
|
+
private static function prepareForJson($value)
|
|
41
|
+
{
|
|
42
|
+
if (is_float($value)) {
|
|
43
|
+
return (is_nan($value) || is_infinite($value)) ? null : $value;
|
|
44
|
+
}
|
|
45
|
+
if ($value instanceof \stdClass) {
|
|
46
|
+
$vars = get_object_vars($value);
|
|
47
|
+
ksort($vars, SORT_STRING);
|
|
48
|
+
$out = new \stdClass();
|
|
49
|
+
foreach ($vars as $k => $v) {
|
|
50
|
+
$out->$k = self::prepareForJson($v);
|
|
51
|
+
}
|
|
52
|
+
return $out;
|
|
53
|
+
}
|
|
54
|
+
if (is_array($value)) {
|
|
55
|
+
if (array_is_list($value)) {
|
|
56
|
+
return array_map([self::class, 'prepareForJson'], $value);
|
|
57
|
+
}
|
|
58
|
+
$out = [];
|
|
59
|
+
foreach ($value as $k => $v) {
|
|
60
|
+
$out[$k] = self::prepareForJson($v);
|
|
61
|
+
}
|
|
62
|
+
ksort($out, SORT_STRING);
|
|
63
|
+
return $out;
|
|
64
|
+
}
|
|
65
|
+
return $value;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Barefoot;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Port of packages/adapter-perl/lib/BarefootJS/SearchParams.pm (see also the
|
|
9
|
+
* Python port packages/adapter-jinja/python/barefootjs/search_params.py).
|
|
10
|
+
*
|
|
11
|
+
* Request-scoped SSR view of the query string behind the reactive
|
|
12
|
+
* `searchParams()` environment signal (router v0.5, #1922). The framework
|
|
13
|
+
* integration builds one per request from the request URL and threads it
|
|
14
|
+
* into the template scope as `searchParams`; the compiled template reads it
|
|
15
|
+
* via `{{ searchParams.get('key') }}`.
|
|
16
|
+
*
|
|
17
|
+
* Semantics mirror the browser's `URLSearchParams.get` exactly under the
|
|
18
|
+
* adapters' `?? ->` lowering: `get()` returns the first value for a key, or
|
|
19
|
+
* `null` when the key is absent -- Twig's native `??` coalesces both
|
|
20
|
+
* "undefined" and `null`, so `searchParams.get('sort') ?? 'name'` falls back
|
|
21
|
+
* only when the key is truly absent, while a present-but-empty value
|
|
22
|
+
* (`?sort=`) keeps the empty string.
|
|
23
|
+
*/
|
|
24
|
+
final class SearchParams
|
|
25
|
+
{
|
|
26
|
+
/** @var array<string, list<string>> */
|
|
27
|
+
private array $values = [];
|
|
28
|
+
|
|
29
|
+
public function __construct(string $query = '')
|
|
30
|
+
{
|
|
31
|
+
if (str_starts_with($query, '?')) {
|
|
32
|
+
$query = substr($query, 1);
|
|
33
|
+
}
|
|
34
|
+
foreach (preg_split('/[&;]/', $query) as $pair) {
|
|
35
|
+
if ($pair === '') {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
$eq = strpos($pair, '=');
|
|
39
|
+
if ($eq === false) {
|
|
40
|
+
$key = $pair;
|
|
41
|
+
$rawVal = null;
|
|
42
|
+
} else {
|
|
43
|
+
$key = substr($pair, 0, $eq);
|
|
44
|
+
$rawVal = substr($pair, $eq + 1);
|
|
45
|
+
}
|
|
46
|
+
$decodedKey = self::decode($key);
|
|
47
|
+
$decodedVal = $rawVal !== null ? self::decode($rawVal) : '';
|
|
48
|
+
$this->values[$decodedKey][] = $decodedVal;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** First value for `$key`, or `null` when the key is absent. A
|
|
53
|
+
* present-but-empty value returns ''. */
|
|
54
|
+
public function get(string $key): ?string
|
|
55
|
+
{
|
|
56
|
+
$vals = $this->values[$key] ?? null;
|
|
57
|
+
if (!$vals) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return $vals[0];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Percent/`+`-decode a query-string component, mirroring
|
|
64
|
+
* `URLSearchParams`'s `application/x-www-form-urlencoded` parsing. Never
|
|
65
|
+
* raises on malformed input -- PHP strings are raw byte sequences, so an
|
|
66
|
+
* invalid UTF-8 byte run is simply carried through unchanged (the same
|
|
67
|
+
* lenient behaviour Perl's `utf8::decode` -- which never dies -- gives). */
|
|
68
|
+
private static function decode(string $s): string
|
|
69
|
+
{
|
|
70
|
+
$s = str_replace('+', ' ', $s);
|
|
71
|
+
return (string) preg_replace_callback(
|
|
72
|
+
'/%([0-9A-Fa-f]{2})/',
|
|
73
|
+
static fn (array $m) => chr((int) hexdec($m[1])),
|
|
74
|
+
$s
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tiny zero-dependency TAP-ish assertion harness shared by every
|
|
7
|
+
* php/tests/test_*.php file (NOT one of the design doc's 9 test files
|
|
8
|
+
* itself -- a private support module, mirroring how the Perl port's t/*.t
|
|
9
|
+
* files share Test::More and the Python port's test_*.py files share
|
|
10
|
+
* unittest.TestCase; this package intentionally carries no PHPUnit
|
|
11
|
+
* dependency, so this file is the minimal stand-in).
|
|
12
|
+
*
|
|
13
|
+
* Each test file:
|
|
14
|
+
* 1. `require_once`s this file (guarded so it's a no-op on a second load).
|
|
15
|
+
* 2. calls `bf_reset()` to start with a clean per-file counter.
|
|
16
|
+
* 3. calls `bf_test($name, fn)` once per case.
|
|
17
|
+
* 4. ends with `bf_finish()`, which either `return`s a
|
|
18
|
+
* `['pass' => n, 'fail' => n]` summary (when `run.php` is driving,
|
|
19
|
+
* signalled by the `BF_RUNNER` constant) or prints a summary and
|
|
20
|
+
* `exit()`s with the right code (when the file is run standalone via
|
|
21
|
+
* `php test_foo.php`).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
$GLOBALS['__bf_pass'] = 0;
|
|
25
|
+
$GLOBALS['__bf_fail'] = 0;
|
|
26
|
+
$GLOBALS['__bf_failures'] = [];
|
|
27
|
+
$GLOBALS['__bf_skipped'] = false;
|
|
28
|
+
$GLOBALS['__bf_skip_count'] = 0;
|
|
29
|
+
|
|
30
|
+
function bf_reset(): void
|
|
31
|
+
{
|
|
32
|
+
$GLOBALS['__bf_pass'] = 0;
|
|
33
|
+
$GLOBALS['__bf_fail'] = 0;
|
|
34
|
+
$GLOBALS['__bf_failures'] = [];
|
|
35
|
+
$GLOBALS['__bf_skipped'] = false;
|
|
36
|
+
$GLOBALS['__bf_skip_count'] = 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function bf_test(string $name, callable $fn): void
|
|
40
|
+
{
|
|
41
|
+
try {
|
|
42
|
+
$fn();
|
|
43
|
+
$GLOBALS['__bf_pass']++;
|
|
44
|
+
echo "ok - {$name}\n";
|
|
45
|
+
} catch (\Throwable $e) {
|
|
46
|
+
$GLOBALS['__bf_fail']++;
|
|
47
|
+
$GLOBALS['__bf_failures'][] = "{$name}: {$e->getMessage()}";
|
|
48
|
+
echo "not ok - {$name}: {$e->getMessage()}\n";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Record one case as VISIBLY skipped (TAP "# SKIP" directive), as opposed
|
|
54
|
+
* to `bf_test()`'s pass/fail -- used for a per-backend `unsupported`
|
|
55
|
+
* declaration (spec/template-helpers.md "Adapter status model"), so a
|
|
56
|
+
* helper with no binding on this backend shows up in the run's output
|
|
57
|
+
* instead of silently vanishing from the case count.
|
|
58
|
+
*/
|
|
59
|
+
function bf_skip(string $name, string $reason): void
|
|
60
|
+
{
|
|
61
|
+
$GLOBALS['__bf_skip_count']++;
|
|
62
|
+
echo "ok - {$name} # SKIP {$reason}\n";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function bf_assert(bool $cond, string $message = 'assertion failed'): void
|
|
66
|
+
{
|
|
67
|
+
if (!$cond) {
|
|
68
|
+
throw new \RuntimeException($message);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function bf_fmt($v): string
|
|
73
|
+
{
|
|
74
|
+
if ($v === null) {
|
|
75
|
+
return 'null';
|
|
76
|
+
}
|
|
77
|
+
if (is_bool($v)) {
|
|
78
|
+
return $v ? 'true' : 'false';
|
|
79
|
+
}
|
|
80
|
+
if (is_float($v) && is_nan($v)) {
|
|
81
|
+
return 'NaN';
|
|
82
|
+
}
|
|
83
|
+
if (is_scalar($v)) {
|
|
84
|
+
return var_export($v, true);
|
|
85
|
+
}
|
|
86
|
+
if ($v instanceof \stdClass) {
|
|
87
|
+
return 'object:' . json_encode($v);
|
|
88
|
+
}
|
|
89
|
+
return json_encode($v);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function bf_assert_eq($actual, $expected, string $message = ''): void
|
|
93
|
+
{
|
|
94
|
+
$ok = $actual === $expected;
|
|
95
|
+
if (!$ok && is_float($actual) && is_float($expected) && is_nan($actual) && is_nan($expected)) {
|
|
96
|
+
$ok = true;
|
|
97
|
+
}
|
|
98
|
+
if (!$ok) {
|
|
99
|
+
$prefix = $message !== '' ? "{$message}: " : '';
|
|
100
|
+
throw new \RuntimeException($prefix . 'expected ' . bf_fmt($expected) . ', got ' . bf_fmt($actual));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function bf_assert_nan($actual, string $message = 'expected NaN'): void
|
|
105
|
+
{
|
|
106
|
+
bf_assert(is_float($actual) && is_nan($actual), $message . ' (got ' . bf_fmt($actual) . ')');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Mark the whole file as skipped (e.g. a golden-vectors fixture or vendor/
|
|
110
|
+
* dependency isn't available). Distinct from a failing test. */
|
|
111
|
+
function bf_skip_file(string $reason): void
|
|
112
|
+
{
|
|
113
|
+
echo "skip - {$reason}\n";
|
|
114
|
+
$GLOBALS['__bf_skipped'] = true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function bf_finish(): array
|
|
118
|
+
{
|
|
119
|
+
$pass = $GLOBALS['__bf_pass'];
|
|
120
|
+
$fail = $GLOBALS['__bf_fail'];
|
|
121
|
+
$skipped = $GLOBALS['__bf_skipped'];
|
|
122
|
+
$skipCount = $GLOBALS['__bf_skip_count'];
|
|
123
|
+
if ($fail > 0) {
|
|
124
|
+
fwrite(STDERR, "\nFailures:\n");
|
|
125
|
+
foreach ($GLOBALS['__bf_failures'] as $f) {
|
|
126
|
+
fwrite(STDERR, " - {$f}\n");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
$result = ['pass' => $pass, 'fail' => $fail, 'skipped' => $skipped, 'skip_count' => $skipCount];
|
|
130
|
+
if (defined('BF_RUNNER')) {
|
|
131
|
+
return $result;
|
|
132
|
+
}
|
|
133
|
+
printf(
|
|
134
|
+
"%s: pass=%d fail=%d%s%s\n",
|
|
135
|
+
basename($_SERVER['SCRIPT_NAME'] ?? 'test'),
|
|
136
|
+
$pass,
|
|
137
|
+
$fail,
|
|
138
|
+
$skipCount > 0 ? " skip={$skipCount}" : '',
|
|
139
|
+
$skipped ? ' (skipped)' : ''
|
|
140
|
+
);
|
|
141
|
+
exit($fail > 0 ? 1 : 0);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Require the engine-agnostic runtime source files directly (no composer
|
|
145
|
+
* autoload needed). Idempotent.
|
|
146
|
+
*
|
|
147
|
+
* This package (`packages/adapter-php`) carries no engine-specific code --
|
|
148
|
+
* no `naming.php` (Twig's reserved-word set) and no `TwigBackend.php` -- so
|
|
149
|
+
* every test in this directory either needs no backend at all, or supplies
|
|
150
|
+
* its own small stub implementing the five-method backend contract
|
|
151
|
+
* (`encode_json`, `mark_raw`, `materialize`, `render_named`, `ident`). The
|
|
152
|
+
* one test that needs a REAL engine backend (a live Twig render) is
|
|
153
|
+
* `packages/adapter-twig/php/tests/test_render.php`, which lives in that
|
|
154
|
+
* engine adapter's own package and requires this file via a relative path. */
|
|
155
|
+
function bf_require_runtime(): void
|
|
156
|
+
{
|
|
157
|
+
if (class_exists(\Barefoot\BarefootJS::class)) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
require_once __DIR__ . '/../src/Evaluator.php';
|
|
161
|
+
require_once __DIR__ . '/../src/SearchParams.php';
|
|
162
|
+
require_once __DIR__ . '/../src/Json.php';
|
|
163
|
+
require_once __DIR__ . '/../src/BarefootJS.php';
|
|
164
|
+
}
|
package/tests/run.php
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Zero-dependency test runner for the engine-agnostic BarefootJS PHP
|
|
7
|
+
* runtime -- NO PHPUnit. Requires each `test_*.php` file (every one is also
|
|
8
|
+
* independently runnable via `php test_foo.php`), aggregates each file's
|
|
9
|
+
* tiny TAP-ish summary, prints a final report, and exits 1 on any failure.
|
|
10
|
+
*
|
|
11
|
+
* Usage: `php tests/run.php` from the package root, or
|
|
12
|
+
* `php packages/adapter-php/tests/run.php` from the repo root.
|
|
13
|
+
*
|
|
14
|
+
* `test_render.php` (a real, live Twig render) is NOT in this list -- it's
|
|
15
|
+
* engine-specific and lives (and runs standalone) in
|
|
16
|
+
* `packages/adapter-twig/php/tests/test_render.php`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
define('BF_RUNNER', true);
|
|
20
|
+
|
|
21
|
+
require_once __DIR__ . '/_harness.php';
|
|
22
|
+
bf_require_runtime();
|
|
23
|
+
|
|
24
|
+
$testFiles = [
|
|
25
|
+
'test_helper_vectors.php',
|
|
26
|
+
'test_eval_vectors.php',
|
|
27
|
+
'test_evaluator.php',
|
|
28
|
+
'test_template_primitives.php',
|
|
29
|
+
'test_query.php',
|
|
30
|
+
'test_search_params.php',
|
|
31
|
+
'test_spread_attrs.php',
|
|
32
|
+
'test_omit.php',
|
|
33
|
+
'test_props_attr.php',
|
|
34
|
+
'test_render_child.php',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
$results = [];
|
|
38
|
+
$totalPass = 0;
|
|
39
|
+
$totalFail = 0;
|
|
40
|
+
|
|
41
|
+
foreach ($testFiles as $file) {
|
|
42
|
+
$path = __DIR__ . '/' . $file;
|
|
43
|
+
echo "\n== {$file} ==\n";
|
|
44
|
+
if (!is_file($path)) {
|
|
45
|
+
fwrite(STDERR, "missing test file: {$file}\n");
|
|
46
|
+
$results[] = [$file, ['pass' => 0, 'fail' => 1, 'skipped' => false]];
|
|
47
|
+
$totalFail++;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
bf_reset();
|
|
51
|
+
$result = require $path;
|
|
52
|
+
if (!is_array($result)) {
|
|
53
|
+
// A test file that didn't `return bf_finish()` (shouldn't happen,
|
|
54
|
+
// but don't let a silent misconfiguration hide as a pass).
|
|
55
|
+
$result = ['pass' => 0, 'fail' => 1, 'skipped' => false];
|
|
56
|
+
fwrite(STDERR, "{$file} did not return a summary array\n");
|
|
57
|
+
}
|
|
58
|
+
$results[] = [$file, $result];
|
|
59
|
+
$totalPass += $result['pass'];
|
|
60
|
+
$totalFail += $result['fail'];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
echo "\n== Summary ==\n";
|
|
64
|
+
foreach ($results as [$file, $result]) {
|
|
65
|
+
$flag = !empty($result['skipped']) ? ' (skipped)' : '';
|
|
66
|
+
$skipCount = $result['skip_count'] ?? 0;
|
|
67
|
+
$skipSuffix = $skipCount > 0 ? " skip={$skipCount}" : '';
|
|
68
|
+
printf("%-32s pass=%-4d fail=%-4d%s%s\n", $file, $result['pass'], $result['fail'], $skipSuffix, $flag);
|
|
69
|
+
}
|
|
70
|
+
printf("TOTAL: pass=%d fail=%d\n", $totalPass, $totalFail);
|
|
71
|
+
|
|
72
|
+
exit($totalFail > 0 ? 1 : 0);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Golden ParsedExpr-evaluator vectors, ported from
|
|
7
|
+
* packages/adapter-perl/t/eval_vectors.t (see also the Python port's
|
|
8
|
+
* test_eval_vectors.py).
|
|
9
|
+
*
|
|
10
|
+
* Runs packages/adapter-tests/vectors/eval-vectors.json -- generated
|
|
11
|
+
* from the JS reference evaluator, shared with the Go/Perl/Python
|
|
12
|
+
* evaluators -- against Evaluator::evaluate(). The evaluator is JS-faithful
|
|
13
|
+
* by contract, so unlike the helper vectors there are NO PHP-side
|
|
14
|
+
* divergences here: each case's real ParsedExpr tree, evaluated against its
|
|
15
|
+
* environment, must reproduce the JS-computed expect exactly.
|
|
16
|
+
*
|
|
17
|
+
* A missing corpus file is a LOUD failure (see test_helper_vectors.php's
|
|
18
|
+
* docstring for why) -- not a silent skip.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
require_once __DIR__ . '/_harness.php';
|
|
22
|
+
bf_require_runtime();
|
|
23
|
+
bf_reset();
|
|
24
|
+
|
|
25
|
+
use Barefoot\Evaluator;
|
|
26
|
+
|
|
27
|
+
$VECTORS_PATH = __DIR__ . '/../../adapter-tests/vectors/eval-vectors.json';
|
|
28
|
+
|
|
29
|
+
if (!is_file($VECTORS_PATH)) {
|
|
30
|
+
bf_test('golden eval-vectors corpus is present', function () use ($VECTORS_PATH) {
|
|
31
|
+
bf_assert(false, "eval-vectors.json not found at {$VECTORS_PATH} -- regenerate it (cd packages/adapter-tests && bun run generate:eval-vectors) or check this path after a corpus move");
|
|
32
|
+
});
|
|
33
|
+
return bf_finish();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Spec value-compat comparison -- non-finite sentinel hashes, booleans by
|
|
38
|
+
* TYPE (a boolean-valued JS operator must return a real PHP bool, not a
|
|
39
|
+
* truthy int), numbers numerically, arrays/objects recursively, strings by
|
|
40
|
+
* equality.
|
|
41
|
+
*/
|
|
42
|
+
function bfe_match($got, $expect): bool
|
|
43
|
+
{
|
|
44
|
+
if ($expect === null) {
|
|
45
|
+
return $got === null;
|
|
46
|
+
}
|
|
47
|
+
if (is_array($expect) && array_key_exists('$num', $expect) && count($expect) === 1) {
|
|
48
|
+
$kind = $expect['$num'];
|
|
49
|
+
if (is_bool($got) || !(is_int($got) || is_float($got))) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
$g = (float) $got;
|
|
53
|
+
if ($kind === 'NaN') {
|
|
54
|
+
return is_nan($g);
|
|
55
|
+
}
|
|
56
|
+
return $g === ($kind === 'Infinity' ? INF : -INF);
|
|
57
|
+
}
|
|
58
|
+
if (is_bool($expect)) {
|
|
59
|
+
return is_bool($got) && $got === $expect;
|
|
60
|
+
}
|
|
61
|
+
if (is_array($expect) && array_is_list($expect)) {
|
|
62
|
+
if (!is_array($got) || !array_is_list($got) || count($got) !== count($expect)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
foreach ($expect as $i => $e) {
|
|
66
|
+
if (!bfe_match($got[$i] ?? null, $e)) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (is_array($expect)) { // JSON object
|
|
73
|
+
$gotArr = $got instanceof \stdClass ? get_object_vars($got) : (is_array($got) ? $got : null);
|
|
74
|
+
if ($gotArr === null || count($gotArr) !== count($expect)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
foreach ($expect as $k => $v) {
|
|
78
|
+
if (!array_key_exists($k, $gotArr) || !bfe_match($gotArr[$k], $v)) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if ($got === null || is_array($got) || $got instanceof \stdClass) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
// Numeric comparison only when BOTH are real numbers (not a
|
|
88
|
+
// numeric-looking string) -- e.g. String(42) must return the string
|
|
89
|
+
// "42", and evaluating it as the number 42 must NOT pass.
|
|
90
|
+
$wantNum = is_int($expect) || is_float($expect);
|
|
91
|
+
$gotNum = is_int($got) || is_float($got);
|
|
92
|
+
if ($wantNum !== $gotNum) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if ($wantNum) {
|
|
96
|
+
if (is_int($got) && is_int($expect)) {
|
|
97
|
+
return $got === $expect;
|
|
98
|
+
}
|
|
99
|
+
return (float) $got === (float) $expect;
|
|
100
|
+
}
|
|
101
|
+
return $got === $expect;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
$doc = json_decode(file_get_contents($VECTORS_PATH), true);
|
|
105
|
+
bf_assert(!empty($doc['cases']), 'eval-vectors.json contains no cases');
|
|
106
|
+
|
|
107
|
+
foreach ($doc['cases'] as $case) {
|
|
108
|
+
$note = $case['note'];
|
|
109
|
+
$expr = $case['expr'];
|
|
110
|
+
$env = $case['env'] ?? [];
|
|
111
|
+
$expect = $case['expect'];
|
|
112
|
+
|
|
113
|
+
bf_test($note, function () use ($expr, $env, $expect, $note) {
|
|
114
|
+
$got = Evaluator::evaluate($expr, $env);
|
|
115
|
+
bf_assert(bfe_match($got, $expect), "{$note}: got " . bf_fmt($got) . ', want ' . bf_fmt($expect));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return bf_finish();
|