@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/DevReload.pm +1 -1
- package/lib/BarefootJS/Evaluator.pm +635 -0
- package/lib/BarefootJS.pm +120 -8
- package/package.json +1 -1
- package/t/eval_vectors.t +113 -0
- package/t/evaluator.t +333 -0
- package/t/helper_vectors.t +15 -5
- package/t/query.t +49 -0
- package/t/template_primitives.t +15 -4
package/lib/BarefootJS.pm
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
package BarefootJS;
|
|
2
|
-
our $VERSION = "0.
|
|
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 `
|
|
584
|
-
#
|
|
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 (
|
|
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.
|
|
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": [
|
package/t/eval_vectors.t
ADDED
|
@@ -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;
|