@barefootjs/mojolicious 0.5.3 → 0.6.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
@@ -508,6 +508,71 @@ sub reverse ($self, $recv) {
508
508
  return [ reverse @$recv ];
509
509
  }
510
510
 
511
+ # `Array.prototype.flat(depth?)` (#1448 Tier C) — flatten nested ARRAY
512
+ # refs `$depth` levels deep. A `$depth` of -1 is the `Infinity` sentinel
513
+ # (flatten fully); 0 returns a shallow copy. Non-ARRAY elements are kept
514
+ # as-is (JS only flattens nested arrays). Non-ARRAY receiver → [].
515
+ sub flat ($self, $recv, $depth = 1) {
516
+ return [] unless ref($recv) eq 'ARRAY';
517
+ my @out;
518
+ for my $el (@$recv) {
519
+ if ($depth != 0 && ref($el) eq 'ARRAY') {
520
+ my $next = $depth > 0 ? $depth - 1 : $depth;
521
+ push @out, @{ $self->flat($el, $next) };
522
+ }
523
+ else {
524
+ push @out, $el;
525
+ }
526
+ }
527
+ return \@out;
528
+ }
529
+
530
+ # `Array.prototype.flatMap(fn)` value-returning field projection
531
+ # (#1448 Tier C) — map each element through a self / field projection,
532
+ # then flatten one level. `field` reads a HASH-ref key (the raw JS prop
533
+ # name, as `bf->reduce` does); a projected non-ARRAY value is kept as-is
534
+ # (flatMap = map + flat(1)). Non-ARRAY receiver → [].
535
+ sub flat_map ($self, $recv, $key_kind, $key) {
536
+ return [] unless ref($recv) eq 'ARRAY';
537
+ my @projected;
538
+ for my $el (@$recv) {
539
+ if ($key_kind eq 'field') {
540
+ # JS `i => i.field` on a non-object yields `undefined`, not the
541
+ # element itself — push `undef` so a scalar element doesn't leak
542
+ # into the output (matches Go's `getFieldValue` returning nil).
543
+ push @projected, ref($el) eq 'HASH' ? $el->{$key} : undef;
544
+ }
545
+ else {
546
+ push @projected, $el;
547
+ }
548
+ }
549
+ return $self->flat(\@projected, 1);
550
+ }
551
+
552
+ # `Array.prototype.flatMap(i => [i.a, i.b])` — array-literal tuple
553
+ # projection (#1448 Tier C). Each `@specs` entry is a [kind, key] arrayref
554
+ # (['self', ''] or ['field', 'a']). For each element, every leaf's value
555
+ # is appended in order. flat(1) removes only the literal wrapper, so an
556
+ # array-valued leaf is appended verbatim (no spread) — i.e. just append
557
+ # each leaf. A non-HASH element under a `field` leaf yields undef (JS
558
+ # `i.field` on a non-object). Non-ARRAY receiver → [].
559
+ sub flat_map_tuple ($self, $recv, @specs) {
560
+ return [] unless ref($recv) eq 'ARRAY';
561
+ my @out;
562
+ for my $el (@$recv) {
563
+ for my $spec (@specs) {
564
+ my ($kind, $key) = @$spec;
565
+ if ($kind eq 'field') {
566
+ push @out, ref($el) eq 'HASH' ? $el->{$key} : undef;
567
+ }
568
+ else {
569
+ push @out, $el;
570
+ }
571
+ }
572
+ }
573
+ return \@out;
574
+ }
575
+
511
576
  # `String.prototype.trim()` — strip leading + trailing whitespace.
512
577
  # JS's `String.prototype.trim` matches `\s` in the Unicode sense
513
578
  # (any whitespace including non-breaking space U+00A0); Perl's `\s`
@@ -526,6 +591,172 @@ sub trim ($self, $recv) {
526
591
  return $s;
527
592
  }
528
593
 
