BarefootJS

 view release on metacpan or  search on metacpan

lib/BarefootJS.pm  view on Meta::CPAN

# ---------------------------------------------------------------------------
# JS-compat callees (#1189) — invoked from generated Mojo templates as
# <%= bf->json($val) %>, <%= bf->floor($val) %>, etc. The MojoAdapter's
# `templatePrimitives` registry emits these helper calls in place of the
# corresponding JS callees (`JSON.stringify`, `Math.floor`, …) so the SSR
# template can render value-equivalent output without a JS engine.
#
# Failure policy mirrors the Go adapter (#1188): user-data marshalling
# (json) bubbles errors so Mojolicious aborts loudly on cycles /
# unsupported values rather than silently producing an empty payload.
# Numeric coercion follows JS semantics (NaN propagates as the special
# string 'NaN'; non-numeric input returns 'NaN' rather than 0). Strings
# always coerce to a string representation.
# ---------------------------------------------------------------------------

sub json ($self, $value) {
    # Mojo::JSON::to_json returns a character string (not bytes), suitable
    # for embedding in HTML output via Mojo::ByteStream / `<%==`.
    #
    # Documented divergence from JS: JS distinguishes `null` (renders as
    # "null") from `undefined` (`JSON.stringify(undefined)` returns the
    # JS value `undefined`, not a string). Perl has no such distinction

lib/BarefootJS.pm  view on Meta::CPAN

sub string ($self, $value) {
    # JS `String(v)` mirror. `undef` renders as the empty string here so
    # an unset prop doesn't surface as a literal "undefined" / "null"
    # in user-facing HTML — same divergence the Go adapter documents
    # for `bf_string`.
    return defined $value ? "$value" : '';
}

sub number ($self, $value) {
    # JS `Number(v)` mirror. Numeric coerces via Perl's implicit
    # numeric context; non-numeric / undef yield real numeric NaN
    # (`'nan' + 0`) so downstream arithmetic propagates correctly
    # (`Math.floor(NaN) === NaN`). Returning the literal string
    # "NaN" would conflate the user-passing-the-string-"NaN" case
    # with the parse-failure case, and break NaN detection in
    # downstream helpers.
    return 0 + 'nan' unless defined $value;
    return $value + 0 if looks_like_number($value);
    return 0 + 'nan';
}

# NaN is the only float for which `$x != $x` holds. Used as the
# portable sentinel check in floor/ceil/round.
sub _is_nan { my $n = shift; return $n != $n }

# True for +/-Infinity. `9**9**9` is Perl's portable infinity literal; a
# finite number is always strictly less than +Inf in magnitude.
sub _is_inf { my $n = shift; return $n == 9**9**9 || $n == -9**9**9 }

sub floor ($self, $value) {
    my $n = $self->number($value);
    return $n if _is_nan($n);

lib/BarefootJS.pm  view on Meta::CPAN

# ---------------------------------------------------------------------------
#
# `Array.prototype.includes(x)` and `String.prototype.includes(sub)`
# share a method name in JS; the JSX parser can't tell the two
# receiver shapes apart without TS type inference, so both lower to
# the same IR node (`array-method` / method `includes`). This helper
# dispatches at the Perl level via `ref()`:
#   - ARRAY ref:  scan elements with `BarefootJS::Evaluator::_same_value_zero`,
#                 matching `Array.prototype.includes`'s SameValueZero
#                 semantics (no cross-type coercion, e.g. `[2].includes("2")`
#                 is false; NaN matches NaN) — the same algorithm the
#                 evaluator's serialized-callback path already uses for
#                 `.includes`, so both positions agree. This used to be a
#                 stringy `eq` scan, which coerced numbers to strings
#                 (`[2].includes("2")` was true) and diverged from JS.
#   - scalar:     `index($recv, $sub) != -1`, with both args
#                 coerced through `// ''` so an undef receiver /
#                 needle doesn't trip Perl's substr warning.
# Anything else (HASH ref, code ref) returns false — matches the
# JS semantic where `.includes` is only defined on Array /
# TypedArray / String.

lib/BarefootJS.pm  view on Meta::CPAN

        else {
            push @out, $el;
        }
    }
    return \@out;
}

