@barefootjs/jsx 0.6.0 → 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.
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect } from 'bun:test'
2
2
  import ts from 'typescript'
3
- import { parseExpression, isSupported, exprToString, parseBlockBody } from '../expression-parser'
3
+ import { parseExpression, isSupported, exprToString, stringifyParsedExpr, parseBlockBody } from '../expression-parser'
4
4
  import { collectAllTypeRanges, reconstructWithoutTypes } from '../strip-types'
5
5
 
6
6
  describe('expression-parser', () => {
@@ -1512,3 +1512,111 @@ describe('expression-parser — array-method full-arity lowering (#1448)', () =>
1512
1512
  })
1513
1513
  }
1514
1514
  })
1515
+
1516
+ describe('expression-parser — .flat(depth?) lowering (#1448 Tier C)', () => {
1517
+ // Depth literals normalise into a structured `flatDepth` at parse time.
1518
+ const depths: Array<[string, string, number | 'infinity']> = [
1519
+ ['.flat() → depth 1', 'arr.flat()', 1],
1520
+ ['.flat(2) → depth 2', 'arr.flat(2)', 2],
1521
+ ['.flat(Infinity) → "infinity"', 'arr.flat(Infinity)', 'infinity'],
1522
+ ['.flat(0) → depth 0 (shallow copy)', 'arr.flat(0)', 0],
1523
+ ['.flat(-1) → normalised to 0 (JS clamps)', 'arr.flat(-1)', 0],
1524
+ ['.flat(1.9) → truncated to 1', 'arr.flat(1.9)', 1],
1525
+ ]
1526
+ for (const [label, expr, expected] of depths) {
1527
+ test(label, () => {
1528
+ const result = parseExpression(expr)
1529
+ expect(result.kind).toBe('array-method')
1530
+ if (result.kind === 'array-method' && result.method === 'flat') {
1531
+ expect(result.flatDepth).toBe(expected)
1532
+ } else {
1533
+ throw new Error(`expected a flat array-method, got ${result.kind}`)
1534
+ }
1535
+ })
1536
+ }
1537
+
1538
+ test('non-literal depth refuses (must be resolvable at template time)', () => {
1539
+ const result = parseExpression('arr.flat(n)')
1540
+ expect(result.kind).toBe('unsupported')
1541
+ if (result.kind === 'unsupported') {
1542
+ // Wrong remedy `@client` must not be suggested (doesn't work in
1543
+ // attribute / condition position).
1544
+ expect(result.reason).not.toContain('@client')
1545
+ expect(result.reason).toContain('literal')
1546
+ }
1547
+ })
1548
+
1549
+ // exprToString (diagnostics / debug output) must preserve the normalised
1550
+ // depth, not collapse every form to `.flat()`.
1551
+ test('exprToString round-trips the normalised depth', () => {
1552
+ expect(exprToString(parseExpression('arr.flat()'))).toBe('arr.flat()')
1553
+ expect(exprToString(parseExpression('arr.flat(2)'))).toBe('arr.flat(2)')
1554
+ expect(exprToString(parseExpression('arr.flat(Infinity)'))).toBe('arr.flat(Infinity)')
1555
+ expect(exprToString(parseExpression('arr.flat(0)'))).toBe('arr.flat(0)')
1556
+ })
1557
+ })
1558
+
1559
+ describe('expression-parser — .flatMap(fn) projection (#1448 Tier C)', () => {
1560
+ // Accepted catalogue: self, single field, and array-literal tuples of
1561
+ // self / field leaves.
1562
+ type Proj = { kind: 'self' } | { kind: 'field'; field: string } | { kind: 'tuple'; elements: unknown[] }
1563
+ const accepted: Array<[string, string, Proj]> = [
1564
+ ['self (i => i)', 'arr.flatMap(i => i)', { kind: 'self' }],
1565
+ ['field (i => i.tags)', 'arr.flatMap(i => i.tags)', { kind: 'field', field: 'tags' }],
1566
+ ['single-return block body', 'arr.flatMap(i => { return i.tags })', { kind: 'field', field: 'tags' }],
1567
+ ['tuple of fields', 'arr.flatMap(i => [i.a, i.b])', { kind: 'tuple', elements: [{ kind: 'field', field: 'a' }, { kind: 'field', field: 'b' }] }],
1568
+ ['tuple self + field', 'arr.flatMap(i => [i, i.tags])', { kind: 'tuple', elements: [{ kind: 'self' }, { kind: 'field', field: 'tags' }] }],
1569
+ ]
1570
+ for (const [label, expr, projection] of accepted) {
1571
+ test(`${label} — lowers to a flatMap array-method`, () => {
1572
+ const result = parseExpression(expr)
1573
+ expect(result.kind).toBe('array-method')
1574
+ if (result.kind === 'array-method' && result.method === 'flatMap') {
1575
+ expect(result.flatMapOp.projection).toEqual(projection)
1576
+ } else {
1577
+ throw new Error(`expected a flatMap array-method, got ${result.kind}`)
1578
+ }
1579
+ })
1580
+ }
1581
+
1582
+ // Out of catalogue → refused (BF101 + @client hint).
1583
+ const refused = [
1584
+ ['deep field access', 'arr.flatMap(i => i.a.b)'],
1585
+ ['index/array callback params', 'arr.flatMap((i, idx) => i.tags)'],
1586
+ // Tuple with a non-leaf element (arithmetic / literal / deep access).
1587
+ ['tuple with arithmetic element', 'arr.flatMap(i => [i.a, i.b + 1])'],
1588
+ ['tuple with literal element', 'arr.flatMap(i => [i.a, "x"])'],
1589
+ ['tuple with deep access', 'arr.flatMap(i => [i.a, i.b.c])'],
1590
+ ['tuple with spread', 'arr.flatMap(i => [...i.a])'],
1591
+ // Empty tuple is a degenerate no-op — refused so emitters never
1592
+ // produce a zero-arg `flat_map_tuple` call.
1593
+ ['empty tuple', 'arr.flatMap(i => [])'],
1594
+ // Wrong-arity forms are intercepted by the same arm (not the generic
1595
+ // "flatMap has no template lowering" gate) so the reason stays tailored.
1596
+ ['2-arg flatMap(fn, thisArg)', 'arr.flatMap(i => i.tags, ctx)'],
1597
+ ]
1598
+ for (const [label, expr] of refused) {
1599
+ test(`${label} — refuses`, () => {
1600
+ const result = parseExpression(expr)
1601
+ expect(result.kind).toBe('unsupported')
1602
+ if (result.kind === 'unsupported') {
1603
+ expect(result.reason).toContain('flatMap shape not supported')
1604
+ }
1605
+ })
1606
+ }
1607
+
1608
+ test('exprToString / stringify round-trip the callback', () => {
1609
+ // Scalar projections, plus the array-literal tuple — the round-trip
1610
+ // relies on the callback `raw`, so a regression in raw capture would
1611
+ // surface here for every projection shape.
1612
+ for (const src of [
1613
+ 'arr.flatMap(i => i.tags)',
1614
+ 'arr.flatMap(t => t)',
1615
+ 'arr.flatMap(i => [i.a, i.b])',
1616
+ 'arr.flatMap(i => [i, i.tags])',
1617
+ ]) {
1618
+ expect(exprToString(parseExpression(src))).toBe(src)
1619
+ expect(stringifyParsedExpr(parseExpression(src))).toBe(src)
1620
+ }
1621
+ })
1622
+ })
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `.forEach()` is client-callback-only (#1448 Tier C / Tier D-class).
3
+ *
4
+ * `.forEach()` returns `undefined`, so it has no template-position meaning
5
+ * and is never a lowering target. This pins the two halves of that contract:
6
+ *
7
+ * 1. In **template position** the support gate refuses it (the template
8
+ * adapters surface this as BF101 via `isSupported`), with a dedicated
9
+ * reason that explains the `undefined` return — not the generic
10
+ * `/* @client *​/` escape-hatch hint.
11
+ * 2. Inside an **event handler / `createEffect` callback** it is client JS
12
+ * and passes straight through to the emitted runtime untouched — this is
13
+ * the only valid use.
14
+ */
15
+
16
+ import { describe, test, expect } from 'bun:test'
17
+ import { parseExpression, isSupported } from '../expression-parser'
18
+ import { compileJSX } from '../compiler'
19
+ import { TestAdapter } from '../adapters/test-adapter'
20
+
21
+ const adapter = new TestAdapter()
22
+
23
+ describe('.forEach() — template position is refused (#1448)', () => {
24
+ test('isSupported reports forEach as unsupported', () => {
25
+ const support = isSupported(parseExpression('items().forEach(t => t.x)'))
26
+ expect(support.supported).toBe(false)
27
+ expect(support.level).toBe('L5_UNSUPPORTED')
28
+ })
29
+
30
+ test('refusal reason explains the undefined return and points to the valid uses', () => {
31
+ const support = isSupported(parseExpression('items().forEach(t => t.x)'))
32
+ expect(support.supported).toBe(false)
33
+ if (support.supported) return
34
+ // Dedicated forEach message that steers to .map(...) / createEffect —
35
+ // not the generic refusal, which offers the /* @client */ escape hatch.
36
+ expect(support.reason).toContain('returns undefined')
37
+ expect(support.reason).toContain('createEffect')
38
+ expect(support.reason).toContain('.map(')
39
+ expect(support.reason).not.toContain('/* @client */')
40
+ })
41
+
42
+ test('a bare-identifier receiver is refused too (not just signal getters)', () => {
43
+ const support = isSupported(parseExpression('list.forEach(x => x)'))
44
+ expect(support.supported).toBe(false)
45
+ expect(support.level).toBe('L5_UNSUPPORTED')
46
+ })
47
+ })
48
+
49
+ describe('.forEach() — client-callback use passes through (#1448)', () => {
50
+ // forEach inside an event handler and a createEffect callback is client JS;
51
+ // it must reach the emitted runtime verbatim with no compile error.
52
+ const source = `
53
+ 'use client'
54
+ import { createSignal, createEffect } from '@barefootjs/client'
55
+
56
+ export function C() {
57
+ const [items, setItems] = createSignal<any[]>([])
58
+ const handle = () => { items().forEach(t => console.log(t)) }
59
+ createEffect(() => { items().forEach(t => t) })
60
+ return <button onClick={handle}>go</button>
61
+ }
62
+ `
63
+
64
+ test('no compile errors for forEach in callbacks', () => {
65
+ const result = compileJSX(source, 'C.tsx', { adapter })
66
+ expect(result.errors).toHaveLength(0)
67
+ })
68
+
69
+ test('forEach reaches the emitted client JS verbatim', () => {
70
+ const result = compileJSX(source, 'C.tsx', { adapter })
71
+ const client = result.files.find(f => f.path.endsWith('.client.js'))
72
+ expect(client).toBeDefined()
73
+ // Target the user's own `items().forEach(...)` calls specifically — a bare
74
+ // `/forEach/g` count would also catch any `forEach` the client-code
75
+ // generator emits for loop / hydration infrastructure, making this flaky.
76
+ // Both the handler call and the createEffect call must survive verbatim.
77
+ const occurrences = client!.content.match(/items\(\)\.forEach\(/g) ?? []
78
+ expect(occurrences.length).toBe(2)
79
+ })
80
+ })
@@ -0,0 +1,51 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { analyzeComponent } from '../analyzer'
3
+ import { jsxToIR } from '../jsx-to-ir'
4
+ import { parseExpression } from '../expression-parser'
5
+
6
+ /**
7
+ * IR-level verification for `.reduce(fn, init)` (#1448 Tier C). The
8
+ * parser intercepts the arithmetic-fold catalogue into a structured
9
+ * `array-method` + `ReduceOp` node — this test pins the source-string
10
+ * that survives through `jsxToIR` to an `IRExpression`, then re-parses
11
+ * it to confirm the structured fold shape adapters consume.
12
+ *
13
+ * Hydration and template-emit correctness are pinned at the adapter
14
+ * conformance layer (`reduce-*` fixtures in `packages/adapter-tests/
15
+ * fixtures/methods/`).
16
+ */
17
+ describe('reduce(fn, init) IR shape', () => {
18
+ test('sum over a struct field re-parses to a numeric ReduceOp', () => {
19
+ const source = `
20
+ 'use client'
21
+ import { createSignal } from '@barefootjs/client'
22
+
23
+ export function Total() {
24
+ const [items] = createSignal<{ duration: number }[]>([])
25
+ return <div>{items().reduce((sum, t) => sum + t.duration, 0)}</div>
26
+ }
27
+ `
28
+
29
+ const ctx = analyzeComponent(source, 'Total.tsx')
30
+ const ir = jsxToIR(ctx)
31
+
32
+ expect(ir).not.toBeNull()
33
+ if (ir!.type !== 'element') return
34
+ const exprNode = ir!.children.find(c => c.type === 'expression')
35
+ expect(exprNode?.type).toBe('expression')
36
+ if (exprNode?.type !== 'expression') return
37
+
38
+ // The IR carries the expression as a source string (the same shape
39
+ // adapters re-parse at emit time). Round-trip it to confirm the
40
+ // ReduceOp catalogue match survives the analyzer → IR boundary.
41
+ const parsed = parseExpression(exprNode.expr)
42
+ expect(parsed.kind).toBe('array-method')
43
+ if (parsed.kind !== 'array-method') return
44
+ expect(parsed.method).toBe('reduce')
45
+ if (parsed.method !== 'reduce') return
46
+ expect(parsed.reduceOp.op).toBe('+')
47
+ expect(parsed.reduceOp.key).toEqual({ kind: 'field', field: 'duration' })
48
+ expect(parsed.reduceOp.type).toBe('numeric')
49
+ expect(parsed.reduceOp.init).toBe('0')
50
+ })
51
+ })
@@ -0,0 +1,201 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { parseExpression, isSupported, stringifyParsedExpr } from '../expression-parser'
3
+
4
+ /**
5
+ * `.reduce(fn, init)` arithmetic-fold catalogue (#1448 Tier C). The
6
+ * parser intercepts the accepted shapes into a structured `ReduceOp`
7
+ * (mirroring the `.sort` `SortComparator` precedent) and refuses
8
+ * everything else to `unsupported` so the template adapters surface
9
+ * BF101 with the `@client` escape hatch.
10
+ */
11
+ describe('reduce() arithmetic-fold catalogue', () => {
12
+ test('numeric sum over a field → field/numeric', () => {
13
+ const r = parseExpression('items.reduce((sum, t) => sum + t.duration, 0)')
14
+ expect(r.kind).toBe('array-method')
15
+ if (r.kind === 'array-method' && r.method === 'reduce') {
16
+ expect(r.reduceOp.op).toBe('+')
17
+ expect(r.reduceOp.key).toEqual({ kind: 'field', field: 'duration' })
18
+ expect(r.reduceOp.type).toBe('numeric')
19
+ expect(r.reduceOp.init).toBe('0')
20
+ expect(r.reduceOp.paramAcc).toBe('sum')
21
+ expect(r.reduceOp.paramItem).toBe('t')
22
+ }
23
+ expect(isSupported(r).supported).toBe(true)
24
+ })
25
+
26
+ test('numeric sum over self (primitive array) → self/numeric', () => {
27
+ const r = parseExpression('nums.reduce((a, b) => a + b, 0)')
28
+ expect(r.kind).toBe('array-method')
29
+ if (r.kind === 'array-method' && r.method === 'reduce') {
30
+ expect(r.reduceOp.op).toBe('+')
31
+ expect(r.reduceOp.key).toEqual({ kind: 'self' })
32
+ expect(r.reduceOp.type).toBe('numeric')
33
+ }
34
+ })
35
+
36
+ test('reduceRight shares the catalogue and preserves the method name', () => {
37
+ const r = parseExpression("items.reduceRight((acc, x) => acc + x.label, '')")
38
+ expect(r.kind).toBe('array-method')
39
+ if (r.kind === 'array-method') {
40
+ expect(r.method).toBe('reduceRight')
41
+ if (r.method === 'reduceRight') {
42
+ expect(r.reduceOp.op).toBe('+')
43
+ expect(r.reduceOp.key).toEqual({ kind: 'field', field: 'label' })
44
+ expect(r.reduceOp.type).toBe('string')
45
+ }
46
+ }
47
+ })
48
+
49
+ test('reduceRight round-trips with its method name preserved', () => {
50
+ const r = parseExpression('nums.reduceRight((a, b) => a + b, 0)')
51
+ expect(stringifyParsedExpr(r)).toBe('nums.reduceRight((a,b) => a + b, 0)')
52
+ })
53
+
54
+ test('product over a field with init 1 → field/numeric/*', () => {
55
+ const r = parseExpression('items.reduce((acc, x) => acc * x.qty, 1)')
56
+ expect(r.kind).toBe('array-method')
57
+ if (r.kind === 'array-method' && r.method === 'reduce') {
58
+ expect(r.reduceOp.op).toBe('*')
59
+ expect(r.reduceOp.key).toEqual({ kind: 'field', field: 'qty' })
60
+ expect(r.reduceOp.type).toBe('numeric')
61
+ expect(r.reduceOp.init).toBe('1')
62
+ }
63
+ })
64
+
65
+ test('string init makes + a concatenation fold (init is the decoded value)', () => {
66
+ const r = parseExpression("items.reduce((acc, x) => acc + x.label, '')")
67
+ expect(r.kind).toBe('array-method')
68
+ if (r.kind === 'array-method' && r.method === 'reduce') {
69
+ expect(r.reduceOp.op).toBe('+')
70
+ expect(r.reduceOp.type).toBe('string')
71
+ expect(r.reduceOp.init).toBe('') // decoded contents, not the `''` source
72
+ }
73
+ })
74
+
75
+ test('non-empty string seed decodes to its contents', () => {
76
+ const r = parseExpression("items.reduce((acc, x) => acc + x.label, ', ')")
77
+ expect(r.kind).toBe('array-method')
78
+ if (r.kind === 'array-method' && r.method === 'reduce') {
79
+ expect(r.reduceOp.init).toBe(', ')
80
+ }
81
+ })
82
+
83
+ test('numeric init normalises separators / radix to canonical decimal', () => {
84
+ // `.text` gives TS's canonical decimal so Go ParseFloat + Perl agree
85
+ // (#1728 review: raw `1_000` / `0x10` would mis-parse on Go).
86
+ for (const [src, expected] of [
87
+ ['1_000', '1000'],
88
+ ['0x10', '16'],
89
+ ['1e3', '1000'],
90
+ ] as const) {
91
+ const r = parseExpression(`nums.reduce((a, b) => a + b, ${src})`)
92
+ expect(r.kind).toBe('array-method')
93
+ if (r.kind === 'array-method' && r.method === 'reduce') {
94
+ expect(r.reduceOp.init).toBe(expected)
95
+ }
96
+ }
97
+ })
98
+
99
+ test('parenthesized numeric init is unwrapped and accepted', () => {
100
+ for (const [src, expected] of [
101
+ ['(0)', '0'],
102
+ ['(-1)', '-1'],
103
+ ] as const) {
104
+ const r = parseExpression(`nums.reduce((a, b) => a + b, ${src})`)
105
+ expect(r.kind).toBe('array-method')
106
+ if (r.kind === 'array-method' && r.method === 'reduce') {
107
+ expect(r.reduceOp.init).toBe(expected)
108
+ }
109
+ }
110
+ })
111
+
112
+ test('an escape-free seed containing an apostrophe is accepted (decoded contents kept)', () => {
113
+ // `"a'b"` is escape-free (decoded === raw inner), so it's accepted;
114
+ // the decoded value carries the apostrophe, which the Mojo emit
115
+ // single-quote-escapes.
116
+ const r = parseExpression(`items.reduce((acc, x) => acc + x.l, "a'b")`)
117
+ expect(r.kind).toBe('array-method')
118
+ if (r.kind === 'array-method' && r.method === 'reduce') {
119
+ expect(r.reduceOp.init).toBe("a'b")
120
+ }
121
+ })
122
+
123
+ test('single-return block body unwraps to the fold expression', () => {
124
+ const r = parseExpression('items.reduce((sum, t) => { return sum + t.n }, 0)')
125
+ expect(r.kind).toBe('array-method')
126
+ if (r.kind === 'array-method' && r.method === 'reduce') {
127
+ expect(r.reduceOp.key).toEqual({ kind: 'field', field: 'n' })
128
+ }
129
+ })
130
+
131
+ test('negative numeric init is accepted', () => {
132
+ const r = parseExpression('nums.reduce((a, b) => a + b, -1)')
133
+ expect(r.kind).toBe('array-method')
134
+ if (r.kind === 'array-method' && r.method === 'reduce') {
135
+ expect(r.reduceOp.init).toBe('-1')
136
+ }
137
+ })
138
+
139
+ test('round-trips a numeric fold back to valid JS via stringifyParsedExpr', () => {
140
+ const r = parseExpression('items.reduce((sum, t) => sum + t.duration, 0)')
141
+ expect(stringifyParsedExpr(r)).toBe('items.reduce((sum,t) => sum + t.duration, 0)')
142
+ })
143
+
144
+ test('round-trips a string fold by re-quoting the decoded seed', () => {
145
+ const r = parseExpression("items.reduce((a, x) => a + x.l, ', ')")
146
+ // Decoded seed `, ` re-quoted via JSON.stringify → a valid JS string.
147
+ expect(stringifyParsedExpr(r)).toBe('items.reduce((a,x) => a + x.l, ", ")')
148
+ })
149
+
150
+ describe('refused shapes → unsupported (BF101)', () => {
151
+ test('missing initial value', () => {
152
+ // A no-init reduce isn't intercepted (it needs 2 args); it falls
153
+ // through to a generic `call` that the UNSUPPORTED_METHODS gate
154
+ // refuses — JS throws on an empty array here, so a template can't
155
+ // mirror it cleanly.
156
+ const r = parseExpression('items.reduce((sum, t) => sum + t.duration)')
157
+ expect(isSupported(r).supported).toBe(false)
158
+ })
159
+
160
+ test('string concat with * is rejected', () => {
161
+ const r = parseExpression("items.reduce((acc, x) => acc * x.label, '')")
162
+ expect(r.kind).toBe('unsupported')
163
+ })
164
+
165
+ test('accumulator on the right operand is rejected', () => {
166
+ const r = parseExpression('items.reduce((sum, t) => t.duration + sum, 0)')
167
+ expect(r.kind).toBe('unsupported')
168
+ })
169
+
170
+ test('non-literal init is rejected', () => {
171
+ const r = parseExpression('items.reduce((sum, t) => sum + t.n, start)')
172
+ expect(r.kind).toBe('unsupported')
173
+ })
174
+
175
+ test('object-building reducer is rejected', () => {
176
+ const r = parseExpression('items.reduce((acc, x) => ({ ...acc, [x.id]: x }), {})')
177
+ expect(r.kind).toBe('unsupported')
178
+ })
179
+
180
+ test('deep field access is rejected', () => {
181
+ const r = parseExpression('items.reduce((sum, t) => sum + t.a.b, 0)')
182
+ expect(r.kind).toBe('unsupported')
183
+ })
184
+
185
+ test('string seed carrying an escape is rejected (cross-adapter safety)', () => {
186
+ // A seed with an escape can't be guaranteed byte-equal across the
187
+ // Go-template / Perl string embeddings without per-target decoding,
188
+ // so it refuses to `unsupported` rather than risk divergence.
189
+ const r = parseExpression("items.reduce((acc, x) => acc + x.l, '\\n')")
190
+ expect(r.kind).toBe('unsupported')
191
+ })
192
+
193
+ test('reduceRight without an init is refused (like reduce)', () => {
194
+ // The matching 2-arg form is intercepted; a no-init reduceRight
195
+ // falls through to a generic call the UNSUPPORTED_METHODS gate
196
+ // refuses (JS throws on an empty array there).
197
+ const rr = parseExpression('items.reduceRight((sum, t) => sum + t.n)')
198
+ expect(isSupported(rr).supported).toBe(false)
199
+ })
200
+ })
201
+ })
@@ -33,7 +33,7 @@
33
33
  * be added in one place.
34
34
  */
35
35
 
36
- import type { ParsedExpr, SortComparator, TemplatePart } from '../expression-parser'
36
+ import type { ParsedExpr, SortComparator, ReduceOp, FlatDepth, FlatMapOp, TemplatePart } from '../expression-parser'
37
37
 
38
38
  export type HigherOrderMethod = 'filter' | 'every' | 'some' | 'find' | 'findIndex' | 'findLast' | 'findLastIndex'
39
39
 
@@ -82,6 +82,17 @@ export type ArrayMethod =
82
82
  */
83
83
  export type SortMethod = 'sort' | 'toSorted'
84
84
 
85
+ /**
86
+ * `reduce` / `reduceRight` are handled by the dedicated `reduceMethod()`
87
+ * dispatcher arm (#1448 Tier C) for the same reason sort is: they carry
88
+ * a structured `ReduceOp` (the parsed arithmetic-fold spec) rather than
89
+ * a `ParsedExpr[]` args list, so folding them into `arrayMethod()` would
90
+ * force a spec-or-args runtime check at every call site. The method name
91
+ * is threaded through so adapters can pick the fold direction (left for
92
+ * `reduce`, right for `reduceRight`).
93
+ */
94
+ export type ReduceMethod = 'reduce' | 'reduceRight'
95
+
85
96
  export type LiteralType = 'string' | 'number' | 'boolean' | 'null'
86
97
 
87
98
  /**
@@ -140,6 +151,28 @@ export interface ParsedExprEmitter {
140
151
  comparator: SortComparator,
141
152
  emit: (e: ParsedExpr) => string,
142
153
  ): string
154
+ reduceMethod(
155
+ method: ReduceMethod,
156
+ object: ParsedExpr,
157
+ reduceOp: ReduceOp,
158
+ emit: (e: ParsedExpr) => string,
159
+ ): string
160
+ // `.flat(depth?)` gets its own dispatcher arm (#1448 Tier C): it carries
161
+ // a structured `FlatDepth` (the validated literal / `'infinity'`) rather
162
+ // than a `ParsedExpr[]` args list, same rationale as sort / reduce.
163
+ flatMethod(
164
+ object: ParsedExpr,
165
+ depth: FlatDepth,
166
+ emit: (e: ParsedExpr) => string,
167
+ ): string
168
+ // `.flatMap(fn)` value-returning field projection gets its own arm
169
+ // (#1448 Tier C): it carries a structured `FlatMapOp` rather than a
170
+ // `ParsedExpr[]` args list, same rationale as sort / reduce / flat.
171
+ flatMapMethod(
172
+ object: ParsedExpr,
173
+ op: FlatMapOp,
174
+ emit: (e: ParsedExpr) => string,
175
+ ): string
143
176
  unsupported(raw: string, reason: string): string
144
177
  }
145
178
 
@@ -183,6 +216,15 @@ export function emitParsedExpr(expr: ParsedExpr, emitter: ParsedExprEmitter): st
183
216
  if (expr.method === 'sort' || expr.method === 'toSorted') {
184
217
  return emitter.sortMethod(expr.method, expr.object, expr.comparator, emit)
185
218
  }
219
+ if (expr.method === 'reduce' || expr.method === 'reduceRight') {
220
+ return emitter.reduceMethod(expr.method, expr.object, expr.reduceOp, emit)
221
+ }
222
+ if (expr.method === 'flat') {
223
+ return emitter.flatMethod(expr.object, expr.flatDepth, emit)
224
+ }
225
+ if (expr.method === 'flatMap') {
226
+ return emitter.flatMapMethod(expr.object, expr.flatMapOp, emit)
227
+ }
186
228
  return emitter.arrayMethod(expr.method, expr.object, expr.args, emit)
187
229
  case 'unsupported':
188
230
  return emitter.unsupported(expr.raw, expr.reason)