594
+ # `String.prototype.split(sep)` (#1448 Tier B) — string → ARRAY ref.
595
+ #
596
+ # Two JS-parity wrinkles drive the helper (a bare `split` emit would
597
+ # diverge from both JS and Go):
598
+ #
599
+ # * Perl's `split` treats its first argument as a *regex*, so a
600
+ # separator like '.' or '|' would match far too much. We
601
+ # `quotemeta` it to force literal-string matching, mirroring JS's
602
+ # string-separator semantics (the regex-separator form stays
603
+ # refused upstream — see the parser arm).
604
+ # * Perl's `split` drops trailing empty fields by default; JS keeps
605
+ # them (`"a,".split(",")` is `["a", ""]`). Passing the `-1` limit
606
+ # preserves them, matching JS and Go's `strings.Split`.
607
+ #
608
+ # An empty separator splits into individual characters (JS + Go agree).
609
+ # Undef receiver renders as the single-element `['']` — the same
610
+ # "missing prop → empty string" convention `bf->trim` uses.
611
+
612
+ sub split ($self, $recv, $sep = undef, $limit = undef) {
613
+ my $s = defined $recv && !ref($recv) ? "$recv" : '';
614
+
615
+ my @parts;
616
+ if (!defined $sep) {
617
+ # No separator → the whole string in a single-element array
618
+ # (matches JS `"x".split()` / `.split(undefined)`).
619
+ @parts = ($s);
620
+ }
621
+ elsif ("$sep" eq '') {
622
+ # Empty separator → individual characters. No `-1` limit here:
623
+ # on an empty pattern Perl's `split` with `-1` appends a spurious
624
+ # trailing empty field ("abc" → 'a','b','c',''), which JS/Go don't.
625
+ @parts = split //, $s;
626
+ }
627
+ elsif ($s eq '') {
628
+ # Empty input with a non-empty separator: JS `"".split(",")` is
629
+ # `[""]` and Go's `strings.Split("", ",")` is `[""]`, but Perl's
630
+ # `split /,/, ''` returns the empty list — special-case for parity.
631
+ @parts = ('');
632
+ }
633
+ else {
634
+ # `quotemeta` forces literal-string matching (JS string-separator
635
+ # semantics); the `-1` keeps trailing empty fields (JS keeps them,
636
+ # Perl's bare `split` drops them).
637
+ my $q = quotemeta("$sep");
638
+ @parts = split /$q/, $s, -1;
639
+ }
640
+
641
+ # Optional `limit` caps the number of pieces (JS `split(sep, limit)`).
642
+ # 0 → empty; a negative limit keeps all (JS ToUint32 wrap makes it
643
+ # effectively unbounded) — both match Go's `bf_split`.
644
+ if (defined $limit) {
645
+ my $n = int($limit);
646
+ if ($n == 0) { @parts = () }
647
+ elsif ($n > 0 && $n < scalar @parts) { @parts = @parts[0 .. $n - 1] }
648
+ }
649
+
650
+ return [@parts];
651
+ }
652
+
653
+ # `String.prototype.startsWith(prefix, position?)` (#1448 Tier B) —
654
+ # string → boolean (1 / 0). `substr`-anchored literal comparison mirrors
655
+ # Go's `strings.HasPrefix`. An empty prefix is always true (JS parity);
656
+ # undef / non-string receivers coerce to the empty string first. The
657
+ # optional `position` re-anchors the test (clamped to `[0, length]`),
658
+ # matching JS `"abc".startsWith("b", 1)`.
659
+
660
+ sub starts_with ($self, $recv, $prefix, $position = undef) {
661
+ my $s = defined $recv && !ref($recv) ? "$recv" : '';
662
+ my $p = defined $prefix ? "$prefix" : '';
663
+ if (defined $position) {
664
+ my $n = int($position);
665
+ $n = 0 if $n < 0;
666
+ $n = length($s) if $n > length($s);
667
+ $s = substr($s, $n);
668
+ }
669
+ return substr($s, 0, length $p) eq $p ? 1 : 0;
670
+ }
671
+
672
+ # `String.prototype.endsWith(suffix, endPosition?)` (#1448 Tier B) —
673
+ # string → boolean (1 / 0). Mirrors Go's `strings.HasSuffix`. An empty
674
+ # suffix is always true (JS parity); a suffix longer than the string is
675
+ # false. `substr($s, -length $x)` would mis-read the whole string when
676
+ # `length $x == 0`, so that case short-circuits. The optional
677
+ # `endPosition` treats the string as if it were only that many chars
678
+ # long (clamped to `[0, length]`), matching JS `"abc".endsWith("b", 2)`.
679
+
680
+ sub ends_with ($self, $recv, $suffix, $end_position = undef) {
681
+ my $s = defined $recv && !ref($recv) ? "$recv" : '';
682
+ my $x = defined $suffix ? "$suffix" : '';
683
+ if (defined $end_position) {
684
+ my $e = int($end_position);
685
+ $e = 0 if $e < 0;
686
+ $e = length($s) if $e > length($s);
687
+ $s = substr($s, 0, $e);
688
+ }
689
+ return 1 if $x eq '';
690
+ return 0 if length($s) < length($x);
691
+ return substr($s, -length $x) eq $x ? 1 : 0;
692
+ }
693
+
694
+ # `String.prototype.replace(pattern, replacement)` — string-pattern
695
+ # form only (#1448 Tier B), replacing the FIRST occurrence (JS string-
696
+ # pattern semantics). Spliced via index/substr rather than `s///` so
697
+ # BOTH the pattern and the replacement are literal: no Perl regex
698
+ # metacharacters in the pattern and no `$1` / `$&` interpolation in the
699
+ # replacement. Go's `bf_replace` (strings.Replace, n=1) treats the
700
+ # replacement literally too, so the two adapters stay byte-equal — this
701
+ # diverges from JS only for replacement strings containing `$`-patterns
702
+ # (rare in template position). An empty pattern inserts the replacement
703
+ # at the front (`"abc".replace("", "X")` → "Xabc"), matching JS + Go.
704
+
705
+ sub replace ($self, $recv, $pattern, $replacement) {
706
+ my $s = defined $recv && !ref($recv) ? "$recv" : '';
707
+ my $o = defined $pattern ? "$pattern" : '';
708
+ my $n = defined $replacement ? "$replacement" : '';
709
+ return $n . $s if $o eq '';
710
+ my $i = index($s, $o);
711
+ return $s if $i < 0;
712
+ return substr($s, 0, $i) . $n . substr($s, $i + length($o));
713
+ }
714
+
715
+ # `String.prototype.repeat(n)` — the receiver concatenated n times
716
+ # (#1448 Tier B), via Perl's `x` operator. JS throws RangeError for a
717
+ # negative count, but SSR templates degrade to the empty string rather
718
+ # than dying mid-render, so a count <= 0 returns "" (Go's `bf_repeat`
719
+ # applies the same clamp). The count is truncated toward zero
720
+ # (`int`), matching JS's ToIntegerOrInfinity on `"a".repeat(3.7)`.
721
+
722
+ sub repeat ($self, $recv, $count) {
723
+ my $s = defined $recv && !ref($recv) ? "$recv" : '';
724
+ my $n = defined $count ? int($count) : 0;
725
+ return $n <= 0 ? '' : $s x $n;
726
+ }
727
+
728
+ # `String.prototype.padStart` / `padEnd` (#1448 Tier B) — pad the
729
+ # receiver to `$target` characters with `$pad` (default a single space)
730
+ # repeated and truncated to fill, prepended or appended. Length is
731
+ # measured in characters (Perl `length`), matching Go's rune-based
732
+ # `bf_pad_*` — diverges from JS's UTF-16-unit length only for
733
+ # astral-plane input. An empty pad, or a receiver already >= `$target`,
734
+ # returns the receiver unchanged (JS parity). The `$target` is
735
+ # truncated toward zero (JS ToLength on the first arg).
736
+
737
+ sub _pad ($s, $target, $pad, $at_start) {
738
+ $pad = ' ' unless defined $pad;
739
+ $pad = "$pad";
740
+ return $s if $pad eq '';
741
+ my $len = length $s;
742
+ my $t = int($target // 0);
743
+ return $s if $len >= $t;
744
+ my $need = $t - $len;
745
+ # Repeat enough copies to cover $need, then trim to exactly $need.
746
+ my $fill = substr($pad x (int($need / length($pad)) + 1), 0, $need);
747
+ return $at_start ? $fill . $s : $s . $fill;
748
+ }
749
+
750
+ sub pad_start ($self, $recv, $target, $pad = undef) {
751
+ my $s = defined $recv && !ref($recv) ? "$recv" : '';
752
+ return _pad($s, $target, $pad, 1);
753
+ }
754
+
755
+ sub pad_end ($self, $recv, $target, $pad = undef) {
756
+ my $s = defined $recv && !ref($recv) ? "$recv" : '';
757
+ return _pad($s, $target, $pad, 0);
758
+ }
759
+
529
760
  # `Array.prototype.sort(cmp)` / `Array.prototype.toSorted(cmp)`
530
761
  # lowering (#1448 Tier B). Non-mutating — JS's mutate-vs-new
531
762
  # distinction is moot in SSR template context.
@@ -616,6 +847,63 @@ sub _compare_sort_key ($av, $bv, $compare_type) {
616
847
  return ($av // 0) <=> ($bv // 0); # numeric
617
848
  }
618
849
 
850
+ # Fold an array into a scalar via the arithmetic-fold catalogue
851
+ # (#1448 Tier C). Mirrors Go's `bf_reduce` and JS `reduce(fn, init)` /
852
+ # `reduceRight(fn, init)` for the shapes `(acc, x) => acc <op> x` /
853
+ # `(acc, x) => acc <op> x.field`:
854
+ #
855
+ # bf->reduce($recv, {
856
+ # op => '+' | '*',
857
+ # key_kind => 'self' | 'field',
858
+ # key => '<field>', # when key_kind eq 'field'
859
+ # type => 'numeric' | 'string',
860
+ # init => <seed>, # number, or string for concat
861
+ # direction => 'left' | 'right', # 'right' = reduceRight (default 'left')
862
+ # })
863
+ #
864
+ # Numeric folds accumulate with `+` / `*` (non-numeric keys coalesce to
865
+ # 0); string folds concatenate via `bf->string` (undef → ''). The init
866
+ # seeds the accumulator, so an empty array returns it unchanged — exactly
867
+ # like JS. `direction => 'right'` folds right-to-left (reduceRight); only
868
+ # observable for string concat, since numeric sum / product commute.
869
+ # Float stringification can diverge from Go's for inexact binary
870
+ # fractions (e.g. 0.1 + 0.2); integer sums — the common case — agree.
871
+ sub reduce ($self, $recv, $opts = {}) {
872
+ my $op = $opts->{op} // '+';
873
+ my $key_kind = $opts->{key_kind} // 'self';
874
+ my $key = $opts->{key} // '';
875
+ my $type = $opts->{type} // 'numeric';
876
+ my $direction = $opts->{direction} // 'left';
877
+
878
+ my @items = ref($recv) eq 'ARRAY' ? @$recv : ();
879
+ # reduceRight folds right-to-left; reversing the snapshot keeps the
880
+ # single forward loop below. Only observable for string concat —
881
+ # numeric sum / product commute. Qualify as CORE::reverse — this
882
+ # package defines `sub reverse` (the `.reverse()` helper), so a bare
883
+ # `reverse` is ambiguous under `use warnings`.
884
+ @items = CORE::reverse(@items) if $direction eq 'right';
885
+ my $project = sub ($item) {
886
+ $key_kind eq 'field' && ref($item) eq 'HASH' ? $item->{$key} : $item;
887
+ };
888
+
889
+ if ($type eq 'string') {
890
+ my $acc = $opts->{init} // '';
891
+ $acc .= $self->string($project->($_)) for @items;
892
+ return $acc;
893
+ }
894
+
895
+ my $acc = $opts->{init} // 0;
896
+ for my $item (@items) {
897
+ my $n = $project->($item);
898
+ # Guard `defined` before `looks_like_number` so a missing field
899
+ # (undef) folds as 0 without an "uninitialized value" warning
900
+ # under `use warnings` — matching the `$av // ''` style `sort` uses.
901
+ $n = 0 unless defined $n && looks_like_number($n);
902
+ $op eq '*' ? ($acc *= $n) : ($acc += $n);
903
+ }
904
+ return $acc;
905
+ }
906
+
619
907
  # ---------------------------------------------------------------------------
620
908
  # JSX intrinsic-element spread (#1407)
621
909
  # ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/mojolicious",
3
- "version": "0.5.3",
3
+ "version": "0.6.1",
4
4
  "description": "Mojolicious EP template adapter for BarefootJS - generates .html.ep files from IR",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -52,14 +52,14 @@
52
52
  "directory": "packages/adapter-mojolicious"
53
53
  },
54
54
  "dependencies": {
55
- "@barefootjs/shared": "0.5.3"
55
+ "@barefootjs/shared": "0.6.1"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@barefootjs/jsx": ">=0.2.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@barefootjs/adapter-tests": "0.1.0",
62
- "@barefootjs/jsx": "0.5.3",
62
+ "@barefootjs/jsx": "0.6.1",
63
63
  "typescript": "^5.0.0"
64
64
  }
65
65
  }
