@barefootjs/perl 0.13.0 → 0.14.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/lib/BarefootJS/DevReload.pm +1 -1
- package/lib/BarefootJS.pm +1 -1
- package/package.json +1 -1
- package/t/helper_vectors.t +360 -0
package/lib/BarefootJS.pm
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@barefootjs/perl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "BarefootJS engine-agnostic Perl runtime (BarefootJS.pm) — pluggable rendering backend; the shared basis for Mojolicious and other Perl framework integrations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
use strict;
|
|
2
|
+
use warnings;
|
|
3
|
+
use Test::More;
|
|
4
|
+
use FindBin;
|
|
5
|
+
use File::Spec;
|
|
6
|
+
use JSON::PP ();
|
|
7
|
+
use Scalar::Util qw(looks_like_number);
|
|
8
|
+
|
|
9
|
+
use lib "$FindBin::Bin/../lib";
|
|
10
|
+
use BarefootJS;
|
|
11
|
+
|
|
12
|
+
# Pure-Perl backend (core JSON::PP only) so this test runs with zero
|
|
13
|
+
# Mojo present — same pattern as t/template_primitives.t.
|
|
14
|
+
{
|
|
15
|
+
package PureBackend;
|
|
16
|
+
use JSON::PP ();
|
|
17
|
+
my $J = JSON::PP->new->canonical->allow_nonref;
|
|
18
|
+
sub new { bless {}, shift }
|
|
19
|
+
sub encode_json { $J->encode($_[1]) }
|
|
20
|
+
sub mark_raw { $_[1] }
|
|
21
|
+
sub materialize { ref($_[1]) eq 'CODE' ? $_[1]->() : $_[1] }
|
|
22
|
+
sub render_named { '' }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
my $bf = bless { c => undef, config => {}, backend => PureBackend->new }, 'BarefootJS';
|
|
26
|
+
|
|
27
|
+
# Golden helper vectors generated from the JS reference implementations
|
|
28
|
+
# (spec/template-helpers.md in the monorepo). The file is not shipped in
|
|
29
|
+
# the CPAN dist — packages/adapter-tests only exists in a monorepo
|
|
30
|
+
# checkout — so skip everywhere else.
|
|
31
|
+
my $vectors_path = File::Spec->catfile(
|
|
32
|
+
$FindBin::Bin, '..', '..', 'adapter-tests', 'helper-vectors', 'vectors.json'
|
|
33
|
+
);
|
|
34
|
+
plan skip_all => 'golden vectors not available outside the monorepo checkout'
|
|
35
|
+
unless -e $vectors_path;
|
|
36
|
+
|
|
37
|
+
my $doc = do {
|
|
38
|
+
open my $fh, '<:raw', $vectors_path or die "open $vectors_path: $!";
|
|
39
|
+
local $/;
|
|
40
|
+
JSON::PP->new->decode(<$fh>);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
# One binding per canonical helper id in the spec catalogue, bound to
|
|
44
|
+
# the exact code shape compiled templates execute on the Perl backends.
|
|
45
|
+
# Where the adapters lower an operation to a native Perl operator
|
|
46
|
+
# (mojo-adapter.ts maps JSX `+` straight to Perl `+`), the binding IS
|
|
47
|
+
# that operator rather than a BarefootJS.pm method. Per the spec, a
|
|
48
|
+
# vector with no binding here fails the test — the Perl backend must
|
|
49
|
+
# not silently fall behind the catalogue.
|
|
50
|
+
my %bindings = (
|
|
51
|
+
add => sub { $_[0] + $_[1] },
|
|
52
|
+
sub => sub { $_[0] - $_[1] },
|
|
53
|
+
mul => sub { $_[0] * $_[1] },
|
|
54
|
+
div => sub { $_[0] / $_[1] },
|
|
55
|
+
mod => sub { $_[0] % $_[1] },
|
|
56
|
+
neg => sub { -$_[0] },
|
|
57
|
+
|
|
58
|
+
string => sub { $bf->string($_[0]) },
|
|
59
|
+
json => sub { $bf->json($_[0]) },
|
|
60
|
+
number => sub { $bf->number($_[0]) },
|
|
61
|
+
floor => sub { $bf->floor($_[0]) },
|
|
62
|
+
ceil => sub { $bf->ceil($_[0]) },
|
|
63
|
+
round => sub { $bf->round($_[0]) },
|
|
64
|
+
|
|
65
|
+
# The Mojo renderer emits native lc()/uc(); Xslate emits $bf.lc /
|
|
66
|
+
# $bf.uc. The helper methods wrap CORE::lc/uc, so binding them
|
|
67
|
+
# covers both shapes at value level.
|
|
68
|
+
lower => sub { $bf->lc($_[0]) },
|
|
69
|
+
upper => sub { $bf->uc($_[0]) },
|
|
70
|
+
trim => sub { $bf->trim($_[0]) },
|
|
71
|
+
starts_with => sub { $bf->starts_with(@_) },
|
|
72
|
+
ends_with => sub { $bf->ends_with(@_) },
|
|
73
|
+
replace => sub { $bf->replace(@_) },
|
|
74
|
+
repeat => sub { $bf->repeat(@_) },
|
|
75
|
+
pad_start => sub { $bf->pad_start(@_) },
|
|
76
|
+
pad_end => sub { $bf->pad_end(@_) },
|
|
77
|
+
split => sub { $bf->split(@_) },
|
|
78
|
+
|
|
79
|
+
len => sub { $bf->length($_[0]) },
|
|
80
|
+
at => sub { $bf->at(@_) },
|
|
81
|
+
includes => sub { $bf->includes(@_) },
|
|
82
|
+
index_of => sub { $bf->index_of(@_) },
|
|
83
|
+
last_index_of => sub { $bf->last_index_of(@_) },
|
|
84
|
+
concat => sub { $bf->concat(@_) },
|
|
85
|
+
# The Mojo emit always passes three value args (`undef` for an
|
|
86
|
+
# absent end) — mirror that exact shape.
|
|
87
|
+
slice => sub { $bf->slice($_[0], $_[1], $_[2]) },
|
|
88
|
+
reverse => sub { $bf->reverse($_[0]) },
|
|
89
|
+
flat => sub { $bf->flat(@_) },
|
|
90
|
+
join => sub { $bf->join(@_) },
|
|
91
|
+
# Array literals are native arrayrefs on the Perl backends.
|
|
92
|
+
arr => sub { [@_] },
|
|
93
|
+
# Mirrors the Mojo inline `[grep { $_ } @{...}]` for filter(Boolean).
|
|
94
|
+
filter_truthy => sub { [grep { $_ } @{ $_[0] }] },
|
|
95
|
+
|
|
96
|
+
# Higher-order entries arrive in the canonical projection form
|
|
97
|
+
# (spec: items + field [+ value]); the closures below rebuild the
|
|
98
|
+
# predicate the adapters compile (`i => i.field === value`,
|
|
99
|
+
# `i => i.field`), choosing eq vs == by the probe's string-typing
|
|
100
|
+
# the same way the Mojo emitter does.
|
|
101
|
+
every => sub { $bf->every($_[0], _truthy_pred($_[1])) },
|
|
102
|
+
some => sub { $bf->some($_[0], _truthy_pred($_[1])) },
|
|
103
|
+
filter => sub { $bf->filter($_[0], _field_eq_pred($_[1], $_[2])) },
|
|
104
|
+
find => sub { $bf->find($_[0], _field_eq_pred($_[1], $_[2])) },
|
|
105
|
+
find_index => sub { $bf->find_index($_[0], _field_eq_pred($_[1], $_[2])) },
|
|
106
|
+
find_last => sub { $bf->find_last($_[0], _field_eq_pred($_[1], $_[2])) },
|
|
107
|
+
find_last_index => sub { $bf->find_last_index($_[0], _field_eq_pred($_[1], $_[2])) },
|
|
108
|
+
|
|
109
|
+
sort => sub {
|
|
110
|
+
my ($recv, @spec) = @_;
|
|
111
|
+
my @keys;
|
|
112
|
+
while (@spec >= 4) {
|
|
113
|
+
my ($kind, $name, $ct, $dir) = splice(@spec, 0, 4);
|
|
114
|
+
push @keys, {
|
|
115
|
+
key_kind => $kind,
|
|
116
|
+
key => $name,
|
|
117
|
+
compare_type => $ct,
|
|
118
|
+
direction => $dir,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return $bf->sort($recv, { keys => \@keys });
|
|
122
|
+
},
|
|
123
|
+
reduce => sub {
|
|
124
|
+
my ($recv, $op, $key_kind, $key, $type, $init, $direction) = @_;
|
|
125
|
+
return $bf->reduce($recv, {
|
|
126
|
+
op => $op,
|
|
127
|
+
key_kind => $key_kind,
|
|
128
|
+
key => $key,
|
|
129
|
+
type => $type,
|
|
130
|
+
init => $init,
|
|
131
|
+
direction => $direction,
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
flat_map => sub { $bf->flat_map(@_) },
|
|
135
|
+
flat_map_tuple => sub {
|
|
136
|
+
my ($recv, @flat) = @_;
|
|
137
|
+
my @specs;
|
|
138
|
+
while (@flat >= 2) {
|
|
139
|
+
my ($kind, $name) = splice(@flat, 0, 2);
|
|
140
|
+
push @specs, [$kind, $name];
|
|
141
|
+
}
|
|
142
|
+
return $bf->flat_map_tuple($recv, @specs);
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
sub _truthy_pred {
|
|
147
|
+
my ($field) = @_;
|
|
148
|
+
return sub { ref $_[0] eq 'HASH' ? $_[0]{$field} : undef };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
sub _field_eq_pred {
|
|
152
|
+
my ($field, $value) = @_;
|
|
153
|
+
my $get = sub { ref $_[0] eq 'HASH' ? $_[0]{$field} : undef };
|
|
154
|
+
return looks_like_number($value)
|
|
155
|
+
? sub { my $v = $get->($_[0]); defined $v && $v == $value }
|
|
156
|
+
: sub { my $v = $get->($_[0]); defined $v && $v eq $value };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# Per-backend status declarations (spec/template-helpers.md "Adapter
|
|
160
|
+
# status model"). This file is the single source of truth for the
|
|
161
|
+
# Perl backends' divergences — the spec stays backend-neutral.
|
|
162
|
+
#
|
|
163
|
+
# %DIVERGENCES pins a deliberate divergence from the JS-normative
|
|
164
|
+
# expect, keyed by `fn/note`. Forms:
|
|
165
|
+
# { expect => <value>, reason => ... } assert the pinned value
|
|
166
|
+
# { nan => 1, reason => ... } assert a real NaN result
|
|
167
|
+
# { dies => 1, reason => ... } assert the call dies
|
|
168
|
+
# A pinned case that starts matching JS fails as stale; a key that
|
|
169
|
+
# matches no vector case fails as dead.
|
|
170
|
+
my %DIVERGENCES = (
|
|
171
|
+
'add/beyond the safe-integer edge rounds as a double' => {
|
|
172
|
+
expect => 9007199254740993,
|
|
173
|
+
reason => 'Perl IV arithmetic is 64-bit exact, not double-rounded',
|
|
174
|
+
},
|
|
175
|
+
'div/zero divisor yields Infinity' => {
|
|
176
|
+
dies => 1,
|
|
177
|
+
reason => 'Perl native / dies on a zero divisor',
|
|
178
|
+
},
|
|
179
|
+
'mod/remainder keeps the dividend sign' => {
|
|
180
|
+
expect => 2,
|
|
181
|
+
reason => 'Perl native % takes the divisor sign',
|
|
182
|
+
},
|
|
183
|
+
'mod/float remainder' => {
|
|
184
|
+
expect => 1,
|
|
185
|
+
reason => 'Perl native % truncates operands to integers',
|
|
186
|
+
},
|
|
187
|
+
'number/empty string coerces to 0' => {
|
|
188
|
+
nan => 1,
|
|
189
|
+
reason => 'deliberate: empty input must not silently zero downstream arithmetic',
|
|
190
|
+
},
|
|
191
|
+
'number/null coerces to 0' => {
|
|
192
|
+
nan => 1,
|
|
193
|
+
reason => 'deliberate: unset props must not silently zero downstream arithmetic',
|
|
194
|
+
},
|
|
195
|
+
'string/null renders as the string "null"' => {
|
|
196
|
+
expect => '',
|
|
197
|
+
reason => 'deliberate: an unset prop must not surface a literal "null" in HTML',
|
|
198
|
+
},
|
|
199
|
+
'string/true renders as the string "true"' => {
|
|
200
|
+
expect => '1',
|
|
201
|
+
reason => 'Perl has no boolean type; template data carries 1/0',
|
|
202
|
+
},
|
|
203
|
+
'string/17-significant-digit double round-trips' => {
|
|
204
|
+
expect => '0.3',
|
|
205
|
+
reason => 'Perl stringifies doubles via %.15g',
|
|
206
|
+
},
|
|
207
|
+
'includes/cross-type probe is strict-equality false' => {
|
|
208
|
+
expect => 1,
|
|
209
|
+
reason => 'bf->includes scans with eq (string equality), so 2 eq "2" matches',
|
|
210
|
+
},
|
|
211
|
+
'filter_truthy/the string "0" is truthy' => {
|
|
212
|
+
expect => ['x'],
|
|
213
|
+
reason => 'Perl truthiness treats the string "0" as false',
|
|
214
|
+
},
|
|
215
|
+
'sort/localeCompare orders case-insensitively (ICU collation)' => {
|
|
216
|
+
expect => ['B', 'a'],
|
|
217
|
+
reason => 'cmp is byte order, not ICU collation',
|
|
218
|
+
},
|
|
219
|
+
'sort/relational compare on numeric strings is lexical' => {
|
|
220
|
+
expect => ['9', '10'],
|
|
221
|
+
reason => 'the "auto" compare goes numeric when both keys look_like_number',
|
|
222
|
+
},
|
|
223
|
+
'reduce/numeric-string items concatenate under JS +' => {
|
|
224
|
+
expect => 11,
|
|
225
|
+
reason => 'numeric folds parse numeric strings instead of concatenating',
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
# Helper ids not implemented on this backend yet — skipped visibly.
|
|
230
|
+
# Empty for the Perl backends; the mechanism exists so a bootstrapping
|
|
231
|
+
# backend can land its harness first and burn the list down.
|
|
232
|
+
my %UNSUPPORTED = ();
|
|
233
|
+
|
|
234
|
+
my %seen_declarations;
|
|
235
|
+
for my $case (@{ $doc->{cases} }) {
|
|
236
|
+
my ($fn, $note) = @{$case}{qw(fn note)};
|
|
237
|
+
my $key = "$fn/$note";
|
|
238
|
+
if (my $why = $UNSUPPORTED{$fn}) {
|
|
239
|
+
SKIP: { skip "unsupported on this backend: $why", 1 }
|
|
240
|
+
next;
|
|
241
|
+
}
|
|
242
|
+
my $bind = $bindings{$fn};
|
|
243
|
+
if (!$bind) {
|
|
244
|
+
fail("no Perl binding for helper '$fn' — add it to %bindings in $0");
|
|
245
|
+
next;
|
|
246
|
+
}
|
|
247
|
+
my @args = map { normalize_arg($_) } @{ $case->{args} };
|
|
248
|
+
my $got = eval { $bind->(@args) };
|
|
249
|
+
my $err = $@;
|
|
250
|
+
|
|
251
|
+
if (my $d = $DIVERGENCES{$key}) {
|
|
252
|
+
$seen_declarations{$key} = 1;
|
|
253
|
+
my $label = "$key (declared divergence: $d->{reason})";
|
|
254
|
+
if ($d->{dies}) {
|
|
255
|
+
ok($err, $label) or diag("expected the call to die, got: " . explain_value($got));
|
|
256
|
+
next;
|
|
257
|
+
}
|
|
258
|
+
if ($err) {
|
|
259
|
+
fail($label);
|
|
260
|
+
diag("died unexpectedly: $err");
|
|
261
|
+
next;
|
|
262
|
+
}
|
|
263
|
+
if (_match($got, $case->{expect})) {
|
|
264
|
+
fail("stale divergence declaration for '$key' — the backend now matches JS; remove it");
|
|
265
|
+
next;
|
|
266
|
+
}
|
|
267
|
+
my $want_ok = $d->{nan} ? (looks_like_number($got // '') && $got != $got)
|
|
268
|
+
: _match_plain($got, $d->{expect});
|
|
269
|
+
ok($want_ok, $label)
|
|
270
|
+
or diag('got ' . explain_value($got) . ', pinned ' . explain_value($d->{nan} ? 'NaN' : $d->{expect}));
|
|
271
|
+
next;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if ($err) {
|
|
275
|
+
fail("$key died: $err");
|
|
276
|
+
next;
|
|
277
|
+
}
|
|
278
|
+
ok(_match($got, $case->{expect}), "$key")
|
|
279
|
+
or diag('got ' . explain_value($got) . ', want ' . explain_value($case->{expect}));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for my $key (keys %DIVERGENCES) {
|
|
283
|
+
fail("divergence declaration '$key' matches no vector case — renamed note?")
|
|
284
|
+
unless $seen_declarations{$key};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
done_testing;
|
|
288
|
+
|
|
289
|
+
# Spec value-compat contract: numbers compare numerically (JSON::PP
|
|
290
|
+
# decodes vector numbers to IV/NV, `==` compares the values), booleans
|
|
291
|
+
# by truthiness, everything else structurally. Arrays currently go
|
|
292
|
+
# through is_deeply (string compare per element) — refine to a
|
|
293
|
+
# recursive numeric walk when the first float-array vector lands.
|
|
294
|
+
# Production Perl template data has no boolean type — the adapters pass
|
|
295
|
+
# 1/0 where JS has true/false — so JSON::PP boolean objects in vector
|
|
296
|
+
# ARGS are lowered to 1/0 before reaching a binding. Expects keep their
|
|
297
|
+
# boolean identity (vector_ok compares those by truthiness).
|
|
298
|
+
sub normalize_arg {
|
|
299
|
+
my ($v) = @_;
|
|
300
|
+
return [ map { normalize_arg($_) } @$v ] if ref $v eq 'ARRAY';
|
|
301
|
+
return { map { $_ => normalize_arg($v->{$_}) } keys %$v } if ref $v eq 'HASH';
|
|
302
|
+
return JSON::PP::is_bool($v) ? ($v ? 1 : 0) : $v;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
# _match: boolean form of the spec's value-compat comparison against a
|
|
306
|
+
# JSON-decoded expect — sentinel hashes, booleans by truthiness,
|
|
307
|
+
# numbers numerically, arrays/hashes recursively.
|
|
308
|
+
sub _match {
|
|
309
|
+
my ($got, $expect) = @_;
|
|
310
|
+
return !defined $got if !defined $expect;
|
|
311
|
+
if (ref $expect eq 'HASH' && exists $expect->{'$num'}) {
|
|
312
|
+
my $kind = $expect->{'$num'};
|
|
313
|
+
return 0 unless defined $got && looks_like_number($got);
|
|
314
|
+
return $got != $got ? 1 : 0 if $kind eq 'NaN';
|
|
315
|
+
my $inf = 9**9**9;
|
|
316
|
+
return $got == ($kind eq 'Infinity' ? $inf : -$inf) ? 1 : 0;
|
|
317
|
+
}
|
|
318
|
+
if (JSON::PP::is_bool($expect)) {
|
|
319
|
+
return (!!$got eq !!$expect) ? 1 : 0;
|
|
320
|
+
}
|
|
321
|
+
if (ref $expect eq 'ARRAY') {
|
|
322
|
+
return 0 unless ref $got eq 'ARRAY' && @$got == @$expect;
|
|
323
|
+
_match($got->[$_], $expect->[$_]) or return 0 for 0 .. $#$expect;
|
|
324
|
+
return 1;
|
|
325
|
+
}
|
|
326
|
+
if (ref $expect eq 'HASH') {
|
|
327
|
+
return 0 unless ref $got eq 'HASH' && keys %$got == keys %$expect;
|
|
328
|
+
for my $k (keys %$expect) {
|
|
329
|
+
return 0 unless exists $got->{$k};
|
|
330
|
+
_match($got->{$k}, $expect->{$k}) or return 0;
|
|
331
|
+
}
|
|
332
|
+
return 1;
|
|
333
|
+
}
|
|
334
|
+
return 0 if !defined $got || ref $got;
|
|
335
|
+
return ($got == $expect ? 1 : 0) if looks_like_number($expect) && looks_like_number($got);
|
|
336
|
+
return ($got eq $expect) ? 1 : 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
# _match_plain: like _match but against a plain Perl value from a
|
|
340
|
+
# divergence declaration (no JSON boolean / sentinel forms).
|
|
341
|
+
sub _match_plain {
|
|
342
|
+
my ($got, $expect) = @_;
|
|
343
|
+
return !defined $got if !defined $expect;
|
|
344
|
+
if (ref $expect eq 'ARRAY') {
|
|
345
|
+
return 0 unless ref $got eq 'ARRAY' && @$got == @$expect;
|
|
346
|
+
_match_plain($got->[$_], $expect->[$_]) or return 0 for 0 .. $#$expect;
|
|
347
|
+
return 1;
|
|
348
|
+
}
|
|
349
|
+
return 0 if !defined $got || ref $got;
|
|
350
|
+
return ($got == $expect ? 1 : 0) if looks_like_number($expect) && looks_like_number($got);
|
|
351
|
+
return ($got eq $expect) ? 1 : 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
sub explain_value {
|
|
355
|
+
my ($v) = @_;
|
|
356
|
+
return 'undef' unless defined $v;
|
|
357
|
+
return JSON::PP->new->canonical->allow_nonref->allow_blessed->convert_blessed->encode($v)
|
|
358
|
+
if ref $v;
|
|
359
|
+
return "'$v'";
|
|
360
|
+
}
|