# `Array.prototype.flat(depth)` with a DYNAMIC depth (#2094) — the depth is
# itself an arbitrary runtime value (e.g. a prop), not a compile-time literal,
# so it must be coerced with JS's `ToIntegerOrInfinity` before delegating to
# `flat` above: truncate toward zero; negative -> 0; NaN / non-numeric -> 0;
# +Infinity or a huge finite value -> flatten fully.
#
# Deliberately a SEPARATE entry point from `flat`, not a smarter version of
# it: the literal-depth path's `-1` argument is a compile-time SENTINEL baked
# into the template source, meaning "the source literally said `Infinity`". A
# genuinely dynamic depth that happens to evaluate to `-1` at render time
# means the OPPOSITE in real JS (`[1,[2]].flat(-1)` never recurses — same as
# `.flat(0)`). Since both paths would otherwise hand the same literal-looking
# argument to one shared function, that function couldn't tell which case
# it's in — so the two stay separate. Mirrors Go's `FlatDynamicDepth` /

lib/BarefootJS.pm  view on Meta::CPAN

    return $self->flat($recv, _coerce_flat_depth($depth));
}

# _coerce_flat_depth: JS `ToIntegerOrInfinity` for a dynamic `.flat(depth)`
# argument, collapsed to `flat`'s int contract (`-1` == flatten fully).
#
# Perl's scalar type system blurs string/number duality, so `looks_like_number`
# (already the codebase's ToNumber-style coercion check, see BarefootJS::
# Evaluator's `_to_number`) is reused here rather than inventing a new
# convention. `looks_like_number` on this Perl (5.38) already recognises the
# strings "Infinity" / "-Infinity" / "NaN" as numeric (verified empirically:
# `perl -MScalar::Util=looks_like_number -e 'print looks_like_number("Infinity")'`
# prints 1), and `$str + 0` on those strings yields the corresponding Perl
# non-finite double, so no extra string special-casing is needed here.
sub _coerce_flat_depth ($depth) {
    return 0 unless defined $depth;
    my $f;
    if (ref($depth) eq 'JSON::PP::Boolean') {
        $f = $depth ? 1 : 0;
    }
    elsif (!ref($depth) && looks_like_number($depth)) {
        $f = $depth + 0;
    }
    else {
        # undef handled above; anything else non-numeric (a plain string
        # that isn't a number, a HASH/ARRAY ref, ...) coerces via NaN.
        return 0;
    }
    return 0 if $f != $f;              # NaN
    return -1 if $f == 9**9**9;        # +Infinity -> flatten fully sentinel
    return 0  if $f == -(9**9**9);     # -Infinity -> 0
    my $trunc = int($f);               # Perl's int() truncates toward zero
    return 0  if $trunc < 0;
    return -1 if $trunc > 1_000_000;   # huge finite ~= flatten fully
    return $trunc;
}

# `Array.prototype.flatMap(fn)` value-returning field projection
# (#1448 Tier C) — map each element through a self / field projection,

lib/BarefootJS.pm  view on Meta::CPAN

# `Number.prototype.toFixed(digits)` (#1897) — fixed-decimal string with
# zero-padding. JS rounds the scaled integer half toward +Infinity (the
# spec's "pick the larger n" tie-break), so `(2.5).toFixed(0)` is "3";
# bare `sprintf("%.*f")` would round half-to-even ("2"), diverging. Scale
# by 10**digits, round with `floor(x + 0.5)` (the same tie-break the
# `round` helper uses), then format the exact multiple. A negative
# `digits` clamps to 0, mirroring how the adapters default an omitted
# argument.
sub to_fixed ($self, $value, $digits = 0) {
    my $n = $self->number($value);
    # JS toFixed returns the STRINGS "NaN" / "Infinity" / "-Infinity" for
    # non-finite inputs; the numeric values would stringify per-platform
    # ("nan"/"inf"/...) and diverge.
    return 'NaN' if _is_nan($n);
    return $n < 0 ? '-Infinity' : 'Infinity' if _is_inf($n);
    $digits = 0 if !defined $digits || $digits < 0;
    my $factor  = 10 ** $digits;
    my $rounded = POSIX::floor($n * $factor + 0.5);
    return sprintf('%.*f', $digits, $rounded / $factor);
}

