@barefootjs/perl 0.12.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.
@@ -1,5 +1,5 @@
1
1
  package BarefootJS::DevReload;
2
- our $VERSION = "0.10.1";
2
+ our $VERSION = "0.13.0";
3
3
  use strict;
4
4
  use warnings;
5
5
  use feature 'signatures';
package/lib/BarefootJS.pm CHANGED
@@ -1,5 +1,5 @@
1
1
  package BarefootJS;
2
- our $VERSION = "0.10.1";
2
+ our $VERSION = "0.13.0";
3
3
  use strict;
4
4
  use warnings;
5
5
  use utf8;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/perl",
3
- "version": "0.12.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
+ }