@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/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();