# `String.prototype.split(sep)` (#1448 Tier B) — string → ARRAY ref.
#
# Two JS-parity wrinkles drive the helper (a bare `split` emit would

lib/BarefootJS/DevReload.pm  view on Meta::CPAN

    return $content;
}

# The browser snippet: a small IIFE — EventSource subscriber + scrollY
# preservation across reloads. Idempotent across duplicate mounts (the
# window.__bfDevReload guard). Returns a plain HTML string; callers mark it raw
# for their template engine.
sub snippet ($class, $endpoint) {
    my $ep = _js_str($endpoint);
    my $sk = _js_str($SCROLL_STORAGE_KEY);
    return qq{<script>(function(){if(window.__bfDevReload)return;window.__bfDevReload=1;try{var s=sessionStorage.getItem($sk);if(s){sessionStorage.removeItem($sk);var y=parseInt(s,10);if(!isNaN(y)){var restore=function(){window.scrollTo(0,y)};if(docu...
}

# A ready-made PSGI app for the SSE endpoint. Streams `event: reload` whenever
# <dist>/.dev/build-id changes, with `: hb` heartbeats in between.
#
# Implemented with the PSGI streaming interface and a blocking poll loop, so it
# holds one worker per open connection for the connection's lifetime — run it
# under a prefork PSGI server (Starman / Starlet) in dev, which is the natural
# choice for an app that also streams (e.g. an AI-chat SSE route). DevReload is
# automatically a no-op unless you mount it, and you should only mount it in

lib/BarefootJS/Evaluator.pm  view on Meta::CPAN

        $i++;
    }
    return \@out;
}

# _join_array($obj, $sep): `Array.prototype.join` semantics — non-ARRAY
# receiver -> '' (the evaluator's degrade-rather-than-die convention,
# matching `.includes` above); a `null`/`undefined` (Perl `undef`) element
# joins as '' (not the string "null"); every other element goes through
# `_to_string` (JS `String(x)` — a JS boolean stringifies "true"/"false", a
# number "42"/"NaN"/"Infinity", etc.). Mirrors Go's evalJoin.
sub _join_array ($obj, $sep) {
    return '' unless ref $obj eq 'ARRAY';
    return join($sep, map { defined $_ ? _to_string($_) : '' } @$obj);
}

# eval_json($json, $env): decode a ParsedExpr JSON string and evaluate it.
# Mirrors the Go EvalExpr entry point. Requires JSON::PP only when used.
sub eval_json ($json, $env) {
    require JSON::PP;
    my $node = JSON::PP->new->decode($json);

lib/BarefootJS/Evaluator.pm  view on Meta::CPAN

        $t =~ s/\s+\z//;
        return 0 if $t eq '';
        return looks_like_number($t) ? ($t + 0) : _nan();
    }
    return $v + 0;
}

sub _to_string ($v) {
    return 'null' if !defined $v;
    if (ref $v eq 'JSON::PP::Boolean') { return $v ? 'true' : 'false' }
    # JS spells the non-finite doubles "Infinity" / "-Infinity" / "NaN";
    # Perl stringifies them "Inf" / "-Inf" / "NaN", so the non-finite cases
    # are pinned here to stay JS-faithful (and match the Go evaluator's
    # evalToString). Finite numbers and strings fall through to plain
    # interpolation.
    if (_is_number($v)) {
        my $n = $v + 0;
        return 'NaN'       if $n != $n;
        return 'Infinity'  if $n == 9**9**9;
        return '-Infinity' if $n == -(9**9**9);
    }
    return "$v";
}

sub _truthy ($v) {
    return 0 if !defined $v;
    if (ref $v eq 'JSON::PP::Boolean') { return $v ? 1 : 0 }
    return 1 if ref $v;    # arrays / objects are always truthy in JS
    if (_is_string($v)) { return $v ne '' ? 1 : 0 }    # incl. the truthy "0"
    my $n = $v + 0;
    return ($n != 0 && $n == $n) ? 1 : 0;              # nonzero and not NaN
}

# _bool: wrap a Perl truthy/falsy into a JS boolean (JSON::PP::Boolean), so
# boolean-valued operators (relational, ===, !, Boolean()) return a real
# boolean rather than 1/0 — matching the Go evaluator's bool (e.g.
# String(a < b) is "true", and `'x' + (a < b)` is "xtrue"). The coercions
# above already treat JSON::PP::Boolean correctly.
sub _bool ($t) { $t ? JSON::PP::true() : JSON::PP::false() }

# ---------------------------------------------------------------------------

lib/BarefootJS/Evaluator.pm  view on Meta::CPAN

        # numeric addition otherwise.
        return _to_string($l) . _to_string($r) if _is_string($l) || _is_string($r);
        return _to_number($l) + _to_number($r);
    }
    return _to_number($l) - _to_number($r) if $op eq '-';
    return _to_number($l) * _to_number($r) if $op eq '*';
    if ($op eq '/') {
        my $ln = _to_number($l);
        my $rn = _to_number($r);
        # JS division by zero is finite-valued, not an error: x/0 is ±Infinity
        # (NaN for 0/0). Perl's native `/` dies on a zero divisor, so guard it
        # to stay JS-faithful and match the Go evaluator (Go float division
        # already yields ±Inf / NaN).
        if ($rn == 0) {
            return _nan() if $ln == 0 || $ln != $ln;
            return $ln > 0 ? 9**9**9 : -(9**9**9);
        }
        return $ln / $rn;
    }
    if ($op eq '%') {
        my $rn = _to_number($r);
        return _nan() if $rn == 0;
        return POSIX::fmod(_to_number($l), $rn);
    }
    return _relational($op, $l, $r) if $op eq '<' || $op eq '<=' || $op eq '>' || $op eq '>=';
    return _bool(_strict_eq($l, $r))  if $op eq '===';
    return _bool(!_strict_eq($l, $r)) if $op eq '!==';
    # Loose equality / bitwise / shift are out of the subset.
    return undef;
}

sub _relational ($op, $l, $r) {
    # JS Abstract Relational Comparison: both strings → compare by code unit;
    # otherwise coerce both to numbers (a NaN operand makes it false).
    my $c;
    if (_is_string($l) && _is_string($r)) {
        $c = $l lt $r ? -1 : $l gt $r ? 1 : 0;
    }
    else {
        my $ln = _to_number($l);
        my $rn = _to_number($r);
        return _bool(0) if $ln != $ln || $rn != $rn;    # NaN → false
        $c = $ln < $rn ? -1 : $ln > $rn ? 1 : 0;
    }
    return _bool($c < 0)  if $op eq '<';
    return _bool($c <= 0) if $op eq '<=';
    return _bool($c > 0)  if $op eq '>';
    return _bool($c >= 0) if $op eq '>=';
    return _bool(0);
}

sub _strict_eq ($l, $r) {
    # Strict `===`: equal JS type and value, no coercion.
    my $ln = _is_number($l);
    my $rn = _is_number($r);
    if ($ln && $rn) {
        my ($lf, $rf) = ($l + 0, $r + 0);
        return 0 if $lf != $lf || $rf != $rf;    # NaN
        return $lf == $rf ? 1 : 0;
    }
    return 0 if $ln != $rn;    # one numeric, one not
    if (!defined $l) { return !defined $r ? 1 : 0 }
    return 0 if !defined $r;
    my $lb = ref $l eq 'JSON::PP::Boolean';
    my $rb = ref $r eq 'JSON::PP::Boolean';
    if ($lb || $rb) {
        return 0 unless $lb && $rb;
        return ((!!$l) == (!!$r)) ? 1 : 0;
    }
    return ($l eq $r ? 1 : 0) if _is_string($l) && _is_string($r);
    return 0;
}

# _same_value_zero: `Array.prototype.includes` membership test — `===`
# except `NaN` equals itself (and +0/-0 are not distinguished, which the
# JSON-decoded values here can't represent anyway). Reuses `_strict_eq`'s
# type/value rules and only special-cases the two-NaN case that `_strict_eq`
# (deliberately, for `===`) reports as unequal.
sub _same_value_zero ($l, $r) {
    if (_is_number($l) && _is_number($r)) {
        my ($lf, $rf) = ($l + 0, $r + 0);
        return 1 if $lf != $lf && $rf != $rf;    # NaN sameValueZero NaN
    }
    return _strict_eq($l, $r);
}

sub _unary ($op, $v) {
    return _bool(!_truthy($v)) if $op eq '!';
    return -_to_number($v) if $op eq '-';
    return _to_number($v)  if $op eq '+';
    return undef;
}

lib/BarefootJS/Evaluator.pm  view on Meta::CPAN

# matching the existing round helper rather than half-away-from-zero.
sub _math_round ($n) {
    return POSIX::floor($n + 0.5);
}

sub _call_builtin ($name, $args) {
    if ($name eq 'Math.max') {
        my $m = -(9**9**9);    # JS Math.max() with no args is -Infinity
        for my $a (@$args) {
            my $n = _to_number($a);
            return $n if $n != $n;    # any NaN argument ⇒ NaN (JS / Go)
            $m = $n if $n > $m;
        }
        return $m;
    }
    if ($name eq 'Math.min') {
        my $m = 9**9**9;       # JS Math.min() with no args is +Infinity
        for my $a (@$args) {
            my $n = _to_number($a);
            return $n if $n != $n;    # any NaN argument ⇒ NaN (JS / Go)
            $m = $n if $n < $m;
        }
        return $m;
    }
    return abs(_to_number($args->[0]))          if $name eq 'Math.abs';
    return POSIX::floor(_to_number($args->[0])) if $name eq 'Math.floor';
    return POSIX::ceil(_to_number($args->[0]))  if $name eq 'Math.ceil';
    return _math_round(_to_number($args->[0]))  if $name eq 'Math.round';
    return _to_string($args->[0])              if $name eq 'String';
    return _to_number($args->[0])              if $name eq 'Number';

lib/BarefootJS/Evaluator.pm  view on Meta::CPAN

    my %env = $base_env ? %$base_env : ();
    # Decorate each element with its original index and tie-break on it when
    # the comparator returns 0, so stability is explicit and independent of
    # the `sort` pragma / build (portable to the declared minimum Perl, and
    # matching Go's sort.SliceStable).
    my @decorated = map { [ $_, $items->[$_] ] } 0 .. $#$items;
    my @sorted = sort {
        $env{$param_a} = $a->[1];
        $env{$param_b} = $b->[1];
        my $c = _to_number(evaluate($cmp, \%env));
        # Explicit sign test rather than `<=> 0`: a NaN comparator result
        # warns / is undefined under `<=>`, whereas `< 0` / `> 0` are both
        # false for NaN, yielding 0 (no reordering) — matching JS (NaN
        # comparator ⇒ keep order) and the Go SortEval sign test. The
        # original-index tie-break then preserves input order for equal keys.
        ($c < 0 ? -1 : $c > 0 ? 1 : 0) || ($a->[0] <=> $b->[0])
    } @decorated;
    return [ map { $_->[1] } @sorted ];
}

# JSON entry points for the adapters: decode the callback body once, then fold /
# sort. Mirror the Go `bf_reduce_eval` / `bf_sort_eval` template funcs, which the
# adapters emit with a serialized-ParsedExpr body argument.

t/helper_vectors.t  view on Meta::CPAN


# _match: boolean form of the spec's value-compat comparison against a
# JSON-decoded expect — sentinel hashes, booleans by truthiness,
# numbers numerically, arrays/hashes recursively.
sub _match {
    my ($got, $expect) = @_;
    return !defined $got if !defined $expect;
    if (ref $expect eq 'HASH' && exists $expect->{'$num'}) {
        my $kind = $expect->{'$num'};
        return 0 unless defined $got && looks_like_number($got);
        return $got != $got ? 1 : 0 if $kind eq 'NaN';
        my $inf = 9**9**9;
        return $got == ($kind eq 'Infinity' ? $inf : -$inf) ? 1 : 0;
    }
    if (JSON::PP::is_bool($expect)) {
        return (!!$got eq !!$expect) ? 1 : 0;
    }
    if (ref $expect eq 'ARRAY') {
        return 0 unless ref $got eq 'ARRAY' && @$got == @$expect;
        _match($got->[$_], $expect->[$_]) or return 0 for 0 .. $#$expect;
        return 1;

t/template_primitives.t  view on Meta::CPAN

    is $bf->json(undef),     'null',    'undef → "null" (matches JS null; diverges from JS undefined)';
};

subtest 'string — JS String(v) mirror' => sub {
    is $bf->string(42),    '42', 'int';
    is $bf->string('hi'),  'hi', 'string passthrough';
    # Documented divergence from JS String(null) === "null".
    is $bf->string(undef), '',   'undef → "" (intentional divergence)';
};

# Real numeric NaN is the only float for which `$x != $x` holds.
# Tests check for it directly rather than string-comparing against
# "NaN", which stringifies platform-dependently.
sub is_nan { my $n = shift; return $n != $n }

subtest 'number — JS Number(v) mirror; NaN on parse failure' => sub {
    is $bf->number('3.14'), 3.14, 'numeric string';
    is $bf->number(42),     42,   'integer passthrough';
    ok is_nan($bf->number('not a num')), 'non-numeric → NaN';
    ok is_nan($bf->number(undef)),       'undef → NaN';
};

subtest 'floor / ceil / round — Math.* mirrors; propagate NaN' => sub {
    is $bf->floor(3.7),    3, '3.7 → 3';
    is $bf->floor(-3.2),  -4, '-3.2 → -4';
    ok is_nan($bf->floor('not')), 'floor: NaN propagates';

    is $bf->ceil(3.1),     4, '3.1 → 4';
    is $bf->ceil(-3.7),   -3, '-3.7 → -3';
    ok is_nan($bf->ceil('not')), 'ceil: NaN propagates';

    is $bf->round(3.5),    4, '3.5 → 4';
    is $bf->round(3.4),    3, '3.4 → 3';
    # JS `Math.round` ties go toward +Infinity, NOT away from zero —
    # so -1.5 rounds to -1 (not -2). Pin both halves of the negative
    # tie-break so a future POSIX::floor swap doesn't silently
    # regress the JS-compat contract.
    is $bf->round(-1.5),  -1, '-1.5 → -1 (JS half-toward-+Inf, not half-away-from-zero)';
    is $bf->round(-1.6),  -2, '-1.6 → -2';
    ok is_nan($bf->round('not')), 'round: NaN propagates';
};

# `Array.prototype.includes(x)` + `String.prototype.includes(sub)` lower
# to the same `$bf->includes($recv, $elem)` shape — see #1448 Tier A.
# The Perl helper dispatches on `ref()`: ARRAY ref scans elements with
# `BarefootJS::Evaluator::_same_value_zero` (SameValueZero — no cross-type
# coercion, NaN matches NaN), matching the evaluator's serialized-callback
# `.includes` path so both positions agree; scalar falls back to
# `index(..., ...) != -1`. Anything else (HASH ref, code ref) returns
# false to match the JS semantic that `.includes` is only defined on
# Array / TypedArray / String.
subtest 'includes — array + string + non-array/string dispatch' => sub {
    # Array receiver: SameValueZero element search (handles defined/undef
    # parity, and no numeric/string coercion — see the cross-type cases
    # below).
    ok  $bf->includes(['a', 'b', 'c'], 'b'), 'array contains element → 1';
    ok !$bf->includes(['a', 'b', 'c'], 'z'), 'array does not contain → 0';



( run in 3.005 seconds using v1.01-cache-2.11-cpan-9581c071862 )