@barefootjs/perl 0.16.0 → 0.17.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.15.2";
2
+ our $VERSION = "0.16.0";
3
3
  use strict;
4
4
  use warnings;
5
5
  use feature 'signatures';
@@ -0,0 +1,578 @@
1
+ package BarefootJS::Evaluator;
2
+ our $VERSION = "0.14.0";
3
+ use strict;
4
+ use warnings;
5
+ use utf8;
6
+ use feature 'signatures';
7
+ no warnings 'experimental::signatures';
8
+
9
+ use B ();
10
+ use POSIX ();
11
+ use JSON::PP ();
12
+ use Scalar::Util qw(looks_like_number);
13
+
14
+ # Lightweight evaluator for the pure `ParsedExpr` subset, scoped to
15
+ # higher-order callback bodies (reduce / sort / map / filter / find
16
+ # `(…) => expr`) — issue #2018. Templates cannot carry a lambda in
17
+ # expression position, which is why the adapters historically special-cased
18
+ # these callbacks into fixed shapes (bf_sort's comparator catalogue,
19
+ # bf_reduce's +/* fold). Instead, the callback BODY rides as a pure
20
+ # `ParsedExpr` subtree (the structured IR the compiler already produces) and
21
+ # is evaluated here against an environment (`{acc, item, …captured free
22
+ # vars}`).
23
+ #
24
+ # ONE shared implementation for both Perl backends (Mojo + Xslate), living
25
+ # alongside SearchParams.pm in the engine-agnostic core (no CPAN deps beyond
26
+ # core B / POSIX / Scalar::Util). The accepted subset and its semantics are
27
+ # documented in spec/compiler.md ("ParsedExpr Evaluator Semantics") and pinned
28
+ # isomorphically by the cross-language golden vectors
29
+ # (packages/adapter-tests/helper-vectors/eval-vectors.json), shared with the Go
30
+ # evaluator (bf.go) — same input → same output.
31
+ #
32
+ # The coercion below is JS-faithful (ToNumber / ToString / ToBoolean, strict
33
+ # equality) and deliberately distinct from the divergent bf->string / number
34
+ # helpers in BarefootJS.pm, so the contract is unambiguous and the two
35
+ # template adapters stay byte-equal with each other and with Go.
36
+
37
+ # evaluate($node, $env)
38
+ #
39
+ # Evaluate a decoded ParsedExpr node (a hashref keyed by `kind`) against the
40
+ # environment hashref ($env), returning a Perl value (number, string,
41
+ # JSON::PP::Boolean, undef for null, arrayref, hashref). The matching JSON
42
+ # entry point is eval_json() below.
43
+ sub evaluate ($node, $env) {
44
+ return undef unless ref $node eq 'HASH';
45
+ my $kind = $node->{kind} // '';
46
+
47
+ if ($kind eq 'literal') {
48
+ return $node->{value};
49
+ }
50
+ if ($kind eq 'identifier') {
51
+ return $env->{ $node->{name} };
52
+ }
53
+ if ($kind eq 'binary') {
54
+ return _binary($node->{op},
55
+ evaluate($node->{left}, $env), evaluate($node->{right}, $env));
56
+ }
57
+ if ($kind eq 'unary') {
58
+ return _unary($node->{op}, evaluate($node->{argument}, $env));
59
+ }
60
+ if ($kind eq 'logical') {
61
+ my $op = $node->{op};
62
+ my $left = evaluate($node->{left}, $env);
63
+ if ($op eq '&&') {
64
+ return _truthy($left) ? evaluate($node->{right}, $env) : $left;
65
+ }
66
+ if ($op eq '||') {
67
+ return _truthy($left) ? $left : evaluate($node->{right}, $env);
68
+ }
69
+ # `??`
70
+ return defined $left ? $left : evaluate($node->{right}, $env);
71
+ }
72
+ if ($kind eq 'conditional') {
73
+ return _truthy(evaluate($node->{test}, $env))
74
+ ? evaluate($node->{consequent}, $env)
75
+ : evaluate($node->{alternate}, $env);
76
+ }
77
+ if ($kind eq 'member') {
78
+ return _read_property(evaluate($node->{object}, $env), $node->{property});
79
+ }
80
+ if ($kind eq 'index-access') {
81
+ return _read_index(evaluate($node->{object}, $env), evaluate($node->{index}, $env));
82
+ }
83
+ if ($kind eq 'call') {
84
+ my $name = _builtin_name($node->{callee});
85
+ return undef unless defined $name && $name ne '';
86
+ my @args = map { evaluate($_, $env) } @{ $node->{args} // [] };
87
+ return _call_builtin($name, \@args);
88
+ }
89
+ if ($kind eq 'template-literal') {
90
+ my $out = '';
91
+ for my $p (@{ $node->{parts} // [] }) {
92
+ if (($p->{type} // '') eq 'string') {
93
+ $out .= $p->{value} // '';
94
+ }
95
+ else {
96
+ $out .= _to_string(evaluate($p->{expr}, $env));
97
+ }
98
+ }
99
+ return $out;
100
+ }
101
+ if ($kind eq 'array-literal') {
102
+ return [ map { evaluate($_, $env) } @{ $node->{elements} // [] } ];
103
+ }
104
+ if ($kind eq 'object-literal') {
105
+ my %out;
106
+ for my $prop (@{ $node->{properties} // [] }) {
107
+ $out{ $prop->{key} } = evaluate($prop->{value}, $env);
108
+ }
109
+ return \%out;
110
+ }
111
+
112
+ # arrow-fn / higher-order / array-method / unsupported: a callback body
113
+ # containing these is refused upstream (BF101); never reached here.
114
+ return undef;
115
+ }
116
+
117
+ # eval_json($json, $env): decode a ParsedExpr JSON string and evaluate it.
118
+ # Mirrors the Go EvalExpr entry point. Requires JSON::PP only when used.
119
+ sub eval_json ($json, $env) {
120
+ require JSON::PP;
121
+ my $node = JSON::PP->new->decode($json);
122
+ return evaluate($node, $env);
123
+ }
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # JS value classification. JSON-decoded strings carry the string flag (POK)
127
+ # but no numeric flag; JSON-decoded numbers carry IOK/NOK. This lets the
128
+ # evaluator tell the JS *string* "10" from the JS *number* 10 — essential for
129
+ # the `+` overload and relational comparison — which looks_like_number alone
130
+ # cannot (it is true for both).
131
+ # ---------------------------------------------------------------------------
132
+
133
+ sub _is_string ($v) {
134
+ return 0 if !defined $v || ref $v;
135
+ my $f = B::svref_2object(\$v)->FLAGS;
136
+ return (($f & B::SVf_POK) && !($f & (B::SVf_IOK | B::SVf_NOK))) ? 1 : 0;
137
+ }
138
+
139
+ sub _is_number ($v) {
140
+ return (defined $v && !ref $v && !_is_string($v)) ? 1 : 0;
141
+ }
142
+
143
+ sub _nan {
144
+ my $inf = 9**9**9;
145
+ return $inf - $inf;
146
+ }
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # JS coercion primitives (ToNumber / ToString / ToBoolean).
150
+ # ---------------------------------------------------------------------------
151
+
152
+ sub _to_number ($v) {
153
+ return 0 if !defined $v;
154
+ if (ref $v eq 'JSON::PP::Boolean') { return $v ? 1 : 0 }
155
+ return _nan() if ref $v;
156
+ if (_is_string($v)) {
157
+ my $t = $v;
158
+ $t =~ s/\A\s+//;
159
+ $t =~ s/\s+\z//;
160
+ return 0 if $t eq '';
161
+ return looks_like_number($t) ? ($t + 0) : _nan();
162
+ }
163
+ return $v + 0;
164
+ }
165
+
166
+ sub _to_string ($v) {
167
+ return 'null' if !defined $v;
168
+ if (ref $v eq 'JSON::PP::Boolean') { return $v ? 'true' : 'false' }
169
+ # JS spells the non-finite doubles "Infinity" / "-Infinity" / "NaN";
170
+ # Perl stringifies them "Inf" / "-Inf" / "NaN", so the non-finite cases
171
+ # are pinned here to stay JS-faithful (and match the Go evaluator's
172
+ # evalToString). Finite numbers and strings fall through to plain
173
+ # interpolation.
174
+ if (_is_number($v)) {
175
+ my $n = $v + 0;
176
+ return 'NaN' if $n != $n;
177
+ return 'Infinity' if $n == 9**9**9;
178
+ return '-Infinity' if $n == -(9**9**9);
179
+ }
180
+ return "$v";
181
+ }
182
+
183
+ sub _truthy ($v) {
184
+ return 0 if !defined $v;
185
+ if (ref $v eq 'JSON::PP::Boolean') { return $v ? 1 : 0 }
186
+ return 1 if ref $v; # arrays / objects are always truthy in JS
187
+ if (_is_string($v)) { return $v ne '' ? 1 : 0 } # incl. the truthy "0"
188
+ my $n = $v + 0;
189
+ return ($n != 0 && $n == $n) ? 1 : 0; # nonzero and not NaN
190
+ }
191
+
192
+ # _bool: wrap a Perl truthy/falsy into a JS boolean (JSON::PP::Boolean), so
193
+ # boolean-valued operators (relational, ===, !, Boolean()) return a real
194
+ # boolean rather than 1/0 — matching the Go evaluator's bool (e.g.
195
+ # String(a < b) is "true", and `'x' + (a < b)` is "xtrue"). The coercions
196
+ # above already treat JSON::PP::Boolean correctly.
197
+ sub _bool ($t) { $t ? JSON::PP::true() : JSON::PP::false() }
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Operators
201
+ # ---------------------------------------------------------------------------
202
+
203
+ sub _binary ($op, $l, $r) {
204
+ if ($op eq '+') {
205
+ # JS `+`: string concatenation once either operand is a string,
206
+ # numeric addition otherwise.
207
+ return _to_string($l) . _to_string($r) if _is_string($l) || _is_string($r);
208
+ return _to_number($l) + _to_number($r);
209
+ }
210
+ return _to_number($l) - _to_number($r) if $op eq '-';
211
+ return _to_number($l) * _to_number($r) if $op eq '*';
212
+ if ($op eq '/') {
213
+ my $ln = _to_number($l);
214
+ my $rn = _to_number($r);
215
+ # JS division by zero is finite-valued, not an error: x/0 is ±Infinity
216
+ # (NaN for 0/0). Perl's native `/` dies on a zero divisor, so guard it
217
+ # to stay JS-faithful and match the Go evaluator (Go float division
218
+ # already yields ±Inf / NaN).
219
+ if ($rn == 0) {
220
+ return _nan() if $ln == 0 || $ln != $ln;
221
+ return $ln > 0 ? 9**9**9 : -(9**9**9);
222
+ }
223
+ return $ln / $rn;
224
+ }
225
+ if ($op eq '%') {
226
+ my $rn = _to_number($r);
227
+ return _nan() if $rn == 0;
228
+ return POSIX::fmod(_to_number($l), $rn);
229
+ }
230
+ return _relational($op, $l, $r) if $op eq '<' || $op eq '<=' || $op eq '>' || $op eq '>=';
231
+ return _bool(_strict_eq($l, $r)) if $op eq '===';
232
+ return _bool(!_strict_eq($l, $r)) if $op eq '!==';
233
+ # Loose equality / bitwise / shift are out of the subset.
234
+ return undef;
235
+ }
236
+
237
+ sub _relational ($op, $l, $r) {
238
+ # JS Abstract Relational Comparison: both strings → compare by code unit;
239
+ # otherwise coerce both to numbers (a NaN operand makes it false).
240
+ my $c;
241
+ if (_is_string($l) && _is_string($r)) {
242
+ $c = $l lt $r ? -1 : $l gt $r ? 1 : 0;
243
+ }
244
+ else {
245
+ my $ln = _to_number($l);
246
+ my $rn = _to_number($r);
247
+ return _bool(0) if $ln != $ln || $rn != $rn; # NaN → false
248
+ $c = $ln < $rn ? -1 : $ln > $rn ? 1 : 0;
249
+ }
250
+ return _bool($c < 0) if $op eq '<';
251
+ return _bool($c <= 0) if $op eq '<=';
252
+ return _bool($c > 0) if $op eq '>';
253
+ return _bool($c >= 0) if $op eq '>=';
254
+ return _bool(0);
255
+ }
256
+
257
+ sub _strict_eq ($l, $r) {
258
+ # Strict `===`: equal JS type and value, no coercion.
259
+ my $ln = _is_number($l);
260
+ my $rn = _is_number($r);
261
+ if ($ln && $rn) {
262
+ my ($lf, $rf) = ($l + 0, $r + 0);
263
+ return 0 if $lf != $lf || $rf != $rf; # NaN
264
+ return $lf == $rf ? 1 : 0;
265
+ }
266
+ return 0 if $ln != $rn; # one numeric, one not
267
+ if (!defined $l) { return !defined $r ? 1 : 0 }
268
+ return 0 if !defined $r;
269
+ my $lb = ref $l eq 'JSON::PP::Boolean';
270
+ my $rb = ref $r eq 'JSON::PP::Boolean';
271
+ if ($lb || $rb) {
272
+ return 0 unless $lb && $rb;
273
+ return ((!!$l) == (!!$r)) ? 1 : 0;
274
+ }
275
+ return ($l eq $r ? 1 : 0) if _is_string($l) && _is_string($r);
276
+ return 0;
277
+ }
278
+
279
+ sub _unary ($op, $v) {
280
+ return _bool(!_truthy($v)) if $op eq '!';
281
+ return -_to_number($v) if $op eq '-';
282
+ return _to_number($v) if $op eq '+';
283
+ return undef;
284
+ }
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Built-in calls (the deterministic allowlist). Locale-sensitive builtins
288
+ # (localeCompare) are deliberately excluded to keep the backends isomorphic.
289
+ # ---------------------------------------------------------------------------
290
+
291
+ # _builtin_name: resolve a `call` callee to its builtin name (e.g.
292
+ # "Math.max"), or '' when the callee is not an allowlisted builtin reference.
293
+ sub _builtin_name ($callee) {
294
+ return '' unless ref $callee eq 'HASH';
295
+ my $kind = $callee->{kind} // '';
296
+ if ($kind eq 'identifier') {
297
+ return $callee->{name} // '';
298
+ }
299
+ if ($kind eq 'member' && !$callee->{computed}) {
300
+ my $obj = $callee->{object};
301
+ return '' unless ref $obj eq 'HASH' && ($obj->{kind} // '') eq 'identifier';
302
+ return ($obj->{name} // '') . '.' . ($callee->{property} // '');
303
+ }
304
+ return '';
305
+ }
306
+
307
+ # _math_round: half rounds toward +Infinity (JS Math.round: 2.5→3, -2.5→-2),
308
+ # matching the existing round helper rather than half-away-from-zero.
309
+ sub _math_round ($n) {
310
+ return POSIX::floor($n + 0.5);
311
+ }
312
+
313
+ sub _call_builtin ($name, $args) {
314
+ if ($name eq 'Math.max') {
315
+ my $m = -(9**9**9); # JS Math.max() with no args is -Infinity
316
+ for my $a (@$args) {
317
+ my $n = _to_number($a);
318
+ return $n if $n != $n; # any NaN argument ⇒ NaN (JS / Go)
319
+ $m = $n if $n > $m;
320
+ }
321
+ return $m;
322
+ }
323
+ if ($name eq 'Math.min') {
324
+ my $m = 9**9**9; # JS Math.min() with no args is +Infinity
325
+ for my $a (@$args) {
326
+ my $n = _to_number($a);
327
+ return $n if $n != $n; # any NaN argument ⇒ NaN (JS / Go)
328
+ $m = $n if $n < $m;
329
+ }
330
+ return $m;
331
+ }
332
+ return abs(_to_number($args->[0])) if $name eq 'Math.abs';
333
+ return POSIX::floor(_to_number($args->[0])) if $name eq 'Math.floor';
334
+ return POSIX::ceil(_to_number($args->[0])) if $name eq 'Math.ceil';
335
+ return _math_round(_to_number($args->[0])) if $name eq 'Math.round';
336
+ return _to_string($args->[0]) if $name eq 'String';
337
+ return _to_number($args->[0]) if $name eq 'Number';
338
+ return _bool(_truthy($args->[0])) if $name eq 'Boolean';
339
+ # Any other callee is outside the subset (refused upstream).
340
+ return undef;
341
+ }
342
+
343
+ # ---------------------------------------------------------------------------
344
+ # Member / index access
345
+ # ---------------------------------------------------------------------------
346
+
347
+ sub _read_property ($obj, $key) {
348
+ return undef unless defined $obj;
349
+ if (ref $obj eq 'HASH') {
350
+ return exists $obj->{$key} ? $obj->{$key} : undef;
351
+ }
352
+ if (ref $obj eq 'ARRAY') {
353
+ return $key eq 'length' ? scalar(@$obj) : undef;
354
+ }
355
+ return undef if ref $obj;
356
+ # `.length` is a string property only — a numeric scalar (123) has no
357
+ # `.length` in the subset (JS `(123).length` is undefined → null), so
358
+ # guard on _is_string rather than coercing the number to a string.
359
+ # Matches the Go evaluator (numbers fall through to nil there).
360
+ return length($obj) if $key eq 'length' && _is_string($obj);
361
+ return undef;
362
+ }
363
+
364
+ sub _read_index ($obj, $index) {
365
+ if (ref $obj eq 'ARRAY') {
366
+ my $f = _to_number($index);
367
+ my $i = int($f);
368
+ return undef if $i != $f || $i < 0 || $i >= @$obj;
369
+ return $obj->[$i];
370
+ }
371
+ if (ref $obj eq 'HASH') {
372
+ return $obj->{ _to_string($index) };
373
+ }
374
+ return undef;
375
+ }
376
+
377
+ # ---------------------------------------------------------------------------
378
+ # Evaluator-driven higher-order folds (the generalization of bf_reduce /
379
+ # bf_sort onto the evaluator) — the runtime half both Perl backends share.
380
+ # ---------------------------------------------------------------------------
381
+
382
+ # fold($items, $body, $acc_name, $item_name, $init, $direction, $base_env)
383
+ #
384
+ # Fold an arrayref into a value via the evaluator. The reducer $body is a
385
+ # pure ParsedExpr node evaluated against `{$acc_name => acc, $item_name =>
386
+ # item}` plus the captured free vars in $base_env per element; $init seeds the
387
+ # accumulator and $direction is "left" (reduce) or "right" (reduceRight).
388
+ # Generalizes bf_reduce — any reducer body, not just the +/* arithmetic
389
+ # catalogue, and acc may appear anywhere. $base_env is optional; the
390
+ # acc/item keys shadow any same-named base key. Mirrors Go's FoldEval.
391
+ sub fold ($items, $body, $acc_name, $item_name, $init, $direction = 'left', $base_env = undef) {
392
+ my @arr = ref $items eq 'ARRAY' ? @$items : ();
393
+ @arr = reverse @arr if ($direction // '') eq 'right';
394
+ # Seed the env from the captured free vars once; acc / item are
395
+ # overwritten each iteration (constant base keys carry through).
396
+ my %env = $base_env ? %$base_env : ();
397
+ my $acc = $init;
398
+ for my $item (@arr) {
399
+ $env{$acc_name} = $acc;
400
+ $env{$item_name} = $item;
401
+ $acc = evaluate($body, \%env);
402
+ }
403
+ return $acc;
404
+ }
405
+
406
+ # sort_by($items, $cmp, $param_a, $param_b, $base_env)
407
+ #
408
+ # Return a new arrayref ordered by a ParsedExpr comparator $cmp evaluated
409
+ # against `{$param_a => a, $param_b => b}` plus the captured free vars in
410
+ # $base_env to a number (negative / zero / positive, like a JS comparator).
411
+ # Generalizes bf_sort — any comparator body. $base_env is optional. Stable
412
+ # and non-mutating. Mirrors Go's SortEval.
413
+ sub sort_by ($items, $cmp, $param_a, $param_b, $base_env = undef) {
414
+ # Non-array receiver → empty arrayref, matching the nil-tolerant
415
+ # BarefootJS->sort helper convention (and avoiding an undef-deref footgun
416
+ # for callers that use the result as an arrayref). Value-compatible with
417
+ # the Go SortEval, whose nil slice iterates as empty too.
418
+ return [] unless ref $items eq 'ARRAY';
419
+ my %env = $base_env ? %$base_env : ();
420
+ # Decorate each element with its original index and tie-break on it when
421
+ # the comparator returns 0, so stability is explicit and independent of
422
+ # the `sort` pragma / build (portable to the declared minimum Perl, and
423
+ # matching Go's sort.SliceStable).
424
+ my @decorated = map { [ $_, $items->[$_] ] } 0 .. $#$items;
425
+ my @sorted = sort {
426
+ $env{$param_a} = $a->[1];
427
+ $env{$param_b} = $b->[1];
428
+ my $c = _to_number(evaluate($cmp, \%env));
429
+ # Explicit sign test rather than `<=> 0`: a NaN comparator result
430
+ # warns / is undefined under `<=>`, whereas `< 0` / `> 0` are both
431
+ # false for NaN, yielding 0 (no reordering) — matching JS (NaN
432
+ # comparator ⇒ keep order) and the Go SortEval sign test. The
433
+ # original-index tie-break then preserves input order for equal keys.
434
+ ($c < 0 ? -1 : $c > 0 ? 1 : 0) || ($a->[0] <=> $b->[0])
435
+ } @decorated;
436
+ return [ map { $_->[1] } @sorted ];
437
+ }
438
+
439
+ # JSON entry points for the adapters: decode the callback body once, then fold /
440
+ # sort. Mirror the Go `bf_reduce_eval` / `bf_sort_eval` template funcs, which the
441
+ # adapters emit with a serialized-ParsedExpr body argument.
442
+ sub fold_json ($items, $body_json, $acc_name, $item_name, $init, $direction = 'left', $base_env = undef) {
443
+ require JSON::PP;
444
+ return fold($items, JSON::PP->new->decode($body_json), $acc_name, $item_name, $init, $direction, $base_env);
445
+ }
446
+
447
+ sub sort_by_json ($items, $cmp_json, $param_a, $param_b, $base_env = undef) {
448
+ require JSON::PP;
449
+ return sort_by($items, JSON::PP->new->decode($cmp_json), $param_a, $param_b, $base_env);
450
+ }
451
+
452
+ # ---------------------------------------------------------------------------
453
+ # Higher-order predicates (#2018, P2) — the generalization of bf_filter /
454
+ # bf_find / bf_find_index / bf_every / bf_some onto the evaluator. The
455
+ # predicate $pred is a pure ParsedExpr evaluated against `{$param => item}`
456
+ # plus the captured free vars in $base_env per element, lifting the
457
+ # field-equality / truthiness restriction to any pure predicate body. Each
458
+ # mirrors the corresponding Go helper (FilterEval / EveryEval / SomeEval /
459
+ # FindEval / FindIndexEval). $base_env is optional.
460
+ # ---------------------------------------------------------------------------
461
+
462
+ # filter — new arrayref of the elements the predicate keeps. Non-array receiver
463
+ # → empty arrayref (the BarefootJS->filter nil-tolerant convention).
464
+ sub filter ($items, $pred, $param, $base_env = undef) {
465
+ return [] unless ref $items eq 'ARRAY';
466
+ my %env = $base_env ? %$base_env : ();
467
+ my @out;
468
+ for my $item (@$items) {
469
+ $env{$param} = $item;
470
+ push @out, $item if _truthy(evaluate($pred, \%env));
471
+ }
472
+ return \@out;
473
+ }
474
+
475
+ # every — 1 iff the predicate holds for every element (vacuously 1 for an empty
476
+ # receiver, like JS).
477
+ sub every ($items, $pred, $param, $base_env = undef) {
478
+ my @arr = ref $items eq 'ARRAY' ? @$items : ();
479
+ my %env = $base_env ? %$base_env : ();
480
+ for my $item (@arr) {
481
+ $env{$param} = $item;
482
+ return 0 unless _truthy(evaluate($pred, \%env));
483
+ }
484
+ return 1;
485
+ }
486
+
487
+ # some — 1 iff the predicate holds for any element (0 for an empty receiver).
488
+ sub some ($items, $pred, $param, $base_env = undef) {
489
+ my @arr = ref $items eq 'ARRAY' ? @$items : ();
490
+ my %env = $base_env ? %$base_env : ();
491
+ for my $item (@arr) {
492
+ $env{$param} = $item;
493
+ return 1 if _truthy(evaluate($pred, \%env));
494
+ }
495
+ return 0;
496
+ }
497
+
498
+ # find — first matching element, or undef. $forward false searches from the end
499
+ # (findLast).
500
+ sub find ($items, $pred, $param, $forward = 1, $base_env = undef) {
501
+ my @arr = ref $items eq 'ARRAY' ? @$items : ();
502
+ @arr = reverse @arr unless $forward;
503
+ my %env = $base_env ? %$base_env : ();
504
+ for my $item (@arr) {
505
+ $env{$param} = $item;
506
+ return $item if _truthy(evaluate($pred, \%env));
507
+ }
508
+ return undef;
509
+ }
510
+
511
+ # find_index — index of the first matching element, or -1. $forward false →
512
+ # findLastIndex (the index is into the original array either way).
513
+ sub find_index ($items, $pred, $param, $forward = 1, $base_env = undef) {
514
+ my @arr = ref $items eq 'ARRAY' ? @$items : ();
515
+ my %env = $base_env ? %$base_env : ();
516
+ my @idx = $forward ? ( 0 .. $#arr ) : reverse( 0 .. $#arr );
517
+ for my $i (@idx) {
518
+ $env{$param} = $arr[$i];
519
+ return $i if _truthy(evaluate($pred, \%env));
520
+ }
521
+ return -1;
522
+ }
523
+
524
+ # flat_map — project each element through $proj (a pure ParsedExpr) and flatten
525
+ # the results one level. A projection yielding an arrayref contributes its
526
+ # elements; any other value contributes itself (JS `.flatMap` keeps a non-array
527
+ # return as a single element). Generalizes bf->flat_map / flat_map_tuple to any
528
+ # pure projection. Mirrors Go's FlatMapEval. $base_env is optional.
529
+ sub flat_map ($items, $proj, $param, $base_env = undef) {
530
+ my @arr = ref $items eq 'ARRAY' ? @$items : ();
531
+ my %env = $base_env ? %$base_env : ();
532
+ my @out;
533
+ for my $item (@arr) {
534
+ $env{$param} = $item;
535
+ my $v = evaluate($proj, \%env);
536
+ if (ref $v eq 'ARRAY') { push @out, @$v }
537
+ else { push @out, $v }
538
+ }
539
+ return \@out;
540
+ }
541
+
542
+ # ---------------------------------------------------------------------------
543
+ # JSON-string seams — the adapters emit `bf->filter_eval($recv, '<json>', …)`;
544
+ # the predicate body arrives as a JSON string here, decoded then handed to the
545
+ # helper above (mirroring fold_json / sort_by_json).
546
+ # ---------------------------------------------------------------------------
547
+
548
+ sub filter_json ($items, $pred_json, $param, $base_env = undef) {
549
+ require JSON::PP;
550
+ return filter($items, JSON::PP->new->decode($pred_json), $param, $base_env);
551
+ }
552
+
553
+ sub every_json ($items, $pred_json, $param, $base_env = undef) {
554
+ require JSON::PP;
555
+ return every($items, JSON::PP->new->decode($pred_json), $param, $base_env);
556
+ }
557
+
558
+ sub some_json ($items, $pred_json, $param, $base_env = undef) {
559
+ require JSON::PP;
560
+ return some($items, JSON::PP->new->decode($pred_json), $param, $base_env);
561
+ }
562
+
563
+ sub find_json ($items, $pred_json, $param, $forward = 1, $base_env = undef) {
564
+ require JSON::PP;
565
+ return find($items, JSON::PP->new->decode($pred_json), $param, $forward, $base_env);
566
+ }
567
+
568
+ sub find_index_json ($items, $pred_json, $param, $forward = 1, $base_env = undef) {
569
+ require JSON::PP;
570
+ return find_index($items, JSON::PP->new->decode($pred_json), $param, $forward, $base_env);
571
+ }
572
+
573
+ sub flat_map_json ($items, $proj_json, $param, $base_env = undef) {
574
+ require JSON::PP;
575
+ return flat_map($items, JSON::PP->new->decode($proj_json), $param, $base_env);
576
+ }
577
+
578
+ 1;
package/lib/BarefootJS.pm CHANGED
@@ -1,5 +1,5 @@
1
1
  package BarefootJS;
2
- our $VERSION = "0.15.2";
2
+ our $VERSION = "0.16.0";
3
3
  use strict;
4
4
  use warnings;
5
5
  use utf8;
@@ -8,6 +8,7 @@ no warnings 'experimental::signatures';
8
8
 
9
9
  use POSIX ();
10
10
  use Scalar::Util qw(looks_like_number weaken);
11
+ use BarefootJS::Evaluator ();
11
12
 
12
13
  # NOTE: This runtime is template-engine-agnostic AND framework-agnostic by
13
14
  # design, so it can ship as a standalone CPAN distribution. It depends only on
@@ -1010,6 +1011,53 @@ sub replace ($self, $recv, $pattern, $replacement) {
1010
1011
  return substr($s, 0, $i) . $n . substr($s, $i + CORE::length($o));
1011
1012
  }
1012
1013
 
1014
+ # `queryHref(base, { … })` (#2042) — build `"$base?k=v&…"` from a flat list of
1015
+ # (guard, key, value) triples. A pair is included iff its guard is truthy AND
1016
+ # its value is a non-empty string, mirroring the client `queryHref`'s `if
1017
+ # (value)` over string values: the adapter passes the guard `1` for a plain
1018
+ # `key: v`, or the lowered condition for `key: cond ? v : undefined`. Repeating a
1019
+ # key overwrites the value at its first position (`URLSearchParams.set`
1020
+ # semantics).
1021
+ #
1022
+ # A value may instead be an array ref, which APPENDS one pair per non-empty
1023
+ # member (`{ tag => ['a','b'] }` → `tag=a&tag=b`, i.e. `URLSearchParams.append`);
1024
+ # empty members are skipped, so an empty/all-empty array contributes nothing.
1025
+ #
1026
+ # Keys/values are form-encoded to equal the browser render byte-for-byte; no
1027
+ # surviving pair yields the bare base.
1028
+ sub query ($self, $base, @triples) {
1029
+ my $b = defined $base && !ref($base) ? "$base" : '';
1030
+ my (@pairs, %pos);
1031
+ my $i = 0;
1032
+ while ($i + 2 < @triples) {
1033
+ my ($guard, $key, $val) = @triples[$i, $i + 1, $i + 2];
1034
+ $i += 3;
1035
+ next unless $guard;
1036
+ $key = defined $key && !ref($key) ? "$key" : '';
1037
+ if (ref($val) eq 'ARRAY') {
1038
+ # Append each non-empty member; appended pairs never overwrite, so
1039
+ # they don't participate in the set()-position map.
1040
+ for my $m (@$val) {
1041
+ my $s = defined $m && !ref($m) ? "$m" : '';
1042
+ next if $s eq '';
1043
+ push @pairs, [$key, $s];
1044
+ }
1045
+ next;
1046
+ }
1047
+ $val = defined $val && !ref($val) ? "$val" : '';
1048
+ next if $val eq '';
1049
+ if (exists $pos{$key}) {
1050
+ $pairs[$pos{$key}][1] = $val;
1051
+ }
1052
+ else {
1053
+ $pos{$key} = scalar @pairs;
1054
+ push @pairs, [$key, $val];
1055
+ }
1056
+ }
1057
+ return $b unless @pairs;
1058
+ return "$b?" . CORE::join('&', map { _form_escape($_->[0]) . '=' . _form_escape($_->[1]) } @pairs);
1059
+ }
1060
+
1013
1061
  # `String.prototype.repeat(n)` — the receiver concatenated n times
1014
1062
  # (#1448 Tier B), via Perl's `x` operator. JS throws RangeError for a
1015
1063
  # negative count, but SSR templates degrade to the empty string rather
@@ -1089,6 +1137,49 @@ sub pad_end ($self, $recv, $target, $pad = undef) {
1089
1137
  # A future `nulls => 'first' | 'last'` knob can land per key without
1090
1138
  # churn — the opts hash is the right place to grow.
1091
1139
 
1140
+ # Evaluator-driven sort / reduce (#2018): the comparator / reducer body rides
1141
+ # as a serialized-ParsedExpr JSON string and is evaluated per element, delegating
1142
+ # to the shared BarefootJS::Evaluator. The adapter emits `bf->sort_eval(...)` /
1143
+ # `bf->reduce_eval(...)` for any pure comparator / reducer body; a body it can't
1144
+ # model (e.g. localeCompare) keeps the legacy `bf->sort` / `bf->reduce` path.
1145
+ sub sort_eval ($self, $recv, $cmp_json, $param_a, $param_b, $base_env = {}) {
1146
+ return BarefootJS::Evaluator::sort_by_json($recv, $cmp_json, $param_a, $param_b, $base_env);
1147
+ }
1148
+
1149
+ sub reduce_eval ($self, $recv, $body_json, $acc_name, $item_name, $init, $direction = 'left', $base_env = {}) {
1150
+ return BarefootJS::Evaluator::fold_json($recv, $body_json, $acc_name, $item_name, $init, $direction, $base_env);
1151
+ }
1152
+
1153
+ # Evaluator-driven higher-order predicates (#2018, P2): the predicate body
1154
+ # rides as a serialized-ParsedExpr JSON string evaluated per element, delegating
1155
+ # to the shared BarefootJS::Evaluator. The adapter emits `bf->filter_eval(...)`
1156
+ # etc. for any pure predicate; a body it can't model (e.g. a method-call
1157
+ # predicate) keeps the legacy `grep` / `bf->find` path. `find_eval` /
1158
+ # `find_index_eval` take a `$forward` flag (false → findLast / findLastIndex).
1159
+ sub filter_eval ($self, $recv, $pred_json, $param, $base_env = {}) {
1160
+ return BarefootJS::Evaluator::filter_json($recv, $pred_json, $param, $base_env);
1161
+ }
1162
+
1163
+ sub every_eval ($self, $recv, $pred_json, $param, $base_env = {}) {
1164
+ return BarefootJS::Evaluator::every_json($recv, $pred_json, $param, $base_env);
1165
+ }
1166
+
1167
+ sub some_eval ($self, $recv, $pred_json, $param, $base_env = {}) {
1168
+ return BarefootJS::Evaluator::some_json($recv, $pred_json, $param, $base_env);
1169
+ }
1170
+
1171
+ sub find_eval ($self, $recv, $pred_json, $param, $forward = 1, $base_env = {}) {
1172
+ return BarefootJS::Evaluator::find_json($recv, $pred_json, $param, $forward, $base_env);
1173
+ }
1174
+
1175
+ sub find_index_eval ($self, $recv, $pred_json, $param, $forward = 1, $base_env = {}) {
1176
+ return BarefootJS::Evaluator::find_index_json($recv, $pred_json, $param, $forward, $base_env);
1177
+ }
1178
+
1179
+ sub flat_map_eval ($self, $recv, $proj_json, $param, $base_env = {}) {
1180
+ return BarefootJS::Evaluator::flat_map_json($recv, $proj_json, $param, $base_env);
1181
+ }
1182
+
1092
1183
  sub sort ($self, $recv, $opts = {}) {
1093
1184
  return [] unless ref($recv) eq 'ARRAY';
1094
1185
 
@@ -1266,6 +1357,18 @@ sub _to_attr_name ($key) {
1266
1357
  return $out;
1267
1358
  }
1268
1359
 
1360
+ sub _form_escape ($s) {
1361
+ # application/x-www-form-urlencoded serialisation, matching the browser's
1362
+ # `URLSearchParams` (which the SSR query render must equal): keep ASCII
1363
+ # alphanumerics and `* - . _`; encode every other byte as `%XX` (UPPER hex);
1364
+ # space → `+`. Non-ASCII is encoded byte-wise over its UTF-8 bytes.
1365
+ my $bytes = defined $s ? "$s" : '';
1366
+ utf8::encode($bytes) if utf8::is_utf8($bytes);
1367
+ $bytes =~ s/([^A-Za-z0-9*\-._ ])/sprintf('%%%02X', ord($1))/ge;
1368
+ $bytes =~ tr/ /+/;
1369
+ return $bytes;
1370
+ }
1371
+
1269
1372
  sub _html_escape ($value) {
1270
1373
  # HTML attribute-value escape for SSR string emission. The
1271
1374
  # spread bag's values reach the browser as part of a generated
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/perl",
3
- "version": "0.16.0",
3
+ "version": "0.17.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,113 @@
1
+ use strict;
2
+ use warnings;
3
+ use Test::More;
4
+ use FindBin;
5
+ use File::Spec;
6
+ use JSON::PP ();
7
+ use B ();
8
+ use Scalar::Util qw(looks_like_number);
9
+
10
+ use lib "$FindBin::Bin/../lib";
11
+ use BarefootJS::Evaluator;
12
+
13
+ # Golden ParsedExpr-evaluator vectors (issue #2018, spec/compiler.md
14
+ # "ParsedExpr Evaluator Semantics"), generated from the JS reference
15
+ # evaluator and shared with the Go evaluator. The file is not shipped in
16
+ # the CPAN dist — packages/adapter-tests only exists in a monorepo
17
+ # checkout — so skip everywhere else.
18
+ my $vectors_path = File::Spec->catfile(
19
+ $FindBin::Bin, '..', '..', 'adapter-tests', 'helper-vectors', 'eval-vectors.json'
20
+ );
21
+ plan skip_all => 'eval vectors not available outside the monorepo checkout'
22
+ unless -e $vectors_path;
23
+
24
+ my $doc = do {
25
+ open my $fh, '<:raw', $vectors_path or die "open $vectors_path: $!";
26
+ local $/;
27
+ JSON::PP->new->decode(<$fh>);
28
+ };
29
+
30
+ die "eval-vectors.json contains no cases" unless @{ $doc->{cases} || [] };
31
+
32
+ # The evaluator is JS-faithful by contract, so there are NO Perl-side
33
+ # divergences here (unlike the bf->string / number helper vectors). Each
34
+ # case's real ParsedExpr tree, evaluated against its environment, must
35
+ # reproduce the JS-computed expect exactly — same input → same output as Go.
36
+ for my $case (@{ $doc->{cases} }) {
37
+ my $note = $case->{note};
38
+ my $expr = $case->{expr};
39
+ my $env = $case->{env};
40
+ my $expect = $case->{expect};
41
+
42
+ my $got = eval { BarefootJS::Evaluator::evaluate($expr, $env) };
43
+ if (my $err = $@) {
44
+ fail("$note died: $err");
45
+ next;
46
+ }
47
+ ok(_match($got, $expect), $note)
48
+ or diag('got ' . explain_value($got) . ', want ' . explain_value($expect));
49
+ }
50
+
51
+ done_testing;
52
+
53
+ # _is_real_number: true only when the scalar is an actual number (SV carries
54
+ # IOK/NOK), NOT a numeric-looking string. JSON::PP decodes a JSON number to
55
+ # IOK/NOK and a JSON string to a POK-only scalar, so this tells the JS *number*
56
+ # 42 from the JS *string* "42" — the distinction the evaluator must preserve.
57
+ # (looks_like_number can't: it is true for the string "42" too.)
58
+ sub _is_real_number {
59
+ my ($v) = @_;
60
+ return 0 unless defined $v && !ref $v;
61
+ return (B::svref_2object(\$v)->FLAGS & (B::SVf_IOK | B::SVf_NOK)) ? 1 : 0;
62
+ }
63
+
64
+ # _match: boolean form of the spec's value-compat comparison against a
65
+ # JSON-decoded expect — non-finite sentinel hashes, booleans by truthiness,
66
+ # numbers numerically, arrays/hashes recursively, strings by eq.
67
+ sub _match {
68
+ my ($got, $expect) = @_;
69
+ return !defined $got if !defined $expect;
70
+ if (ref $expect eq 'HASH' && exists $expect->{'$num'}) {
71
+ my $kind = $expect->{'$num'};
72
+ return 0 unless defined $got && looks_like_number($got);
73
+ return $got != $got ? 1 : 0 if $kind eq 'NaN';
74
+ my $inf = 9**9**9;
75
+ return $got == ($kind eq 'Infinity' ? $inf : -$inf) ? 1 : 0;
76
+ }
77
+ if (JSON::PP::is_bool($expect)) {
78
+ # The result must itself be a JS boolean (JSON::PP::Boolean), not a
79
+ # truthy/falsy 1/0 — otherwise a boolean-valued expression returning
80
+ # the number 1 instead of `true` would pass and mask the divergence.
81
+ return 0 unless JSON::PP::is_bool($got);
82
+ return (!!$got eq !!$expect) ? 1 : 0;
83
+ }
84
+ if (ref $expect eq 'ARRAY') {
85
+ return 0 unless ref $got eq 'ARRAY' && @$got == @$expect;
86
+ _match($got->[$_], $expect->[$_]) or return 0 for 0 .. $#$expect;
87
+ return 1;
88
+ }
89
+ if (ref $expect eq 'HASH') {
90
+ return 0 unless ref $got eq 'HASH' && keys %$got == keys %$expect;
91
+ for my $k (keys %$expect) {
92
+ return 0 unless exists $got->{$k};
93
+ _match($got->{$k}, $expect->{$k}) or return 0;
94
+ }
95
+ return 1;
96
+ }
97
+ return 0 if !defined $got || ref $got;
98
+ # Numeric == only when BOTH are real numbers (not numeric-looking strings),
99
+ # so a string-vs-number mismatch fails: e.g. String(42) must return the
100
+ # string "42", and the evaluator returning the number 42 must NOT pass.
101
+ my $want_num = _is_real_number($expect);
102
+ return 0 if $want_num != _is_real_number($got);
103
+ return ($got == $expect ? 1 : 0) if $want_num;
104
+ return ($got eq $expect) ? 1 : 0;
105
+ }
106
+
107
+ sub explain_value {
108
+ my ($v) = @_;
109
+ return 'undef' unless defined $v;
110
+ return JSON::PP->new->canonical->allow_nonref->allow_blessed->convert_blessed->encode($v)
111
+ if ref $v;
112
+ return "'$v'";
113
+ }
package/t/evaluator.t ADDED
@@ -0,0 +1,265 @@
1
+ use strict;
2
+ use warnings;
3
+ use Test::More;
4
+ use FindBin;
5
+ use JSON::PP ();
6
+
7
+ use lib "$FindBin::Bin/../lib";
8
+ use BarefootJS::Evaluator;
9
+
10
+ # Hand-built ParsedExpr constructors for the fold / sort_by trees, mirroring
11
+ # the Go eval_test.go demonstrations so the two backends prove the SAME
12
+ # restriction-lifting on the SAME shapes.
13
+ sub nid { return { kind => 'identifier', name => $_[0] } }
14
+ sub nmem { return { kind => 'member', object => $_[0], property => $_[1], computed => JSON_false() } }
15
+ sub nbin { return { kind => 'binary', op => $_[0], left => $_[1], right => $_[2] } }
16
+ sub nstr { return { kind => 'literal', value => $_[0], literalType => 'string' } }
17
+
18
+ sub ncall_math {
19
+ my ($fn, $arg) = @_;
20
+ return { kind => 'call', callee => nmem(nid('Math'), $fn), args => [$arg] };
21
+ }
22
+
23
+ # A plain false for the `computed` flag; the evaluator only checks truthiness.
24
+ sub JSON_false { return 0 }
25
+
26
+ # fold lifts bf_reduce's op restriction and acc-canonical form: a reducer body
27
+ # that mixes acc with a product of two fields is impossible in the +/*
28
+ # self/field catalogue but trivial for the evaluator.
29
+ subtest 'fold: arbitrary reducer body (acc + item.price * item.qty)' => sub {
30
+ my $body = nbin('+',
31
+ nid('acc'),
32
+ nbin('*', nmem(nid('item'), 'price'), nmem(nid('item'), 'qty')),
33
+ );
34
+ my $items = [ { price => 5, qty => 3 }, { price => 2, qty => 4 } ];
35
+ is(BarefootJS::Evaluator::fold($items, $body, 'acc', 'item', 0, 'left'),
36
+ 23, '0 + 5*3 + 2*4');
37
+ };
38
+
39
+ # reduceRight is observable for string concatenation; the same body folds both
40
+ # directions.
41
+ subtest 'fold: direction is observable for string concat' => sub {
42
+ my $body = nbin('+', nid('acc'), nid('item'));
43
+ my $items = [ 'a', 'b', 'c' ];
44
+ is(BarefootJS::Evaluator::fold($items, $body, 'acc', 'item', '', 'left'), 'abc', 'left');
45
+ is(BarefootJS::Evaluator::fold($items, $body, 'acc', 'item', '', 'right'), 'cba', 'right');
46
+ };
47
+
48
+ # sort_by lifts bf_sort's comparator pattern restriction: a comparator that
49
+ # calls Math.abs on each operand's field is outside the subtraction /
50
+ # localeCompare / relational-ternary catalogue, but is just another pure
51
+ # expression to the evaluator.
52
+ subtest 'sort_by: arbitrary comparator body (abs-of-field difference)' => sub {
53
+ my $cmp = nbin('-',
54
+ ncall_math('abs', nmem(nid('a'), 'v')),
55
+ ncall_math('abs', nmem(nid('b'), 'v')),
56
+ );
57
+ my $items = [ { v => -5 }, { v => 3 }, { v => -1 } ];
58
+ my $sorted = BarefootJS::Evaluator::sort_by($items, $cmp, 'a', 'b');
59
+ is_deeply([ map { $_->{v} } @$sorted ], [ -1, 3, -5 ], 'ascending by |v|');
60
+ };
61
+
62
+ # Descending is just a reversed comparator body — no separate direction knob.
63
+ subtest 'sort_by: descending via a reversed comparator' => sub {
64
+ my $cmp = nbin('-', nmem(nid('b'), 'x'), nmem(nid('a'), 'x'));
65
+ my $items = [ { x => 10 }, { x => 30 }, { x => 20 } ];
66
+ my $sorted = BarefootJS::Evaluator::sort_by($items, $cmp, 'a', 'b');
67
+ is_deeply([ map { $_->{x} } @$sorted ], [ 30, 20, 10 ], 'descending by x');
68
+ };
69
+
70
+ # Non-finite coercion stays JS-faithful (and matches the Go evaluator):
71
+ # division by zero is ±Infinity / NaN rather than a Perl die, and those
72
+ # values stringify as "Infinity" / "-Infinity" / "NaN" (not Perl's "Inf").
73
+ subtest 'non-finite: division by zero and JS stringification' => sub {
74
+ my $div = sub {
75
+ my ($a, $b) = @_;
76
+ BarefootJS::Evaluator::evaluate(
77
+ nbin('/', { kind => 'identifier', name => 'a' }, { kind => 'identifier', name => 'b' }),
78
+ { a => $a, b => $b },
79
+ );
80
+ };
81
+ my $inf = 9**9**9;
82
+ is($div->(1, 0), $inf, '1/0 is +Infinity, not a die');
83
+ is($div->(-1, 0), -$inf, '-1/0 is -Infinity');
84
+ my $nan = $div->(0, 0);
85
+ ok($nan != $nan, '0/0 is NaN');
86
+
87
+ is(BarefootJS::Evaluator::_to_string($inf), 'Infinity', 'String(Infinity)');
88
+ is(BarefootJS::Evaluator::_to_string(-$inf), '-Infinity', 'String(-Infinity)');
89
+ is(BarefootJS::Evaluator::_to_string($inf - $inf), 'NaN', 'String(NaN)');
90
+ };
91
+
92
+ # Captured free vars flow through $base_env (mirrors the Go FoldEval/SortEval
93
+ # baseEnv test) — a reducer / comparator body can reference an outer const.
94
+ subtest 'captured free vars via base_env' => sub {
95
+ # reduce: acc + item * factor, with `factor` captured.
96
+ my $body = nbin('+', nid('acc'), nbin('*', nid('item'), nid('factor')));
97
+ my $sum = BarefootJS::Evaluator::fold([1, 2, 3], $body, 'acc', 'item', 0, 'left', { factor => 10 });
98
+ is($sum, 60, '0 + 1*10 + 2*10 + 3*10 with captured factor');
99
+
100
+ # sort by distance from a captured `pivot`: |a-pivot| - |b-pivot|.
101
+ my $cmp = nbin('-',
102
+ ncall_math('abs', nbin('-', nid('a'), nid('pivot'))),
103
+ ncall_math('abs', nbin('-', nid('b'), nid('pivot'))),
104
+ );
105
+ my $sorted = BarefootJS::Evaluator::sort_by([1, 8, 4], $cmp, 'a', 'b', { pivot => 5 });
106
+ is_deeply($sorted, [4, 8, 1], 'ascending by distance from captured pivot');
107
+ };
108
+
109
+ # Boolean-valued operators return JS booleans (JSON::PP::Boolean), not 1/0 —
110
+ # matching the Go evaluator, so they stringify "true"/"false" and concatenate
111
+ # as JS does.
112
+ subtest 'boolean-valued ops return JS booleans, not 1/0' => sub {
113
+ my $lt = BarefootJS::Evaluator::evaluate(nbin('<', nid('a'), nid('b')), { a => 1, b => 2 });
114
+ ok(JSON::PP::is_bool($lt), 'a < b is a JS boolean');
115
+ is(BarefootJS::Evaluator::_to_string($lt), 'true', 'String(a < b) is "true", not "1"');
116
+
117
+ my $cat = BarefootJS::Evaluator::evaluate(
118
+ nbin('+', nstr('x'), nbin('<', nid('a'), nid('b'))), { a => 1, b => 2 });
119
+ is($cat, 'xtrue', "'x' + (a < b) is 'xtrue', not 'x1'");
120
+
121
+ my $eq = BarefootJS::Evaluator::evaluate(nbin('===', nid('a'), nid('b')), { a => 1, b => 1 });
122
+ ok(JSON::PP::is_bool($eq), '1 === 1 is a JS boolean');
123
+
124
+ my $not = BarefootJS::Evaluator::evaluate({ kind => 'unary', op => '!', argument => nstr('') }, {});
125
+ is(BarefootJS::Evaluator::_to_string($not), 'true', 'String(!"") is "true"');
126
+
127
+ my $b = BarefootJS::Evaluator::evaluate(
128
+ { kind => 'call', callee => nid('Boolean'), args => [nstr('')] }, {});
129
+ ok(JSON::PP::is_bool($b), 'Boolean("") is a JS boolean');
130
+ is(BarefootJS::Evaluator::_to_string($b), 'false', 'String(Boolean("")) is "false", not "0"');
131
+
132
+ # `.length` is a string/array property only; a numeric scalar has none.
133
+ my $len = BarefootJS::Evaluator::evaluate(nmem(nid('n'), 'length'), { n => 123 });
134
+ ok(!defined $len, '(123).length is null, not 3');
135
+ };
136
+
137
+ # sort_by tolerates a non-array receiver by returning an empty arrayref (the
138
+ # BarefootJS->sort convention), never undef — so callers can always deref it.
139
+ subtest 'sort_by non-array receiver returns []' => sub {
140
+ my $cmp = nbin('-', nid('a'), nid('b'));
141
+ is_deeply(BarefootJS::Evaluator::sort_by(undef, $cmp, 'a', 'b'), [], 'undef receiver → []');
142
+ is_deeply(BarefootJS::Evaluator::sort_by(42, $cmp, 'a', 'b'), [], 'scalar receiver → []');
143
+ };
144
+
145
+ # sort_by is stable: equal-comparing elements keep their input order. The
146
+ # explicit index tie-break makes this independent of the `sort` pragma /
147
+ # build, matching Go's sort.SliceStable.
148
+ subtest 'sort_by is stable for equal keys' => sub {
149
+ my $cmp = nbin('-', nmem(nid('a'), 'k'), nmem(nid('b'), 'k'));
150
+ # All-equal keys → input order preserved.
151
+ my $eq = BarefootJS::Evaluator::sort_by(
152
+ [ { k => 1, id => 'a' }, { k => 1, id => 'b' }, { k => 1, id => 'c' } ],
153
+ $cmp, 'a', 'b');
154
+ is_deeply([ map { $_->{id} } @$eq ], [ 'a', 'b', 'c' ], 'equal keys keep input order');
155
+ # Mixed keys → sorted by key, ties stable (x before z).
156
+ my $mixed = BarefootJS::Evaluator::sort_by(
157
+ [ { k => 2, id => 'x' }, { k => 1, id => 'y' }, { k => 2, id => 'z' } ],
158
+ $cmp, 'a', 'b');
159
+ is_deeply([ map { $_->{id} } @$mixed ], [ 'y', 'x', 'z' ], 'tie (x,z) stays in input order');
160
+ };
161
+
162
+ # fold_json / sort_by_json are the JSON-string seam the adapters emit into:
163
+ # the serialized ParsedExpr body travels as a `bf->reduce_eval` / `bf->sort_eval`
164
+ # argument and is decoded here, then handed to fold / sort_by. These exercise
165
+ # the exact shapes the #2018 EXPR2 reduce/sort migration emits (field access
166
+ # over hashref rows keyed by the raw JS prop name), with the captured-env arg.
167
+ subtest 'fold_json / sort_by_json decode a JSON body and evaluate it' => sub {
168
+ my @rows = ({ duration => 95 }, { duration => 213 }, { duration => 185 });
169
+
170
+ # reduce: sum + t.duration, seed 0 → 493
171
+ my $reduce = JSON::PP->new->encode(
172
+ nbin('+', nid('sum'), nmem(nid('t'), 'duration')));
173
+ is(BarefootJS::Evaluator::fold_json(\@rows, $reduce, 'sum', 't', 0, 'left', {}),
174
+ 493, 'fold_json sums a field');
175
+
176
+ # reduceRight concat is order-observable: cba, not abc.
177
+ my @labels = ({ label => 'a' }, { label => 'b' }, { label => 'c' });
178
+ my $concat = JSON::PP->new->encode(
179
+ nbin('+', nid('acc'), nmem(nid('x'), 'label')));
180
+ is(BarefootJS::Evaluator::fold_json(\@labels, $concat, 'acc', 'x', '', 'left', {}),
181
+ 'abc', 'fold_json concat left → abc');
182
+ is(BarefootJS::Evaluator::fold_json(\@labels, $concat, 'acc', 'x', '', 'right', {}),
183
+ 'cba', 'fold_json concat right → cba');
184
+
185
+ # sort: a.duration - b.duration → ascending
186
+ my $cmp = JSON::PP->new->encode(
187
+ nbin('-', nmem(nid('a'), 'duration'), nmem(nid('b'), 'duration')));
188
+ my $sorted = BarefootJS::Evaluator::sort_by_json(\@rows, $cmp, 'a', 'b', {});
189
+ is_deeply([ map { $_->{duration} } @$sorted ], [ 95, 185, 213 ],
190
+ 'sort_by_json orders by a field');
191
+ };
192
+
193
+ # The #2018 P2 higher-order predicate helpers evaluate an arbitrary pure
194
+ # predicate body per element, generalizing bf_filter / bf_find / bf_every /
195
+ # bf_some. Mirrors the Go TestPredicateEvalHelpers shapes (u => u.age >= 18).
196
+ subtest 'filter / every / some / find / find_index over a predicate body' => sub {
197
+ my @rows = ({ age => 15 }, { age => 30 }, { age => 18 });
198
+ my $pred = nbin('>=', nmem(nid('u'), 'age'),
199
+ { kind => 'literal', value => 18, literalType => 'number' });
200
+
201
+ my $f = BarefootJS::Evaluator::filter(\@rows, $pred, 'u');
202
+ is_deeply([ map { $_->{age} } @$f ], [ 30, 18 ], 'filter keeps age >= 18');
203
+
204
+ is(BarefootJS::Evaluator::some(\@rows, $pred, 'u'), 1, 'some → true');
205
+ is(BarefootJS::Evaluator::every(\@rows, $pred, 'u'), 0, 'every → false (15 < 18)');
206
+
207
+ is(BarefootJS::Evaluator::find(\@rows, $pred, 'u', 1)->{age}, 30, 'find forward → 30');
208
+ is(BarefootJS::Evaluator::find(\@rows, $pred, 'u', 0)->{age}, 18, 'findLast → 18');
209
+ is(BarefootJS::Evaluator::find_index(\@rows, $pred, 'u', 1), 1, 'findIndex → 1');
210
+ is(BarefootJS::Evaluator::find_index(\@rows, $pred, 'u', 0), 2, 'findLastIndex → 2');
211
+
212
+ # Empty receiver: every vacuously true, some false, find undef / index -1.
213
+ is(BarefootJS::Evaluator::every([], $pred, 'u'), 1, 'every(empty) → true');
214
+ is(BarefootJS::Evaluator::some([], $pred, 'u'), 0, 'some(empty) → false');
215
+ ok(!defined BarefootJS::Evaluator::find([], $pred, 'u'), 'find(empty) → undef');
216
+ is(BarefootJS::Evaluator::find_index([], $pred, 'u'), -1, 'find_index(empty) → -1');
217
+
218
+ # JSON seam: the body arrives as the string the adapter emits. Cover more
219
+ # than filter_json so each *_json entry point is pinned (Copilot review
220
+ # #2032).
221
+ my $json = JSON::PP->new->encode($pred);
222
+ my $fj = BarefootJS::Evaluator::filter_json(\@rows, $json, 'u');
223
+ is_deeply([ map { $_->{age} } @$fj ], [ 30, 18 ], 'filter_json decodes + filters');
224
+ is(BarefootJS::Evaluator::every_json(\@rows, $json, 'u'), 0, 'every_json → false');
225
+ is(BarefootJS::Evaluator::some_json(\@rows, $json, 'u'), 1, 'some_json → true');
226
+ is(BarefootJS::Evaluator::find_json(\@rows, $json, 'u', 1)->{age}, 30, 'find_json forward → 30');
227
+ is(BarefootJS::Evaluator::find_index_json(\@rows, $json, 'u', 0), 2, 'find_index_json backward → 2');
228
+
229
+ # Captured base_env: a predicate `u => u.age >= threshold` reads the outer
230
+ # `threshold`, and changing it changes the result — pins the capture
231
+ # plumbing (Copilot review #2032).
232
+ my $cap = nbin('>=', nmem(nid('u'), 'age'), nid('threshold'));
233
+ my $hi = BarefootJS::Evaluator::filter(\@rows, $cap, 'u', { threshold => 18 });
234
+ my $lo = BarefootJS::Evaluator::filter(\@rows, $cap, 'u', { threshold => 100 });
235
+ is(scalar(@$hi), 2, 'captured threshold 18 keeps 2');
236
+ is(scalar(@$lo), 0, 'captured threshold 100 keeps 0');
237
+ is(BarefootJS::Evaluator::find_index(\@rows, $cap, 'u', 1, { threshold => 100 }), -1,
238
+ 'find_index with unmet captured threshold → -1');
239
+ };
240
+
241
+ # #2018 P3: flat_map projects each element through a projection body and
242
+ # flattens one level — a field projection yielding an arrayref contributes its
243
+ # elements; an array-literal (tuple) projection contributes its leaves. Mirrors
244
+ # the Go TestFlatMapEval shapes.
245
+ subtest 'flat_map projects + flattens one level' => sub {
246
+ my @rows = ({ tags => [ 'a', 'b' ] }, { tags => [ 'c' ] });
247
+ my $field = nmem(nid('i'), 'tags');
248
+ is_deeply(BarefootJS::Evaluator::flat_map(\@rows, $field, 'i'), [ 'a', 'b', 'c' ],
249
+ 'field projection flattens the per-item arrays');
250
+
251
+ my @pts = ({ x => 1, y => 2 }, { x => 3, y => 4 });
252
+ my $tuple = {
253
+ kind => 'array-literal',
254
+ elements => [ nmem(nid('p'), 'x'), nmem(nid('p'), 'y') ],
255
+ };
256
+ is_deeply(BarefootJS::Evaluator::flat_map(\@pts, $tuple, 'p'), [ 1, 2, 3, 4 ],
257
+ 'array-literal projection flattens the leaf tuples');
258
+
259
+ # JSON seam.
260
+ my $fj = BarefootJS::Evaluator::flat_map_json(\@rows,
261
+ JSON::PP->new->encode($field), 'i');
262
+ is_deeply($fj, [ 'a', 'b', 'c' ], 'flat_map_json decodes + projects');
263
+ };
264
+
265
+ done_testing;
@@ -9,6 +9,12 @@ use Scalar::Util qw(looks_like_number);
9
9
  use lib "$FindBin::Bin/../lib";
10
10
  use BarefootJS;
11
11
 
12
+ # vectors.json is UTF-8 (string args like "café"; `≡` in some notes). Decode it
13
+ # as UTF-8 (below) and route TAP through a UTF-8 layer so wide test names don't
14
+ # trigger "Wide character in print".
15
+ binmode Test::More->builder->$_, ':encoding(UTF-8)'
16
+ for qw(output failure_output todo_output);
17
+
12
18
  # Pure-Perl backend (core JSON::PP only) so this test runs with zero
13
19
  # Mojo present — same pattern as t/template_primitives.t.
14
20
  {
@@ -37,7 +43,10 @@ plan skip_all => 'golden vectors not available outside the monorepo checkout'
37
43
  my $doc = do {
38
44
  open my $fh, '<:raw', $vectors_path or die "open $vectors_path: $!";
39
45
  local $/;
40
- JSON::PP->new->decode(<$fh>);
46
+ # ->utf8 decodes the file's UTF-8 bytes into Perl characters, so a value
47
+ # like "café" round-trips to a single é (U+00E9) instead of its two raw
48
+ # bytes — otherwise _form_escape would re-encode them and double-escape.
49
+ JSON::PP->new->utf8->decode(scalar <$fh>);
41
50
  };
42
51
 
43
52
  # One binding per canonical helper id in the spec catalogue, bound to
@@ -99,6 +108,11 @@ my %bindings = (
99
108
  # and '' for present-but-empty, so the Perl backend matches JS exactly.
100
109
  search_params_get => sub { BarefootJS->search_params($_[0])->get($_[1]) },
101
110
 
111
+ # queryHref SSR builder (#2042): (base, include, key, value, …) → URL. The
112
+ # include flags arrive as JSON booleans (JSON::PP::Boolean, truthy/falsy
113
+ # under `next unless`); the helper form-encodes to match URLSearchParams.
114
+ query => sub { $bf->query(@_) },
115
+
102
116
  # Higher-order entries arrive in the canonical projection form
103
117
  # (spec: items + field [+ value]); the closures below rebuild the
104
118
  # predicate the adapters compile (`i => i.field === value`,
package/t/query.t ADDED
@@ -0,0 +1,49 @@
1
+ use Test2::V0;
2
+ use utf8;
3
+
4
+ # BarefootJS->query — the `queryHref(base, { … })` URL-query builder helper
5
+ # (#2042).
6
+ #
7
+ # The full CROSS-BACKEND behaviour (control flow + form-encoding parity with the
8
+ # browser's URLSearchParams) is defined ONCE in the shared golden helper vectors
9
+ # — packages/adapter-tests/helper-vectors/vectors.json, fn "query" — and run
10
+ # here by t/helper_vectors.t and by the Go runtime's TestHelperVectors, so the
11
+ # Perl and Go expectations can't drift apart.
12
+ #
13
+ # This file keeps a few representative cases for always-on coverage (the golden
14
+ # file is monorepo-only, absent from the CPAN dist) plus the Perl-runtime-
15
+ # SPECIFIC defensive behaviour the golden vectors can't express: an `undef`
16
+ # value. JSON has no `undefined`, and a JSON `null` stringifies to "null" under
17
+ # JS `String()`, so it can't be a shared vector; Perl coerces `undef` to '' and
18
+ # omits the empty pair.
19
+
20
+ use FindBin qw($Bin);
21
+ use lib "$Bin/../lib";
22
+
23
+ use BarefootJS;
24
+
25
+ # `query` is a pure helper (no backend / controller), so a bare blessed stub is
26
+ # enough — mirroring t/helper_vectors.t. Triples are (include, key, value).
27
+ my $bf = bless {}, 'BarefootJS';
28
+
29
+ # Representative control flow: a repeated key overwrites at its first position
30
+ # (URLSearchParams.set), surrounding order preserved.
31
+ is $bf->query('/blog', 1, 'sort', 'title', 1, 'tag', 'go', 1, 'sort', 'date'),
32
+ '/blog?sort=date&tag=go', 'order preserved, repeated key overwrites at first position';
33
+
34
+ # Representative form-encoding: space → '+', '~' → %7E, '*' kept (URLSearchParams,
35
+ # not Go's url.QueryEscape).
36
+ is $bf->query('/s', 1, 't', 'a~b *c'), '/s?t=a%7Eb+*c', 'form-encode: ~ → %7E, * kept, space → +';
37
+
38
+ # Representative array value: an array ref appends one pair per non-empty member
39
+ # (#2048), skipping empties.
40
+ is $bf->query('/list', 1, 'tag', ['a', '', 'b']), '/list?tag=a&tag=b',
41
+ 'array value appends a pair per non-empty member';
42
+
43
+ # Perl-specific: an `undef` value is coerced to '' and then omitted, without
44
+ # disturbing the surrounding pairs.
45
+ is $bf->query('/list', 1, 'tag', undef), '/list', 'undef value coerced to empty → omitted';
46
+ is $bf->query('/list', 1, 'tag', undef, 1, 'keep', 'me'), '/list?keep=me',
47
+ 'undef value dropped, surrounding pairs intact';
48
+
49
+ done_testing;