@@ -395,9 +395,16 @@ export function C() {
395
395
  // The nested-higher-order-in-filter-predicate shape also lowers
396
396
  // now (#1443 PR4) — moved to a positive-output test below.
397
397
  const cases: { name: string; body: string; needle: string }[] = [
398
- { name: 'reduce', body: `<div>{items().reduce((s, x) => s + x, 0)}</div>`, needle: '.reduce(' },
398
+ // The arithmetic-fold `.reduce(fn, init)` catalogue now lowers
399
+ // (positive-output tests below + the reduce-* conformance
400
+ // fixtures); the no-init form stays refused — JS throws on an
401
+ // empty array there, which a template can't mirror.
402
+ { name: 'reduce (no init)', body: `<div>{items().reduce((s, x) => s + x)}</div>`, needle: '.reduce(' },
399
403
  { name: 'forEach', body: `<ul>{items().forEach(x => x)}</ul>`, needle: '.forEach(' },
400
- { name: 'flatMap', body: `<ul>{items().flatMap(x => x.tags).map(t => <li key={t}>{t}</li>)}</ul>`, needle: '.flatMap(' },
404
+ // Self / field / field-tuple `.flatMap` now lowers (#1448 Tier C)
405
+ // even as a loop base — so those moved to positive tests below. A
406
+ // tuple with a non-leaf element (a string literal) stays refused.
407
+ { name: 'flatMap (literal element)', body: `<div>{items().flatMap(x => [x.tag, "x"])}</div>`, needle: '.flatMap(' },
401
408
  ]
402
409
 
403
410
  for (const { name, body, needle } of cases) {
@@ -928,11 +935,26 @@ import { fixture as arrayLastIndexOfFixture } from '../../../adapter-tests/fixtu
928
935
  import { fixture as arrayAtFixture } from '../../../adapter-tests/fixtures/methods/array-at'
929
936
  import { fixture as arrayConcatFixture } from '../../../adapter-tests/fixtures/methods/array-concat'
930
937
  import { fixture as arraySliceFixture } from '../../../adapter-tests/fixtures/methods/array-slice'
938
+ import { fixture as arraySliceCopyFixture } from '../../../adapter-tests/fixtures/methods/array-slice-copy'
939
+ import { fixture as arrayJoinDefaultFixture } from '../../../adapter-tests/fixtures/methods/array-join-default'
940
+ import { fixture as arrayAtDefaultFixture } from '../../../adapter-tests/fixtures/methods/array-at-default'
941
+ import { fixture as arrayConcatCopyFixture } from '../../../adapter-tests/fixtures/methods/array-concat-copy'
931
942
  import { fixture as arrayReverseFixture } from '../../../adapter-tests/fixtures/methods/array-reverse'
932
943
  import { fixture as arrayToReversedFixture } from '../../../adapter-tests/fixtures/methods/array-toReversed'
933
944
  import { fixture as stringToLowerCaseFixture } from '../../../adapter-tests/fixtures/methods/string-toLowerCase'
934
945
  import { fixture as stringToUpperCaseFixture } from '../../../adapter-tests/fixtures/methods/string-toUpperCase'
935
946
  import { fixture as stringTrimFixture } from '../../../adapter-tests/fixtures/methods/string-trim'
947
+ // #1448 Tier B — string methods.
948
+ import { fixture as stringSplitFixture } from '../../../adapter-tests/fixtures/methods/string-split'
949
+ import { fixture as stringSplitLimitFixture } from '../../../adapter-tests/fixtures/methods/string-split-limit'
950
+ import { fixture as stringStartsWithFixture } from '../../../adapter-tests/fixtures/methods/string-startsWith'
951
+ import { fixture as stringStartsWithPositionFixture } from '../../../adapter-tests/fixtures/methods/string-startsWith-position'
952
+ import { fixture as stringEndsWithFixture } from '../../../adapter-tests/fixtures/methods/string-endsWith'
953
+ import { fixture as stringEndsWithPositionFixture } from '../../../adapter-tests/fixtures/methods/string-endsWith-position'
954
+ import { fixture as stringReplaceFixture } from '../../../adapter-tests/fixtures/methods/string-replace'
955
+ import { fixture as stringRepeatFixture } from '../../../adapter-tests/fixtures/methods/string-repeat'
956
+ import { fixture as stringPadStartFixture } from '../../../adapter-tests/fixtures/methods/string-padStart'
957
+ import { fixture as stringPadEndFixture } from '../../../adapter-tests/fixtures/methods/string-padEnd'
936
958
  // #1448 Tier B — .sort / .toSorted fixtures (loop-chained + standalone).
937
959
  import { fixture as arraySortFieldAscFixture } from '../../../adapter-tests/fixtures/methods/array-sort-field-asc'
938
960
  import { fixture as arraySortFieldDescFixture } from '../../../adapter-tests/fixtures/methods/array-sort-field-desc'
@@ -945,6 +967,12 @@ import { fixture as arrayToSortedFixture } from '../../../adapter-tests/fixtures
945
967
  import { fixture as arrayEntriesFixture } from '../../../adapter-tests/fixtures/methods/array-entries'
946
968
  import { fixture as arrayKeysFixture } from '../../../adapter-tests/fixtures/methods/array-keys'
947
969
  import { fixture as arrayValuesFixture } from '../../../adapter-tests/fixtures/methods/array-values'
970
+ // #1448 Tier C — .reduce(fn, init) arithmetic-fold catalogue.
971
+ import { fixture as reduceSumFieldFixture } from '../../../adapter-tests/fixtures/methods/reduce-sum-field'
972
+ import { fixture as reduceSumSelfFixture } from '../../../adapter-tests/fixtures/methods/reduce-sum-self'
973
+ import { fixture as reduceConcatFixture } from '../../../adapter-tests/fixtures/methods/reduce-concat'
974
+ import { fixture as reduceProductFixture } from '../../../adapter-tests/fixtures/methods/reduce-product'
975
+ import { fixture as reduceRightConcatFixture } from '../../../adapter-tests/fixtures/methods/reduce-right-concat'
948
976
 
949
977
  describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
950
978
  const cases = [
@@ -955,6 +983,12 @@ describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
955
983
  { fixture: arrayAtFixture, expect: 'bf->at($items, -1)' },
956
984
  { fixture: arrayConcatFixture, expect: 'bf->concat($left, $right)' },
957
985
  { fixture: arraySliceFixture, expect: 'bf->slice($items, 1, 3)' },
986
+ // #1448 full-arity — zero-arg defaults.
987
+ { fixture: arraySliceCopyFixture, expect: 'bf->slice($items, 0, undef)' },
988
+ { fixture: arrayJoinDefaultFixture, expect: `join(',', @{$items})` },
989
+ // `.at()` → index 0; `.concat()` → the receiver (shallow copy).
990
+ { fixture: arrayAtDefaultFixture, expect: 'bf->at($items, 0)' },
991
+ { fixture: arrayConcatCopyFixture, expect: `join('|', @{$items})` },
958
992
  { fixture: arrayReverseFixture, expect: 'bf->reverse($items)' },
959
993
  // .toReversed shares the helper with .reverse — pinning both
960
994
  // routings catches a future divergence between them.
@@ -962,6 +996,23 @@ describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
962
996
  { fixture: stringToLowerCaseFixture,expect: 'lc($value)' },
963
997
  { fixture: stringToUpperCaseFixture,expect: 'uc($value)' },
964
998
  { fixture: stringTrimFixture, expect: 'bf->trim($value)' },
999
+ // #1448 Tier B — string → array. `.split(',')` lowers to
1000
+ // `bf->split`, here chained into `.join('|')` so the array ref is
1001
+ // observable (`join('|', @{bf->split($value, ',')})`).
1002
+ { fixture: stringSplitFixture, expect: `bf->split($value, ',')` },
1003
+ { fixture: stringSplitLimitFixture, expect: `bf->split($value, ',', 2)` },
1004
+ // #1448 Tier B — string → boolean at condition position (`% if`).
1005
+ { fixture: stringStartsWithFixture, expect: 'bf->starts_with($value, $prefix)' },
1006
+ { fixture: stringStartsWithPositionFixture, expect: `bf->starts_with($value, 'world', 6)` },
1007
+ { fixture: stringEndsWithFixture, expect: 'bf->ends_with($value, $suffix)' },
1008
+ { fixture: stringEndsWithPositionFixture, expect: `bf->ends_with($value, 'hello', 5)` },
1009
+ // #1448 Tier B — string → string, first-occurrence replace.
1010
+ { fixture: stringReplaceFixture, expect: `bf->replace($value, 'o', '0')` },
1011
+ // #1448 Tier B — string → string, repeat n times.
1012
+ { fixture: stringRepeatFixture, expect: 'bf->repeat($value, 3)' },
1013
+ // #1448 Tier B — string → string, padded to a target width.
1014
+ { fixture: stringPadStartFixture, expect: `bf->pad_start($value, 5, '0')` },
1015
+ { fixture: stringPadEndFixture, expect: `bf->pad_end($value, 5, '.')` },
965
1016
  // #1448 Tier B — sort / toSorted. The loop-chained field cases
966
1017
  // hoist into a `my $bf_iter_lN = bf->sort(...)` local; the
967
1018
  // standalone primitive cases inline the call. Each comparison key
@@ -982,6 +1033,17 @@ describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
982
1033
  { fixture: arrayKeysFixture, expect: '% for my $k (0..$#{$items})' },
983
1034
  // .values() → standard for loop (same as plain .map())
984
1035
  { fixture: arrayValuesFixture, expect: '% my $v = $items->[$_i];' },
1036
+ // #1448 Tier C — .reduce(fn, init) arithmetic fold. The structured
1037
+ // ReduceOp lowers to a single `bf->reduce(...)` call with op /
1038
+ // key / type / init / direction in the options hash. Each shape
1039
+ // exercises one arm: field-numeric sum, self-numeric sum,
1040
+ // string-concat fold, the product (`*`) operator, and the
1041
+ // right-to-left `direction` of reduceRight.
1042
+ { fixture: reduceSumFieldFixture, expect: `bf->reduce($items, { op => '+', key_kind => 'field', key => 'duration', type => 'numeric', init => 0, direction => 'left' })` },
1043
+ { fixture: reduceSumSelfFixture, expect: `bf->reduce($nums, { op => '+', key_kind => 'self', type => 'numeric', init => 0, direction => 'left' })` },
1044
+ { fixture: reduceConcatFixture, expect: `bf->reduce($items, { op => '+', key_kind => 'field', key => 'label', type => 'string', init => '', direction => 'left' })` },
1045
+ { fixture: reduceProductFixture, expect: `bf->reduce($items, { op => '*', key_kind => 'field', key => 'qty', type => 'numeric', init => 1, direction => 'left' })` },
1046
+ { fixture: reduceRightConcatFixture, expect: `bf->reduce($items, { op => '+', key_kind => 'field', key => 'label', type => 'string', init => '', direction => 'right' })` },
985
1047
  ]
986
1048
 
987
1049
  for (const { fixture, expect: expectedHelper } of cases) {
@@ -1003,6 +1065,73 @@ describe('MojoAdapter - #1448 Tier A/B fixture-driven lowering pins', () => {
1003
1065
  }
1004
1066
  })
1005
1067
 
1068
+ describe('MojoAdapter - #1448 Tier C .flat(depth?)', () => {
1069
+ function emitFlat(expr: string): string {
1070
+ const a = new MojoAdapter()
1071
+ const ir = compileToIR(`
1072
+ function C({ rows }: { rows: number[][] }) {
1073
+ return <div>{${expr}}</div>
1074
+ }
1075
+ export { C }
1076
+ `, a)
1077
+ return a.generate(ir).template ?? ''
1078
+ }
1079
+
1080
+ test('.flat() emits bf->flat with default depth 1', () => {
1081
+ expect(emitFlat('rows.flat()')).toContain('bf->flat($rows, 1)')
1082
+ })
1083
+
1084
+ test('.flat(2) emits the explicit depth', () => {
1085
+ expect(emitFlat('rows.flat(2)')).toContain('bf->flat($rows, 2)')
1086
+ })
1087
+
1088
+ test('.flat(Infinity) emits the -1 full-depth sentinel', () => {
1089
+ expect(emitFlat('rows.flat(Infinity)')).toContain('bf->flat($rows, -1)')
1090
+ })
1091
+ })
1092
+
1093
+ describe('MojoAdapter - #1448 Tier C .flatMap(field projection)', () => {
1094
+ function emitFlatMap(expr: string): string {
1095
+ const a = new MojoAdapter()
1096
+ const ir = compileToIR(`
1097
+ function C({ rows }: { rows: { a: string; b: string; tags: string[] }[] }) {
1098
+ return <div>{${expr}}</div>
1099
+ }
1100
+ export { C }
1101
+ `, a)
1102
+ return a.generate(ir).template ?? ''
1103
+ }
1104
+
1105
+ test('.flatMap(i => i.field) emits bf->flat_map with the raw field key', () => {
1106
+ expect(emitFlatMap('rows.flatMap(i => i.tags).join(" ")')).toContain(`bf->flat_map($rows, 'field', 'tags')`)
1107
+ })
1108
+
1109
+ test('.flatMap(i => i) emits the self projection', () => {
1110
+ expect(emitFlatMap('rows.flatMap(i => i).join(" ")')).toContain(`bf->flat_map($rows, 'self', '')`)
1111
+ })
1112
+
1113
+ test('.flatMap(i => [i.a, i.b]) emits bf->flat_map_tuple with leaf specs', () => {
1114
+ expect(emitFlatMap('rows.flatMap(i => [i.a, i.b]).join(" ")')).toContain(`bf->flat_map_tuple($rows, ['field', 'a'], ['field', 'b'])`)
1115
+ })
1116
+
1117
+ test('tuple self + field leaves', () => {
1118
+ expect(emitFlatMap('rows.flatMap(i => [i, i.a]).join(" ")')).toContain(`bf->flat_map_tuple($rows, ['self', ''], ['field', 'a'])`)
1119
+ })
1120
+
1121
+ test('field-projection flatMap as a loop base lowers (no BF101)', () => {
1122
+ const a = new MojoAdapter()
1123
+ const ir = compileToIR(`'use client'
1124
+ import { createSignal } from '@barefootjs/client'
1125
+ export function C() {
1126
+ const [items] = createSignal<{ tags: string[] }[]>([])
1127
+ return <ul>{items().flatMap(x => x.tags).map(t => <li key={t}>{t}</li>)}</ul>
1128
+ }`, a)
1129
+ const template = a.generate(ir).template ?? ''
1130
+ expect((a.errors ?? []).filter(e => e.code === 'BF101')).toEqual([])
1131
+ expect(template).toContain(`bf->flat_map($items, 'field', 'tags')`)
1132
+ })
1133
+ })
1134
+
1006
1135
  // =============================================================================
1007
1136
  // #1448 — `/* @client */` escape hatch for STILL-UNSUPPORTED methods
1008
1137
  // =============================================================================
@@ -1061,19 +1190,29 @@ export function C() {
1061
1190
  // Perl fragment that must NOT survive into the template (the pre-fix
1062
1191
  // silent-footgun output for the string rows).
1063
1192
  const unsupported: Array<{ name: string; expr: string; badEmit: string }> = [
1064
- // Tier C array methods.
1065
- { name: 'reduce', expr: `items().reduce((a, b) => a + b.n, 0)`, badEmit: '->{reduce}' },
1066
- { name: 'flatMap', expr: `items().flatMap(i => i.tags)`, badEmit: '->{flatMap}' },
1067
- { name: 'flat', expr: `items().flat()`, badEmit: '->{flat}' },
1193
+ // Tier C array methods. The arithmetic-fold `.reduce(fn, init)`
1194
+ // catalogue now lowers (pinned in the positive reduce-* fixtures);
1195
+ // the no-initial-value form stays refused JS throws on an empty
1196
+ // array there, which a template can't mirror.
1197
+ { name: 'reduce (no init)', expr: `items().reduce((a, b) => a + b.n)`, badEmit: '->{reduce}' },
1198
+ // Self / field / field-tuple `.flatMap` now lowers (#1448 Tier C); a
1199
+ // tuple with a non-leaf element (here a string literal) stays refused.
1200
+ { name: 'flatMap (literal element)', expr: `items().flatMap(i => [i.name, "x"])`, badEmit: '->{flatMap}' },
1201
+ // Lowered methods whose MEANINGFUL extra argument isn't lowered yet
1202
+ // (#1448): the `fromIndex` of `.includes`/`.indexOf`/`.lastIndexOf`
1203
+ // and the variadic `.concat`. The parser refuses these (silently
1204
+ // dropping the arg would change the result). (The zero-arg defaults
1205
+ // `.join()`/`.slice()` and JS-ignored trailing args like `.trim(1)`
1206
+ // are accepted — pinned in the positive blocks.)
1207
+ { name: 'includes (2-arg fromIndex)', expr: `items().includes("a", 1)`, badEmit: '->{includes}' },
1208
+ { name: 'concat (variadic)', expr: `items().concat(items(), items())`, badEmit: '->{concat}' },
1068
1209
  // Tier B/C string methods — previously slipped through with no
1069
- // diagnostic; now routed through the AST / `isSupported` gate.
1070
- { name: 'split', expr: `name().split(",")`, badEmit: '->{split}' },
1071
- { name: 'startsWith', expr: `name().startsWith("a")`, badEmit: '->{startsWith}' },
1072
- { name: 'endsWith', expr: `name().endsWith("z")`, badEmit: '->{endsWith}' },
1073
- { name: 'replace', expr: `name().replace("a", "b")`, badEmit: '->{replace}' },
1074
- { name: 'repeat', expr: `name().repeat(3)`, badEmit: '->{repeat}' },
1075
- { name: 'padStart', expr: `name().padStart(5, "0")`, badEmit: '->{padStart}' },
1076
- { name: 'padEnd', expr: `name().padEnd(5, "0")`, badEmit: '->{padEnd}' },
1210
+ // diagnostic; now routed through the AST / `isSupported` gate. The
1211
+ // full Tier B string set (`split`, `startsWith`, `endsWith`,
1212
+ // `replace`, `repeat`, `padStart`, `padEnd`) has since landed its
1213
+ // full-arity lowering and moved to the positive fixture-pin block
1214
+ // above (the regex-pattern `replace` form stays refused — pinned
1215
+ // separately below). `charAt` is Tier C and stays refused entirely.
1077
1216
  { name: 'charAt', expr: `name().charAt(0)`, badEmit: '->{charAt}' },
1078
1217
  ]
1079
1218
  for (const { name, expr, badEmit } of unsupported) {
@@ -1118,22 +1257,41 @@ export function C(props: { config: string }) {
1118
1257
  })
1119
1258
 
1120
1259
  // Predicate-level use of an unsupported string method also fails the
1121
- // build loudly (intended): a `.filter(t => t.name.startsWith("a"))`
1260
+ // build loudly (intended): a `.filter(t => t.name.charAt(0) === "a")`
1122
1261
  // whose predicate calls one of the gated methods now refuses the whole
1123
1262
  // loop with BF101 (via the shared `isSupported` predicate gate in
1124
- // jsx-to-ir) rather than lowering to a broken `->{startsWith}` inside
1263
+ // jsx-to-ir) rather than lowering to a broken `->{charAt}` inside
1125
1264
  // the grep. Pinning this so the loud-failure contract can't silently
1126
- // regress back to the old emit-broken-template behaviour.
1265
+ // regress back to the old emit-broken-template behaviour. (`charAt`
1266
+ // is a Tier C method that stays refused — earlier this test used
1267
+ // `startsWith`, which has since landed its Tier B lowering.)
1127
1268
  test('unsupported string method inside a .filter() predicate raises BF101', () => {
1128
1269
  const result = compileJSX(`
1129
1270
  "use client"
1130
1271
  import { createSignal } from "@barefootjs/client"
1131
1272
  export function C() {
1132
1273
  const [items, setItems] = createSignal<{ name: string }[]>([])
1133
- return <ul>{items().filter(t => t.name.startsWith("a")).map(t => <li key={t.name}>{t.name}</li>)}</ul>
1274
+ return <ul>{items().filter(t => t.name.charAt(0) === "a").map(t => <li key={t.name}>{t.name}</li>)}</ul>
1275
+ }
1276
+ `.trimStart(), 'test.tsx', { adapter: new MojoAdapter() })
1277
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
1278
+ })
1279
+
1280
+ // The string-pattern form of `.replace` lowers (#1448 Tier B), but
1281
+ // the regex-pattern form stays refused with BF101 — the Perl `s///`
1282
+ // vs Go `regexp.ReplaceAllString` flavour gap is the open design
1283
+ // question. Pinning the refusal so it can't regress into a broken
1284
+ // `->{replace}` emit for the regex form.
1285
+ test('regex-pattern .replace raises BF101 (string-pattern form is lowered)', () => {
1286
+ const result = compileJSX(`
1287
+ function C({ value }: { value: string }) {
1288
+ return <div>{value.replace(/o/g, "0")}</div>
1134
1289
  }
1290
+ export { C }
1135
1291
  `.trimStart(), 'test.tsx', { adapter: new MojoAdapter() })
1136
1292
  expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
1293
+ const template = result.files?.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
1294
+ expect(template).not.toContain('->{replace}')
1137
1295
  })
1138
1296
 
1139
1297
  // Tier B `.sort` / `.toSorted` follow-ups still refused with BF021.
@@ -1172,7 +1330,9 @@ export function C() {
1172
1330
  // more `HASH ref` crash), so we assert the build error rather than a
1173
1331
  // render crash. Skipped on hosts without Mojolicious installed.
1174
1332
  test('e2e: @client renders placeholder; bare is caught at build with BF101', async () => {
1175
- const bare = emit(`name().repeat(3)`, false)
1333
+ // Uses the Tier C `charAt` (still refused) — earlier this test used
1334
+ // `repeat`, which has since landed its #1448 Tier B lowering.
1335
+ const bare = emit(`name().charAt(0)`, false)
1176
1336
  expect(bare.errors.some(e => e.code === 'BF101')).toBe(true)
1177
1337
 
1178
1338
  try {
@@ -1182,7 +1342,7 @@ export function C() {
1182
1342
  import { createSignal } from "@barefootjs/client"
1183
1343
  export function C() {
1184
1344
  const [name, setName] = createSignal("hello")
1185
- return <div>{/* @client */ name().repeat(3)}</div>
1345
+ return <div>{/* @client */ name().charAt(0)}</div>
1186
1346
  }
1187
1347
  `.trimStart(),
1188
1348
  adapter: new MojoAdapter(),