@barefootjs/perl 0.16.0 → 0.17.1

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.pm CHANGED
@@ -1,5 +1,5 @@
1
1
  package BarefootJS;
2
- our $VERSION = "0.15.2";
2
+ our $VERSION = "0.17.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
@@ -580,8 +581,14 @@ sub round ($self, $value) {
580
581
  # receiver shapes apart without TS type inference, so both lower to
581
582
  # the same IR node (`array-method` / method `includes`). This helper
582
583
  # dispatches at the Perl level via `ref()`:
583
- # - ARRAY ref: scan elements with `eq`; one defined-vs-undef
584
- # hop matches JS's `===` for null/undefined.
584
+ # - ARRAY ref: scan elements with `BarefootJS::Evaluator::_same_value_zero`,
585
+ # matching `Array.prototype.includes`'s SameValueZero
586
+ # semantics (no cross-type coercion, e.g. `[2].includes("2")`
587
+ # is false; NaN matches NaN) — the same algorithm the
588
+ # evaluator's serialized-callback path already uses for
589
+ # `.includes`, so both positions agree. This used to be a
590
+ # stringy `eq` scan, which coerced numbers to strings
591
+ # (`[2].includes("2")` was true) and diverged from JS.
585
592
  # - scalar: `index($recv, $sub) != -1`, with both args
586
593
  # coerced through `// ''` so an undef receiver /
587
594
  # needle doesn't trip Perl's substr warning.
@@ -592,11 +599,7 @@ sub round ($self, $value) {
592
599
  sub includes ($self, $recv, $elem) {
593
600
  if (ref($recv) eq 'ARRAY') {
594
601
  for my $item (@$recv) {
595
- if (!defined $item) {
596
- return 1 if !defined $elem;
597
- next;
598
- }
599
- return 1 if defined $elem && $item eq $elem;
602
+ return 1 if BarefootJS::Evaluator::_same_value_zero($item, $elem);
600
603
  }
601
604
  return 0;
602
605
  }
@@ -1010,6 +1013,53 @@ sub replace ($self, $recv, $pattern, $replacement) {
1010
1013
  return substr($s, 0, $i) . $n . substr($s, $i + CORE::length($o));
1011
1014
  }
1012
1015
 
1016
+ # `queryHref(base, { … })` (#2042) — build `"$base?k=v&…"` from a flat list of
1017
+ # (guard, key, value) triples. A pair is included iff its guard is truthy AND
1018
+ # its value is a non-empty string, mirroring the client `queryHref`'s `if
1019
+ # (value)` over string values: the adapter passes the guard `1` for a plain
1020
+ # `key: v`, or the lowered condition for `key: cond ? v : undefined`. Repeating a
1021
+ # key overwrites the value at its first position (`URLSearchParams.set`
1022
+ # semantics).
1023
+ #
1024
+ # A value may instead be an array ref, which APPENDS one pair per non-empty
1025
+ # member (`{ tag => ['a','b'] }` → `tag=a&tag=b`, i.e. `URLSearchParams.append`);
1026
+ # empty members are skipped, so an empty/all-empty array contributes nothing.
1027
+ #
1028
+ # Keys/values are form-encoded to equal the browser render byte-for-byte; no
1029
+ # surviving pair yields the bare base.
1030
+ sub query ($self, $base, @triples) {
1031
+ my $b = defined $base && !ref($base) ? "$base" : '';
1032
+ my (@pairs, %pos);
1033
+ my $i = 0;
1034
+ while ($i + 2 < @triples) {
1035
+ my ($guard, $key, $val) = @triples[$i, $i + 1, $i + 2];
1036
+ $i += 3;
1037
+ next unless $guard;
1038
+ $key = defined $key && !ref($key) ? "$key" : '';
1039
+ if (ref($val) eq 'ARRAY') {
1040
+ # Append each non-empty member; appended pairs never overwrite, so
1041
+ # they don't participate in the set()-position map.
1042
+ for my $m (@$val) {
1043
+ my $s = defined $m && !ref($m) ? "$m" : '';
1044
+ next if $s eq '';
1045
+ push @pairs, [$key, $s];
1046
+ }
1047
+ next;
1048
+ }
1049
+ $val = defined $val && !ref($val) ? "$val" : '';
1050
+ next if $val eq '';
1051
+ if (exists $pos{$key}) {
1052
+ $pairs[$pos{$key}][1] = $val;
1053
+ }
1054
+ else {
1055
+ $pos{$key} = scalar @pairs;
1056
+ push @pairs, [$key, $val];
1057
+ }
1058
+ }
1059
+ return $b unless @pairs;
1060
+ return "$b?" . CORE::join('&', map { _form_escape($_->[0]) . '=' . _form_escape($_->[1]) } @pairs);
1061
+ }
1062
+
1013
1063
  # `String.prototype.repeat(n)` — the receiver concatenated n times
1014
1064
  # (#1448 Tier B), via Perl's `x` operator. JS throws RangeError for a
1015
1065
  # negative count, but SSR templates degrade to the empty string rather
@@ -1089,6 +1139,56 @@ sub pad_end ($self, $recv, $target, $pad = undef) {
1089
1139
  # A future `nulls => 'first' | 'last'` knob can land per key without
1090
1140
  # churn — the opts hash is the right place to grow.
1091
1141
 
1142
+ # Evaluator-driven sort / reduce (#2018): the comparator / reducer body rides
1143
+ # as a serialized-ParsedExpr JSON string and is evaluated per element, delegating
1144
+ # to the shared BarefootJS::Evaluator. The adapter emits `bf->sort_eval(...)` /
1145
+ # `bf->reduce_eval(...)` for any pure comparator / reducer body; a body it can't
1146
+ # model (e.g. localeCompare) keeps the legacy `bf->sort` / `bf->reduce` path.
1147
+ sub sort_eval ($self, $recv, $cmp_json, $param_a, $param_b, $base_env = {}) {
1148
+ return BarefootJS::Evaluator::sort_by_json($recv, $cmp_json, $param_a, $param_b, $base_env);
1149
+ }
1150
+
1151
+ sub reduce_eval ($self, $recv, $body_json, $acc_name, $item_name, $init, $direction = 'left', $base_env = {}) {
1152
+ return BarefootJS::Evaluator::fold_json($recv, $body_json, $acc_name, $item_name, $init, $direction, $base_env);
1153
+ }
1154
+
1155
+ # Evaluator-driven higher-order predicates (#2018, P2): the predicate body
1156
+ # rides as a serialized-ParsedExpr JSON string evaluated per element, delegating
1157
+ # to the shared BarefootJS::Evaluator. The adapter emits `bf->filter_eval(...)`
1158
+ # etc. for any pure predicate; a body it can't model (e.g. a method-call
1159
+ # predicate) keeps the legacy `grep` / `bf->find` path. `find_eval` /
1160
+ # `find_index_eval` take a `$forward` flag (false → findLast / findLastIndex).
1161
+ sub filter_eval ($self, $recv, $pred_json, $param, $base_env = {}) {
1162
+ return BarefootJS::Evaluator::filter_json($recv, $pred_json, $param, $base_env);
1163
+ }
1164
+
1165
+ sub every_eval ($self, $recv, $pred_json, $param, $base_env = {}) {
1166
+ return BarefootJS::Evaluator::every_json($recv, $pred_json, $param, $base_env);
1167
+ }
1168
+
1169
+ sub some_eval ($self, $recv, $pred_json, $param, $base_env = {}) {
1170
+ return BarefootJS::Evaluator::some_json($recv, $pred_json, $param, $base_env);
1171
+ }
1172
+
1173
+ sub find_eval ($self, $recv, $pred_json, $param, $forward = 1, $base_env = {}) {
1174
+ return BarefootJS::Evaluator::find_json($recv, $pred_json, $param, $forward, $base_env);
1175
+ }
1176
+
1177
+ sub find_index_eval ($self, $recv, $pred_json, $param, $forward = 1, $base_env = {}) {
1178
+ return BarefootJS::Evaluator::find_index_json($recv, $pred_json, $param, $forward, $base_env);
1179
+ }
1180
+
1181
+ sub flat_map_eval ($self, $recv, $proj_json, $param, $base_env = {}) {
1182
+ return BarefootJS::Evaluator::flat_map_json($recv, $proj_json, $param, $base_env);
1183
+ }
1184
+
1185
+ # Value-producing `.map(cb)` (#2073): project each element through the
1186
+ # serialized projection body, one result per element (no flatten). Composes
1187
+ # through the array-method chain (`.map(cb).join(' ')`).
1188
+ sub map_eval ($self, $recv, $proj_json, $param, $base_env = {}) {
1189
+ return BarefootJS::Evaluator::map_json($recv, $proj_json, $param, $base_env);
1190
+ }
1191
+
1092
1192
  sub sort ($self, $recv, $opts = {}) {
1093
1193
  return [] unless ref($recv) eq 'ARRAY';
1094
1194
 
@@ -1266,6 +1366,18 @@ sub _to_attr_name ($key) {
1266
1366
  return $out;
1267
1367
  }
1268
1368
 
1369
+ sub _form_escape ($s) {
1370
+ # application/x-www-form-urlencoded serialisation, matching the browser's
1371
+ # `URLSearchParams` (which the SSR query render must equal): keep ASCII
1372
+ # alphanumerics and `* - . _`; encode every other byte as `%XX` (UPPER hex);
1373
+ # space → `+`. Non-ASCII is encoded byte-wise over its UTF-8 bytes.
1374
+ my $bytes = defined $s ? "$s" : '';
1375
+ utf8::encode($bytes) if utf8::is_utf8($bytes);
1376
+ $bytes =~ s/([^A-Za-z0-9*\-._ ])/sprintf('%%%02X', ord($1))/ge;
1377
+ $bytes =~ tr/ /+/;
1378
+ return $bytes;
1379
+ }
1380
+
1269
1381
  sub _html_escape ($value) {
1270
1382
  # HTML attribute-value escape for SSR string emission. The
1271
1383
  # 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.1",
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,333 @@
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
+ sub nincludes {
24
+ my ($object, $needle) = @_;
25
+ return { kind => 'array-method', method => 'includes', object => $object, args => [$needle] };
26
+ }
27
+
28
+ # A plain false for the `computed` flag; the evaluator only checks truthiness.
29
+ sub JSON_false { return 0 }
30
+
31
+ # fold lifts bf_reduce's op restriction and acc-canonical form: a reducer body
32
+ # that mixes acc with a product of two fields is impossible in the +/*
33
+ # self/field catalogue but trivial for the evaluator.
34
+ subtest 'fold: arbitrary reducer body (acc + item.price * item.qty)' => sub {
35
+ my $body = nbin('+',
36
+ nid('acc'),
37
+ nbin('*', nmem(nid('item'), 'price'), nmem(nid('item'), 'qty')),
38
+ );
39
+ my $items = [ { price => 5, qty => 3 }, { price => 2, qty => 4 } ];
40
+ is(BarefootJS::Evaluator::fold($items, $body, 'acc', 'item', 0, 'left'),
41
+ 23, '0 + 5*3 + 2*4');
42
+ };
43
+
44
+ # reduceRight is observable for string concatenation; the same body folds both
45
+ # directions.
46
+ subtest 'fold: direction is observable for string concat' => sub {
47
+ my $body = nbin('+', nid('acc'), nid('item'));
48
+ my $items = [ 'a', 'b', 'c' ];
49
+ is(BarefootJS::Evaluator::fold($items, $body, 'acc', 'item', '', 'left'), 'abc', 'left');
50
+ is(BarefootJS::Evaluator::fold($items, $body, 'acc', 'item', '', 'right'), 'cba', 'right');
51
+ };
52
+
53
+ # sort_by lifts bf_sort's comparator pattern restriction: a comparator that
54
+ # calls Math.abs on each operand's field is outside the subtraction /
55
+ # localeCompare / relational-ternary catalogue, but is just another pure
56
+ # expression to the evaluator.
57
+ subtest 'sort_by: arbitrary comparator body (abs-of-field difference)' => sub {
58
+ my $cmp = nbin('-',
59
+ ncall_math('abs', nmem(nid('a'), 'v')),
60
+ ncall_math('abs', nmem(nid('b'), 'v')),
61
+ );
62
+ my $items = [ { v => -5 }, { v => 3 }, { v => -1 } ];
63
+ my $sorted = BarefootJS::Evaluator::sort_by($items, $cmp, 'a', 'b');
64
+ is_deeply([ map { $_->{v} } @$sorted ], [ -1, 3, -5 ], 'ascending by |v|');
65
+ };
66
+
67
+ # Descending is just a reversed comparator body — no separate direction knob.
68
+ subtest 'sort_by: descending via a reversed comparator' => sub {
69
+ my $cmp = nbin('-', nmem(nid('b'), 'x'), nmem(nid('a'), 'x'));
70
+ my $items = [ { x => 10 }, { x => 30 }, { x => 20 } ];
71
+ my $sorted = BarefootJS::Evaluator::sort_by($items, $cmp, 'a', 'b');
72
+ is_deeply([ map { $_->{x} } @$sorted ], [ 30, 20, 10 ], 'descending by x');
73
+ };
74
+
75
+ # Non-finite coercion stays JS-faithful (and matches the Go evaluator):
76
+ # division by zero is ±Infinity / NaN rather than a Perl die, and those
77
+ # values stringify as "Infinity" / "-Infinity" / "NaN" (not Perl's "Inf").
78
+ subtest 'non-finite: division by zero and JS stringification' => sub {
79
+ my $div = sub {
80
+ my ($a, $b) = @_;
81
+ BarefootJS::Evaluator::evaluate(
82
+ nbin('/', { kind => 'identifier', name => 'a' }, { kind => 'identifier', name => 'b' }),
83
+ { a => $a, b => $b },
84
+ );
85
+ };
86
+ my $inf = 9**9**9;
87
+ is($div->(1, 0), $inf, '1/0 is +Infinity, not a die');
88
+ is($div->(-1, 0), -$inf, '-1/0 is -Infinity');
89
+ my $nan = $div->(0, 0);
90
+ ok($nan != $nan, '0/0 is NaN');
91
+
92
+ is(BarefootJS::Evaluator::_to_string($inf), 'Infinity', 'String(Infinity)');
93
+ is(BarefootJS::Evaluator::_to_string(-$inf), '-Infinity', 'String(-Infinity)');
94
+ is(BarefootJS::Evaluator::_to_string($inf - $inf), 'NaN', 'String(NaN)');
95
+ };
96
+
97
+ # Captured free vars flow through $base_env (mirrors the Go FoldEval/SortEval
98
+ # baseEnv test) — a reducer / comparator body can reference an outer const.
99
+ subtest 'captured free vars via base_env' => sub {
100
+ # reduce: acc + item * factor, with `factor` captured.
101
+ my $body = nbin('+', nid('acc'), nbin('*', nid('item'), nid('factor')));
102
+ my $sum = BarefootJS::Evaluator::fold([1, 2, 3], $body, 'acc', 'item', 0, 'left', { factor => 10 });
103
+ is($sum, 60, '0 + 1*10 + 2*10 + 3*10 with captured factor');
104
+
105
+ # sort by distance from a captured `pivot`: |a-pivot| - |b-pivot|.
106
+ my $cmp = nbin('-',
107
+ ncall_math('abs', nbin('-', nid('a'), nid('pivot'))),
108
+ ncall_math('abs', nbin('-', nid('b'), nid('pivot'))),
109
+ );
110
+ my $sorted = BarefootJS::Evaluator::sort_by([1, 8, 4], $cmp, 'a', 'b', { pivot => 5 });
111
+ is_deeply($sorted, [4, 8, 1], 'ascending by distance from captured pivot');
112
+ };
113
+
114
+ # Boolean-valued operators return JS booleans (JSON::PP::Boolean), not 1/0 —
115
+ # matching the Go evaluator, so they stringify "true"/"false" and concatenate
116
+ # as JS does.
117
+ subtest 'boolean-valued ops return JS booleans, not 1/0' => sub {
118
+ my $lt = BarefootJS::Evaluator::evaluate(nbin('<', nid('a'), nid('b')), { a => 1, b => 2 });
119
+ ok(JSON::PP::is_bool($lt), 'a < b is a JS boolean');
120
+ is(BarefootJS::Evaluator::_to_string($lt), 'true', 'String(a < b) is "true", not "1"');
121
+
122
+ my $cat = BarefootJS::Evaluator::evaluate(
123
+ nbin('+', nstr('x'), nbin('<', nid('a'), nid('b'))), { a => 1, b => 2 });
124
+ is($cat, 'xtrue', "'x' + (a < b) is 'xtrue', not 'x1'");
125
+
126
+ my $eq = BarefootJS::Evaluator::evaluate(nbin('===', nid('a'), nid('b')), { a => 1, b => 1 });
127
+ ok(JSON::PP::is_bool($eq), '1 === 1 is a JS boolean');
128
+
129
+ my $not = BarefootJS::Evaluator::evaluate({ kind => 'unary', op => '!', argument => nstr('') }, {});
130
+ is(BarefootJS::Evaluator::_to_string($not), 'true', 'String(!"") is "true"');
131
+
132
+ my $b = BarefootJS::Evaluator::evaluate(
133
+ { kind => 'call', callee => nid('Boolean'), args => [nstr('')] }, {});
134
+ ok(JSON::PP::is_bool($b), 'Boolean("") is a JS boolean');
135
+ is(BarefootJS::Evaluator::_to_string($b), 'false', 'String(Boolean("")) is "false", not "0"');
136
+
137
+ # `.length` is a string/array property only; a numeric scalar has none.
138
+ my $len = BarefootJS::Evaluator::evaluate(nmem(nid('n'), 'length'), { n => 123 });
139
+ ok(!defined $len, '(123).length is null, not 3');
140
+ };
141
+
142
+ # `.includes` (#2075) is the one `array-method` in the evaluator subset,
143
+ # dispatching on the receiver type like the SSR template lowering does at
144
+ # runtime (`bf_includes` / `$bf->includes`): array → SameValueZero membership
145
+ # (the same value rules as `===`, so a numeric 2 does NOT match the string
146
+ # "2"); string → substring search; anything else degrades to false rather
147
+ # than dying.
148
+ subtest 'array-method includes' => sub {
149
+ my $hit = BarefootJS::Evaluator::evaluate(nincludes(nid('tags'), nstr('go')), { tags => [ 'perl', 'go' ] });
150
+ ok(JSON::PP::is_bool($hit), 'array hit is a JS boolean');
151
+ ok($hit, 'array .includes: hit');
152
+
153
+ my $miss = BarefootJS::Evaluator::evaluate(nincludes(nid('tags'), nstr('rust')), { tags => [ 'perl', 'go' ] });
154
+ ok(JSON::PP::is_bool($miss), 'array miss is a JS boolean');
155
+ ok(!$miss, 'array .includes: miss');
156
+
157
+ # SameValueZero, not loose equality: the numeric element 2 matches the
158
+ # numeric needle 2, but the string needle "2" (a different JS type) does
159
+ # not — mirroring `===`'s type-sensitivity.
160
+ my $num_hit = BarefootJS::Evaluator::evaluate(nincludes(nid('nums'), { kind => 'literal', value => 2 }), { nums => [ 1, 2, 3 ] });
161
+ ok($num_hit, 'array .includes: numeric element hit');
162
+ my $num_vs_string = BarefootJS::Evaluator::evaluate(nincludes(nid('nums'), nstr('2')), { nums => [ 1, 2, 3 ] });
163
+ ok(!$num_vs_string, 'array .includes: numeric element does not match a string needle');
164
+
165
+ my $sub = BarefootJS::Evaluator::evaluate(nincludes(nid('name'), nstr('ar')), { name => 'bare' });
166
+ ok($sub, 'string .includes: substring hit');
167
+
168
+ # A non-array, non-string receiver (number, null, object) is not a JS
169
+ # `.includes` target; the evaluator degrades to false rather than dying.
170
+ my $scalar_recv = BarefootJS::Evaluator::evaluate(nincludes(nid('n'), { kind => 'literal', value => 1 }), { n => 42 });
171
+ ok(!$scalar_recv, 'non-collection receiver (number) is false, not a die');
172
+ my $null_recv = BarefootJS::Evaluator::evaluate(nincludes(nid('n'), nstr('x')), { n => undef });
173
+ ok(!$null_recv, 'non-collection receiver (null) is false, not a die');
174
+ };
175
+
176
+ # sort_by tolerates a non-array receiver by returning an empty arrayref (the
177
+ # BarefootJS->sort convention), never undef — so callers can always deref it.
178
+ subtest 'sort_by non-array receiver returns []' => sub {
179
+ my $cmp = nbin('-', nid('a'), nid('b'));
180
+ is_deeply(BarefootJS::Evaluator::sort_by(undef, $cmp, 'a', 'b'), [], 'undef receiver → []');
181
+ is_deeply(BarefootJS::Evaluator::sort_by(42, $cmp, 'a', 'b'), [], 'scalar receiver → []');
182
+ };
183
+
184
+ # sort_by is stable: equal-comparing elements keep their input order. The
185
+ # explicit index tie-break makes this independent of the `sort` pragma /
186
+ # build, matching Go's sort.SliceStable.
187
+ subtest 'sort_by is stable for equal keys' => sub {
188
+ my $cmp = nbin('-', nmem(nid('a'), 'k'), nmem(nid('b'), 'k'));
189
+ # All-equal keys → input order preserved.
190
+ my $eq = BarefootJS::Evaluator::sort_by(
191
+ [ { k => 1, id => 'a' }, { k => 1, id => 'b' }, { k => 1, id => 'c' } ],
192
+ $cmp, 'a', 'b');
193
+ is_deeply([ map { $_->{id} } @$eq ], [ 'a', 'b', 'c' ], 'equal keys keep input order');
194
+ # Mixed keys → sorted by key, ties stable (x before z).
195
+ my $mixed = BarefootJS::Evaluator::sort_by(
196
+ [ { k => 2, id => 'x' }, { k => 1, id => 'y' }, { k => 2, id => 'z' } ],
197
+ $cmp, 'a', 'b');
198
+ is_deeply([ map { $_->{id} } @$mixed ], [ 'y', 'x', 'z' ], 'tie (x,z) stays in input order');
199
+ };
200
+
201
+ # fold_json / sort_by_json are the JSON-string seam the adapters emit into:
202
+ # the serialized ParsedExpr body travels as a `bf->reduce_eval` / `bf->sort_eval`
203
+ # argument and is decoded here, then handed to fold / sort_by. These exercise
204
+ # the exact shapes the #2018 EXPR2 reduce/sort migration emits (field access
205
+ # over hashref rows keyed by the raw JS prop name), with the captured-env arg.
206
+ subtest 'fold_json / sort_by_json decode a JSON body and evaluate it' => sub {
207
+ my @rows = ({ duration => 95 }, { duration => 213 }, { duration => 185 });
208
+
209
+ # reduce: sum + t.duration, seed 0 → 493
210
+ my $reduce = JSON::PP->new->encode(
211
+ nbin('+', nid('sum'), nmem(nid('t'), 'duration')));
212
+ is(BarefootJS::Evaluator::fold_json(\@rows, $reduce, 'sum', 't', 0, 'left', {}),
213
+ 493, 'fold_json sums a field');
214
+
215
+ # reduceRight concat is order-observable: cba, not abc.
216
+ my @labels = ({ label => 'a' }, { label => 'b' }, { label => 'c' });
217
+ my $concat = JSON::PP->new->encode(
218
+ nbin('+', nid('acc'), nmem(nid('x'), 'label')));
219
+ is(BarefootJS::Evaluator::fold_json(\@labels, $concat, 'acc', 'x', '', 'left', {}),
220
+ 'abc', 'fold_json concat left → abc');
221
+ is(BarefootJS::Evaluator::fold_json(\@labels, $concat, 'acc', 'x', '', 'right', {}),
222
+ 'cba', 'fold_json concat right → cba');
223
+
224
+ # sort: a.duration - b.duration → ascending
225
+ my $cmp = JSON::PP->new->encode(
226
+ nbin('-', nmem(nid('a'), 'duration'), nmem(nid('b'), 'duration')));
227
+ my $sorted = BarefootJS::Evaluator::sort_by_json(\@rows, $cmp, 'a', 'b', {});
228
+ is_deeply([ map { $_->{duration} } @$sorted ], [ 95, 185, 213 ],
229
+ 'sort_by_json orders by a field');
230
+ };
231
+
232
+ # The #2018 P2 higher-order predicate helpers evaluate an arbitrary pure
233
+ # predicate body per element, generalizing bf_filter / bf_find / bf_every /
234
+ # bf_some. Mirrors the Go TestPredicateEvalHelpers shapes (u => u.age >= 18).
235
+ subtest 'filter / every / some / find / find_index over a predicate body' => sub {
236
+ my @rows = ({ age => 15 }, { age => 30 }, { age => 18 });
237
+ my $pred = nbin('>=', nmem(nid('u'), 'age'),
238
+ { kind => 'literal', value => 18, literalType => 'number' });
239
+
240
+ my $f = BarefootJS::Evaluator::filter(\@rows, $pred, 'u');
241
+ is_deeply([ map { $_->{age} } @$f ], [ 30, 18 ], 'filter keeps age >= 18');
242
+
243
+ is(BarefootJS::Evaluator::some(\@rows, $pred, 'u'), 1, 'some → true');
244
+ is(BarefootJS::Evaluator::every(\@rows, $pred, 'u'), 0, 'every → false (15 < 18)');
245
+
246
+ is(BarefootJS::Evaluator::find(\@rows, $pred, 'u', 1)->{age}, 30, 'find forward → 30');
247
+ is(BarefootJS::Evaluator::find(\@rows, $pred, 'u', 0)->{age}, 18, 'findLast → 18');
248
+ is(BarefootJS::Evaluator::find_index(\@rows, $pred, 'u', 1), 1, 'findIndex → 1');
249
+ is(BarefootJS::Evaluator::find_index(\@rows, $pred, 'u', 0), 2, 'findLastIndex → 2');
250
+
251
+ # Empty receiver: every vacuously true, some false, find undef / index -1.
252
+ is(BarefootJS::Evaluator::every([], $pred, 'u'), 1, 'every(empty) → true');
253
+ is(BarefootJS::Evaluator::some([], $pred, 'u'), 0, 'some(empty) → false');
254
+ ok(!defined BarefootJS::Evaluator::find([], $pred, 'u'), 'find(empty) → undef');
255
+ is(BarefootJS::Evaluator::find_index([], $pred, 'u'), -1, 'find_index(empty) → -1');
256
+
257
+ # JSON seam: the body arrives as the string the adapter emits. Cover more
258
+ # than filter_json so each *_json entry point is pinned (Copilot review
259
+ # #2032).
260
+ my $json = JSON::PP->new->encode($pred);
261
+ my $fj = BarefootJS::Evaluator::filter_json(\@rows, $json, 'u');
262
+ is_deeply([ map { $_->{age} } @$fj ], [ 30, 18 ], 'filter_json decodes + filters');
263
+ is(BarefootJS::Evaluator::every_json(\@rows, $json, 'u'), 0, 'every_json → false');
264
+ is(BarefootJS::Evaluator::some_json(\@rows, $json, 'u'), 1, 'some_json → true');
265
+ is(BarefootJS::Evaluator::find_json(\@rows, $json, 'u', 1)->{age}, 30, 'find_json forward → 30');
266
+ is(BarefootJS::Evaluator::find_index_json(\@rows, $json, 'u', 0), 2, 'find_index_json backward → 2');
267
+
268
+ # Captured base_env: a predicate `u => u.age >= threshold` reads the outer
269
+ # `threshold`, and changing it changes the result — pins the capture
270
+ # plumbing (Copilot review #2032).
271
+ my $cap = nbin('>=', nmem(nid('u'), 'age'), nid('threshold'));
272
+ my $hi = BarefootJS::Evaluator::filter(\@rows, $cap, 'u', { threshold => 18 });
273
+ my $lo = BarefootJS::Evaluator::filter(\@rows, $cap, 'u', { threshold => 100 });
274
+ is(scalar(@$hi), 2, 'captured threshold 18 keeps 2');
275
+ is(scalar(@$lo), 0, 'captured threshold 100 keeps 0');
276
+ is(BarefootJS::Evaluator::find_index(\@rows, $cap, 'u', 1, { threshold => 100 }), -1,
277
+ 'find_index with unmet captured threshold → -1');
278
+ };
279
+
280
+ # #2018 P3: flat_map projects each element through a projection body and
281
+ # flattens one level — a field projection yielding an arrayref contributes its
282
+ # elements; an array-literal (tuple) projection contributes its leaves. Mirrors
283
+ # the Go TestFlatMapEval shapes.
284
+ subtest 'flat_map projects + flattens one level' => sub {
285
+ my @rows = ({ tags => [ 'a', 'b' ] }, { tags => [ 'c' ] });
286
+ my $field = nmem(nid('i'), 'tags');
287
+ is_deeply(BarefootJS::Evaluator::flat_map(\@rows, $field, 'i'), [ 'a', 'b', 'c' ],
288
+ 'field projection flattens the per-item arrays');
289
+
290
+ my @pts = ({ x => 1, y => 2 }, { x => 3, y => 4 });
291
+ my $tuple = {
292
+ kind => 'array-literal',
293
+ elements => [ nmem(nid('p'), 'x'), nmem(nid('p'), 'y') ],
294
+ };
295
+ is_deeply(BarefootJS::Evaluator::flat_map(\@pts, $tuple, 'p'), [ 1, 2, 3, 4 ],
296
+ 'array-literal projection flattens the leaf tuples');
297
+
298
+ # JSON seam.
299
+ my $fj = BarefootJS::Evaluator::flat_map_json(\@rows,
300
+ JSON::PP->new->encode($field), 'i');
301
+ is_deeply($fj, [ 'a', 'b', 'c' ], 'flat_map_json decodes + projects');
302
+ };
303
+
304
+ # #2073: map_items is the value-producing `.map(cb)` — one result per element,
305
+ # NO flatten (an array-valued projection stays one element). Mirrors the Go
306
+ # TestMapEval shapes.
307
+ subtest 'map_items projects one result per element (no flatten)' => sub {
308
+ my $tmpl = {
309
+ kind => 'template-literal',
310
+ parts => [
311
+ { type => 'string', value => '#' },
312
+ { type => 'expression', expr => nid('t') },
313
+ ],
314
+ };
315
+ is_deeply(BarefootJS::Evaluator::map_items([ 'perl', 'go' ], $tmpl, 't'),
316
+ [ '#perl', '#go' ], 'template-literal projection maps each element');
317
+
318
+ my @users = ({ name => 'Ada' }, { name => 'Grace' });
319
+ my $field = nmem(nid('u'), 'name');
320
+ is_deeply(BarefootJS::Evaluator::map_items(\@users, $field, 'u'),
321
+ [ 'Ada', 'Grace' ], 'field projection maps each element');
322
+
323
+ my @rows = ({ tags => [ 'a', 'b' ] });
324
+ is_deeply(BarefootJS::Evaluator::map_items(\@rows, nmem(nid('i'), 'tags'), 'i'),
325
+ [ [ 'a', 'b' ] ], 'array-valued projection stays ONE element (no flatten)');
326
+
327
+ # JSON seam.
328
+ my $mj = BarefootJS::Evaluator::map_json(\@users,
329
+ JSON::PP->new->encode($field), 'u');
330
+ is_deeply($mj, [ 'Ada', 'Grace' ], 'map_json decodes + projects');
331
+ };
332
+
333
+ done_testing;