@barefootjs/jsx 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/dist/adapters/parsed-expr-emitter.d.ts +15 -2
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +138 -1
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +450 -23
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/generate-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +30 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/phases/provider-and-child-inits.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/build-static-array-child-init.d.ts +4 -0
- package/dist/ir-to-client-js/plan/build-static-array-child-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +46 -2
- package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/stringify/static-array-child-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +10 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/child-components-in-map.test.ts +84 -0
- package/src/__tests__/expression-parser.test.ts +276 -14
- package/src/__tests__/foreach-client-only.test.ts +80 -0
- package/src/__tests__/ir-reduce-op.test.ts +51 -0
- package/src/__tests__/ir-to-client-js/reactivity.test.ts +1 -0
- package/src/__tests__/reduce-op.test.ts +201 -0
- package/src/__tests__/staged-ir/06-multi-stage-soak.test.ts +18 -3
- package/src/adapters/parsed-expr-emitter.ts +50 -1
- package/src/expression-parser.ts +770 -21
- package/src/index.ts +1 -1
- package/src/ir-to-client-js/collect-elements.ts +9 -3
- package/src/ir-to-client-js/emit-registration.ts +1 -1
- package/src/ir-to-client-js/generate-init.ts +16 -1
- package/src/ir-to-client-js/html-template.ts +156 -2
- package/src/ir-to-client-js/index.ts +1 -0
- package/src/ir-to-client-js/phases/provider-and-child-inits.ts +12 -1
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +55 -1
- package/src/ir-to-client-js/plan/static-array-child-init.ts +47 -1
- package/src/ir-to-client-js/stringify/static-array-child-init.ts +69 -0
- package/src/ir-to-client-js/types.ts +10 -0
package/src/expression-parser.ts
CHANGED
|
@@ -46,6 +46,13 @@ export type ParsedExpr =
|
|
|
46
46
|
| 'toLowerCase'
|
|
47
47
|
| 'toUpperCase'
|
|
48
48
|
| 'trim'
|
|
49
|
+
| 'split'
|
|
50
|
+
| 'startsWith'
|
|
51
|
+
| 'endsWith'
|
|
52
|
+
| 'replace'
|
|
53
|
+
| 'repeat'
|
|
54
|
+
| 'padStart'
|
|
55
|
+
| 'padEnd'
|
|
49
56
|
object: ParsedExpr
|
|
50
57
|
args: ParsedExpr[]
|
|
51
58
|
}
|
|
@@ -66,6 +73,49 @@ export type ParsedExpr =
|
|
|
66
73
|
args: []
|
|
67
74
|
comparator: SortComparator
|
|
68
75
|
}
|
|
76
|
+
// `.reduce(fn, init)` (#1448 Tier C). Like sort, the reducer is
|
|
77
|
+
// extracted into a structured `ReduceOp` at parse time — the
|
|
78
|
+
// two-param arrow never reaches `args`, so adapters fold via a
|
|
79
|
+
// runtime helper instead of re-walking the callback. The accepted
|
|
80
|
+
// catalogue is the arithmetic-fold family only (`acc + key` /
|
|
81
|
+
// `acc * key`, numeric or string-concat); any other reducer body,
|
|
82
|
+
// or a missing initial value, falls through to `unsupported` so
|
|
83
|
+
// adapters surface BF101 with an @client suggestion. See
|
|
84
|
+
// `extractReduceOpFromTS` below.
|
|
85
|
+
| {
|
|
86
|
+
kind: 'array-method'
|
|
87
|
+
method: 'reduce' | 'reduceRight'
|
|
88
|
+
object: ParsedExpr
|
|
89
|
+
args: []
|
|
90
|
+
reduceOp: ReduceOp
|
|
91
|
+
}
|
|
92
|
+
// `.flat(depth?)` (#1448 Tier C). The flatten depth is validated and
|
|
93
|
+
// normalised into a structured `FlatDepth` at parse time — the literal
|
|
94
|
+
// never reaches `args`, so adapters fold via a runtime helper instead of
|
|
95
|
+
// re-inspecting the depth argument. A non-literal depth refuses with
|
|
96
|
+
// BF101 (the depth must be known at template time). See the `.flat` arm
|
|
97
|
+
// in `convertNode`.
|
|
98
|
+
| {
|
|
99
|
+
kind: 'array-method'
|
|
100
|
+
method: 'flat'
|
|
101
|
+
object: ParsedExpr
|
|
102
|
+
args: []
|
|
103
|
+
flatDepth: FlatDepth
|
|
104
|
+
}
|
|
105
|
+
// `.flatMap(fn)` value-returning field projection (#1448 Tier C). The
|
|
106
|
+
// callback is extracted into a structured `FlatMapOp` (self / field
|
|
107
|
+
// projection) at parse time, mirroring sort / reduce. The projected
|
|
108
|
+
// per-item value is flattened one level (flatMap = map + flat(1)).
|
|
109
|
+
// Array-literal / complex callbacks fall through to `unsupported`; the
|
|
110
|
+
// JSX-returning `.flatMap` is handled as an `IRLoop` upstream and never
|
|
111
|
+
// reaches here. See `extractFlatMapOpFromTS` below.
|
|
112
|
+
| {
|
|
113
|
+
kind: 'array-method'
|
|
114
|
+
method: 'flatMap'
|
|
115
|
+
object: ParsedExpr
|
|
116
|
+
args: []
|
|
117
|
+
flatMapOp: FlatMapOp
|
|
118
|
+
}
|
|
69
119
|
| { kind: 'unsupported'; raw: string; reason: string }
|
|
70
120
|
|
|
71
121
|
/**
|
|
@@ -125,6 +175,95 @@ export type SortComparator = {
|
|
|
125
175
|
method: 'sort' | 'toSorted'
|
|
126
176
|
}
|
|
127
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Structured form of a JS `.reduce((acc, item) => …, init)` call,
|
|
180
|
+
* built once at parse time and consumed by both template adapters'
|
|
181
|
+
* `reduceMethod()` emit (#1448 Tier C). The shape is intentionally
|
|
182
|
+
* finite — only the arithmetic-fold family is lowerable in a
|
|
183
|
+
* declarative template; arbitrary accumulator bodies are not. See
|
|
184
|
+
* `extractReduceOpFromTS` for the accepted catalogue.
|
|
185
|
+
*/
|
|
186
|
+
export type ReduceOp = {
|
|
187
|
+
// The fold operator between accumulator and per-item value. `+`
|
|
188
|
+
// covers numeric sums and (with a string init) string concatenation;
|
|
189
|
+
// `*` covers numeric products. Subtraction / division are excluded —
|
|
190
|
+
// they're order-sensitive and rarely written as a `.reduce`.
|
|
191
|
+
op: '+' | '*'
|
|
192
|
+
// What value each item contributes to the fold:
|
|
193
|
+
// { kind: 'self' } → `acc + item` (primitive array)
|
|
194
|
+
// { kind: 'field', field } → `acc + item.field` (struct-field accessor)
|
|
195
|
+
key: { kind: 'self' } | { kind: 'field'; field: string }
|
|
196
|
+
// Numeric fold vs string concatenation. Determined by the init
|
|
197
|
+
// literal's type: a number init folds numerically; a string init
|
|
198
|
+
// (only valid with `+`) concatenates. Both template runtimes apply
|
|
199
|
+
// the same coercion so their output stays byte-equal; this can
|
|
200
|
+
// diverge from JS for floating-point sums whose decimal expansion
|
|
201
|
+
// differs by runtime (rare in SSR data — integer sums agree).
|
|
202
|
+
type: 'numeric' | 'string'
|
|
203
|
+
// Decoded initial-accumulator value (never raw source). For a numeric
|
|
204
|
+
// fold this is TypeScript's canonical decimal form (`1_000` -> `1000`,
|
|
205
|
+
// `0x10` -> `16`) so `strconv.ParseFloat` / Perl agree; for a concat
|
|
206
|
+
// fold it's the contents of a quoted string literal (only single- or
|
|
207
|
+
// double-quoted `ts.StringLiteral` seeds are accepted — template
|
|
208
|
+
// literals and escape-carrying literals are refused at parse time, so
|
|
209
|
+
// the value is an escape-free single-line string, e.g. the empty
|
|
210
|
+
// string "" or a separator like ", "). Round-trip emitters re-quote a
|
|
211
|
+
// string init via `JSON.stringify`; a numeric init re-emits as-is.
|
|
212
|
+
init: string
|
|
213
|
+
// Original JS source of the reducer body (the returned expression
|
|
214
|
+
// for block bodies). Lets the `@client` fallback ship the user's
|
|
215
|
+
// exact arrow to the JS runtime.
|
|
216
|
+
raw: string
|
|
217
|
+
// The two parameter names the user wrote (e.g. `acc`/`item`, or
|
|
218
|
+
// `sum`/`t`). Only the `@client` fallback reads them — it binds them
|
|
219
|
+
// in a synthetic `(acc, item) => raw` arrow. Server-side lowering
|
|
220
|
+
// works off `op` / `key` / `init` and ignores them.
|
|
221
|
+
paramAcc: string
|
|
222
|
+
paramItem: string
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Flatten depth for `.flat(depth?)` (#1448 Tier C). A finite non-negative
|
|
227
|
+
* integer flattens that many levels (`.flat()` defaults to `1`; a `0` or
|
|
228
|
+
* negative JS depth means "no flatten" → a shallow copy, normalised to
|
|
229
|
+
* `0` here); `'infinity'` is the `.flat(Infinity)` full-depth form. The
|
|
230
|
+
* depth must be a literal — a non-literal argument can't be resolved at
|
|
231
|
+
* template time and refuses with BF101.
|
|
232
|
+
*/
|
|
233
|
+
export type FlatDepth = number | 'infinity'
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A single non-computed projection leaf on the flatMap callback param —
|
|
237
|
+
* the item itself (`i`) or one of its fields (`i.field`). Shared by the
|
|
238
|
+
* scalar and tuple `FlatMapOp` projections.
|
|
239
|
+
*/
|
|
240
|
+
export type FlatMapLeaf = { kind: 'self' } | { kind: 'field'; field: string }
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Structured form of a value-returning `.flatMap(fn)` callback (#1448
|
|
244
|
+
* Tier C). The accepted catalogue:
|
|
245
|
+
*
|
|
246
|
+
* i => i → self projection (flatten one level)
|
|
247
|
+
* i => i.field → field projection (flatten a per-item array field)
|
|
248
|
+
* i => [i.a, i.b] → tuple projection (gather per-item leaves)
|
|
249
|
+
*
|
|
250
|
+
* The scalar `self` / `field` projections return a value that is then
|
|
251
|
+
* flattened one level (flatMap = map + flat(1)) — a non-array value is
|
|
252
|
+
* kept as-is, matching JS. The `tuple` projection returns an array
|
|
253
|
+
* literal; flat(1) removes only that literal's wrapper, so each leaf is
|
|
254
|
+
* appended verbatim (an array-valued leaf is NOT spread). Leaves outside
|
|
255
|
+
* self / field (literals, `i.a + 1`, calls, deep access) refuse with
|
|
256
|
+
* BF101. See `extractFlatMapOpFromTS`.
|
|
257
|
+
*/
|
|
258
|
+
export type FlatMapOp = {
|
|
259
|
+
// What each item projects to before the one-level flatten.
|
|
260
|
+
projection: FlatMapLeaf | { kind: 'tuple'; elements: FlatMapLeaf[] }
|
|
261
|
+
// The callback param name the user wrote (for the `@client` round-trip).
|
|
262
|
+
param: string
|
|
263
|
+
// Original JS source of the callback body (for the `@client` fallback).
|
|
264
|
+
raw: string
|
|
265
|
+
}
|
|
266
|
+
|
|
128
267
|
export type TemplatePart =
|
|
129
268
|
| { type: 'string'; value: string }
|
|
130
269
|
| { type: 'expression'; expr: ParsedExpr }
|
|
@@ -159,6 +298,13 @@ export interface SupportResult {
|
|
|
159
298
|
supported: boolean
|
|
160
299
|
level?: SupportLevel
|
|
161
300
|
reason?: string
|
|
301
|
+
/**
|
|
302
|
+
* The `reason` already spells out the fix (the pre-compute / `@client`
|
|
303
|
+
* hint, or a tailored message like the `forEach` diagnostic), so adapters
|
|
304
|
+
* surface it as-is instead of appending their own remediation block.
|
|
305
|
+
* Low-level reasons (operators, comparators, predicates) leave this unset.
|
|
306
|
+
*/
|
|
307
|
+
selfContained?: boolean
|
|
162
308
|
}
|
|
163
309
|
|
|
164
310
|
// JS Array / String prototype methods that the template-language
|
|
@@ -181,10 +327,30 @@ const UNSUPPORTED_METHODS = new Set([
|
|
|
181
327
|
// Higher-order array methods. Seven of these (`filter`, `every`,
|
|
182
328
|
// `some`, `find`, `findIndex`, `findLast`, `findLastIndex`) are
|
|
183
329
|
// intercepted as `higher-order` IR before reaching this gate;
|
|
184
|
-
// `map` is intercepted as an IRLoop.
|
|
185
|
-
//
|
|
330
|
+
// `map` is intercepted as an IRLoop. `reduce` / `reduceRight` stay
|
|
331
|
+
// listed here so the shapes the Tier C catalogue can't lower still
|
|
332
|
+
// refuse loudly: the `convertNode` call branch intercepts a matching
|
|
333
|
+
// `.reduce(fn, init)` / `.reduceRight(fn, init)` into the structured
|
|
334
|
+
// `array-method` + `ReduceOp` form *before* this gate (returning
|
|
335
|
+
// early), so only the unlowerable fall-throughs (a `.reduce(fn)` with
|
|
336
|
+
// no initial value, or a bare method reference) reach the gate and
|
|
337
|
+
// refuse. A 2-arg call whose reducer/init shape is off-catalogue
|
|
338
|
+
// returns an explicit `unsupported` from the call branch with a richer
|
|
339
|
+
// message. The rest stay refused — see #1448 Tier C for the design
|
|
340
|
+
// questions. `forEach` carries a tailored reason (see
|
|
341
|
+
// `UNSUPPORTED_METHOD_REASONS`).
|
|
342
|
+
// `flat` is no longer here — `.flat(depth?)` lowers via the
|
|
343
|
+
// `array-method` IR (structured `FlatDepth`) + `bf_flat` (Go) /
|
|
344
|
+
// `bf->flat` (Mojo). `flatMap` stays listed as a fallback: the
|
|
345
|
+
// field-projection form (`i => i` / `i => i.field`) lowers via a
|
|
346
|
+
// structured `FlatMapOp`. The convertNode arm intercepts EVERY
|
|
347
|
+
// `.flatMap(...)` call before this gate — matching shapes lower, and the
|
|
348
|
+
// off-catalogue / wrong-arity forms get a tailored `unsupported` reason
|
|
349
|
+
// there — so only a bare method *reference* (`arr.flatMap` uncalled)
|
|
350
|
+
// falls through to this gate. The JSX-returning `.flatMap` lowers as an
|
|
351
|
+
// `IRLoop` upstream. See #1448.
|
|
186
352
|
'filter', 'map', 'reduce', 'reduceRight', 'every', 'some',
|
|
187
|
-
'forEach', 'flatMap',
|
|
353
|
+
'forEach', 'flatMap',
|
|
188
354
|
// #1448 Tier A — Array methods. Each method PR adds the lowering
|
|
189
355
|
// (typically a new `array-method` variant or runtime helper) and
|
|
190
356
|
// removes its row here. See packages/adapter-tests/fixtures/methods/.
|
|
@@ -225,12 +391,60 @@ const UNSUPPORTED_METHODS = new Set([
|
|
|
225
391
|
// unsupported array methods above get), pointing users at the
|
|
226
392
|
// `/* @client */` escape hatch. Each name drops off as its lowering
|
|
227
393
|
// lands. See #1448 "Unsupported string methods" Tier B / Tier C.
|
|
228
|
-
|
|
229
|
-
|
|
394
|
+
// `split` is no longer here — `String.prototype.split(sep)` lowers
|
|
395
|
+
// via the `array-method` IR + `bf_split` (Go) / `bf->split` (Mojo),
|
|
396
|
+
// returning an array that composes with `.join()` / `.map()` / etc.
|
|
397
|
+
// See #1448 Tier B.
|
|
398
|
+
// `startsWith` / `endsWith` are no longer here — both lower via the
|
|
399
|
+
// `array-method` IR + `bf_starts_with` / `bf_ends_with` (Go) and
|
|
400
|
+
// `bf->starts_with` / `bf->ends_with` (Mojo). See #1448 Tier B.
|
|
401
|
+
// `replace` is no longer here — the string-pattern form lowers via
|
|
402
|
+
// the `array-method` IR + `bf_replace` (Go) / `bf->replace` (Mojo);
|
|
403
|
+
// the regex-pattern form is refused at the parse arm below (it would
|
|
404
|
+
// need the per-adapter regex-flavour decision). `replaceAll` stays
|
|
405
|
+
// refused. See #1448 Tier B.
|
|
406
|
+
// `repeat` is no longer here — `String.prototype.repeat(n)` lowers via
|
|
407
|
+
// the `array-method` IR + `bf_repeat` (Go) / `bf->repeat` (Mojo).
|
|
408
|
+
// See #1448 Tier B.
|
|
409
|
+
// `padStart` / `padEnd` are no longer here — both lower via the
|
|
410
|
+
// `array-method` IR + `bf_pad_start` / `bf_pad_end` (Go) and
|
|
411
|
+
// `bf->pad_start` / `bf->pad_end` (Mojo). See #1448 Tier B.
|
|
412
|
+
'replaceAll',
|
|
230
413
|
'charAt', 'charCodeAt', 'codePointAt', 'normalize',
|
|
231
414
|
'substring', 'substr', 'match', 'matchAll', 'search',
|
|
232
415
|
])
|
|
233
416
|
|
|
417
|
+
// Per-method override reasons for the BF101 refusal. A method here is still
|
|
418
|
+
// refused via `UNSUPPORTED_METHODS` above; this only swaps the generic hint
|
|
419
|
+
// for a tailored one. Add a row here rather than a branch in the support gate
|
|
420
|
+
// when a method needs special wording.
|
|
421
|
+
const UNSUPPORTED_METHOD_REASONS: Record<string, string> = {
|
|
422
|
+
// `forEach` returns `undefined`, so the generic pre-compute / @client hint
|
|
423
|
+
// is misleading (renders nothing either way) — steer to `.map(...)` /
|
|
424
|
+
// `createEffect`. Rationale pinned in foreach-client-only.test.ts.
|
|
425
|
+
forEach:
|
|
426
|
+
`'.forEach()' returns undefined and has no template-position meaning. ` +
|
|
427
|
+
`Use it for side effects inside an event handler or createEffect callback ` +
|
|
428
|
+
`(client JS), or use '.map(...)' if you meant to render each item.`,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Methods that lower at their single-argument form but whose EXTRA
|
|
432
|
+
// argument is meaningful and NOT yet lowered: the `fromIndex` of
|
|
433
|
+
// `.includes` / `.indexOf` / `.lastIndexOf` (the 2-arg form) and the
|
|
434
|
+
// additional arrays of a variadic `.concat(a, b, …)`. The relaxed
|
|
435
|
+
// per-method arms in `convertNode` accept every method's zero-arg
|
|
436
|
+
// defaults (`.join()` / `.slice()` / `.concat()` / `.at()`) and
|
|
437
|
+
// JS-ignored trailing arguments; this guard catches only the remaining
|
|
438
|
+
// meaningful-extra forms, refusing them with BF101 because silently
|
|
439
|
+
// dropping the argument would make the SSR output differ from the
|
|
440
|
+
// client. See #1448.
|
|
441
|
+
const LOWERED_ARRAY_METHODS = new Set([
|
|
442
|
+
'includes',
|
|
443
|
+
'indexOf',
|
|
444
|
+
'lastIndexOf',
|
|
445
|
+
'concat',
|
|
446
|
+
])
|
|
447
|
+
|
|
234
448
|
// =============================================================================
|
|
235
449
|
// Expression Parser
|
|
236
450
|
// =============================================================================
|
|
@@ -344,7 +558,10 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
344
558
|
// etc. later means widening the IR discriminator, not adding more
|
|
345
559
|
// branches to every adapter's call dispatch.
|
|
346
560
|
if (callee.kind === 'member' && !callee.computed) {
|
|
347
|
-
|
|
561
|
+
// `.join()` / `.join(sep)` — JS defaults the separator to `,` when
|
|
562
|
+
// omitted and ignores any extra arguments. Accept every arity; the
|
|
563
|
+
// adapters supply the default separator and read only `args[0]`.
|
|
564
|
+
if (callee.property === 'join') {
|
|
348
565
|
return { kind: 'array-method', method: 'join', object: callee.object, args }
|
|
349
566
|
}
|
|
350
567
|
// `.includes(x)` — shared between `Array.prototype.includes` and
|
|
@@ -369,20 +586,28 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
369
586
|
// element). Go has `bf_at` registered already (see runtime
|
|
370
587
|
// FuncMap); Mojo's `bf->at` wraps the same arithmetic.
|
|
371
588
|
// See #1448 Tier A.
|
|
372
|
-
|
|
589
|
+
// `.at(i)` — JS ignores any argument past the first, and `.at()`
|
|
590
|
+
// with no argument is `.at(0)` (the first element). Accept every
|
|
591
|
+
// arity; the adapters read `args[0]` (defaulting the index to 0).
|
|
592
|
+
if (callee.property === 'at') {
|
|
373
593
|
return { kind: 'array-method', method: 'at', object: callee.object, args }
|
|
374
594
|
}
|
|
375
|
-
// `.concat(other)` —
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
595
|
+
// `.concat()` / `.concat(other)` — `.concat()` returns a shallow
|
|
596
|
+
// copy (indistinguishable from the receiver in an SSR snapshot),
|
|
597
|
+
// and `.concat(other)` merges the two arrays. Go uses `bf_concat`
|
|
598
|
+
// (reflect-based append into `[]any`); Mojo uses `bf->concat`
|
|
599
|
+
// (Perl list builder). The VARIADIC form (`.concat(a, b, …)`) is
|
|
600
|
+
// not lowered yet — it's refused by the guard below rather than
|
|
601
|
+
// silently dropping the extra arrays.
|
|
602
|
+
if (callee.property === 'concat' && args.length <= 1) {
|
|
380
603
|
return { kind: 'array-method', method: 'concat', object: callee.object, args }
|
|
381
604
|
}
|
|
382
|
-
// `.slice(start)` / `.slice(start, end)` —
|
|
383
|
-
// through `bf_slice` (Go) / `bf->slice` (Mojo)
|
|
384
|
-
//
|
|
385
|
-
|
|
605
|
+
// `.slice()` / `.slice(start)` / `.slice(start, end)` — route
|
|
606
|
+
// through `bf_slice` (Go) / `bf->slice` (Mojo). A missing `start`
|
|
607
|
+
// defaults to 0 (full copy), a missing / undef `end` means "to
|
|
608
|
+
// length", and JS ignores any third+ argument. Accept every arity;
|
|
609
|
+
// the adapters read only `args[0]` / `args[1]`.
|
|
610
|
+
if (callee.property === 'slice') {
|
|
386
611
|
return { kind: 'array-method', method: 'slice', object: callee.object, args }
|
|
387
612
|
}
|
|
388
613
|
// `.reverse()` and `.toReversed()` — both zero-arg shapes
|
|
@@ -390,28 +615,198 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
390
615
|
// of state, so JS's mutate-and-return-receiver (`reverse`)
|
|
391
616
|
// vs return-new-array (`toReversed`) distinction has no
|
|
392
617
|
// template-level meaning; both produce a new reversed array.
|
|
393
|
-
|
|
618
|
+
// JS takes no argument and ignores any that are passed.
|
|
619
|
+
if (callee.property === 'reverse' || callee.property === 'toReversed') {
|
|
394
620
|
return { kind: 'array-method', method: callee.property, object: callee.object, args }
|
|
395
621
|
}
|
|
622
|
+
// `.flat(depth?)` — flatten nested arrays `depth` levels deep
|
|
623
|
+
// (default 1). The depth is validated to a literal and normalised
|
|
624
|
+
// into a structured `FlatDepth` here: `Infinity` becomes the
|
|
625
|
+
// full-depth form, a 0 / negative JS depth normalises to 0 ("no
|
|
626
|
+
// flatten" → shallow copy), and a fractional literal truncates
|
|
627
|
+
// toward zero (JS ToIntegerOrInfinity). A NON-literal depth can't be
|
|
628
|
+
// resolved at template time, so it refuses with BF101 rather than
|
|
629
|
+
// emitting a broken helper call. Go uses `bf_flat`; Mojo uses
|
|
630
|
+
// `bf->flat`. See #1448 Tier C.
|
|
631
|
+
if (callee.property === 'flat') {
|
|
632
|
+
const depthNode = node.arguments[0]
|
|
633
|
+
let flatDepth: FlatDepth
|
|
634
|
+
if (depthNode === undefined) {
|
|
635
|
+
flatDepth = 1
|
|
636
|
+
} else if (ts.isIdentifier(depthNode) && depthNode.text === 'Infinity') {
|
|
637
|
+
flatDepth = 'infinity'
|
|
638
|
+
} else {
|
|
639
|
+
let n: number | undefined
|
|
640
|
+
if (ts.isNumericLiteral(depthNode)) {
|
|
641
|
+
n = Number(depthNode.text)
|
|
642
|
+
} else if (
|
|
643
|
+
ts.isPrefixUnaryExpression(depthNode) &&
|
|
644
|
+
depthNode.operator === ts.SyntaxKind.MinusToken &&
|
|
645
|
+
ts.isNumericLiteral(depthNode.operand)
|
|
646
|
+
) {
|
|
647
|
+
n = -Number(depthNode.operand.text)
|
|
648
|
+
}
|
|
649
|
+
if (n === undefined || Number.isNaN(n)) {
|
|
650
|
+
return {
|
|
651
|
+
kind: 'unsupported',
|
|
652
|
+
raw,
|
|
653
|
+
reason: `\`.flat(depth)\` needs a literal integer or \`Infinity\` depth — a computed depth can't be resolved at template time. Use a literal depth, or pre-compute the value before the template.`,
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const truncated = Math.trunc(n)
|
|
657
|
+
flatDepth = truncated < 0 ? 0 : truncated
|
|
658
|
+
}
|
|
659
|
+
return { kind: 'array-method', method: 'flat', object: callee.object, args: [], flatDepth }
|
|
660
|
+
}
|
|
396
661
|
// `.toLowerCase()` — string-only (the IR carries a value-builtin
|
|
397
662
|
// tag, not a receiver-type discriminator, so the `array-method`
|
|
398
663
|
// label is a misnomer for string methods but the mechanical
|
|
399
664
|
// pipeline matches). Go uses the existing `bf_lower` helper;
|
|
400
665
|
// Mojo uses Perl's native `lc`. See #1448 Tier A.
|
|
401
|
-
if (callee.property === 'toLowerCase'
|
|
666
|
+
if (callee.property === 'toLowerCase') {
|
|
402
667
|
return { kind: 'array-method', method: 'toLowerCase', object: callee.object, args }
|
|
403
668
|
}
|
|
404
669
|
// `.toUpperCase()` — Go uses the existing `bf_upper` helper;
|
|
405
670
|
// Mojo uses Perl's native `uc`.
|
|
406
|
-
if (callee.property === 'toUpperCase'
|
|
671
|
+
if (callee.property === 'toUpperCase') {
|
|
407
672
|
return { kind: 'array-method', method: 'toUpperCase', object: callee.object, args }
|
|
408
673
|
}
|
|
409
674
|
// `.trim()` — Go uses the existing `bf_trim` helper; Mojo uses
|
|
410
675
|
// a new `bf->trim` method that mirrors JS's "strip leading +
|
|
411
676
|
// trailing whitespace" semantic via a Perl regex.
|
|
412
|
-
if (callee.property === 'trim'
|
|
677
|
+
if (callee.property === 'trim') {
|
|
413
678
|
return { kind: 'array-method', method: 'trim', object: callee.object, args }
|
|
414
679
|
}
|
|
680
|
+
// `.split()` / `.split(sep)` / `.split(sep, limit)` — string →
|
|
681
|
+
// array, full JS arity. `.split()` (no separator) returns the
|
|
682
|
+
// whole string as a single element; `.split(sep)` splits on the
|
|
683
|
+
// (literal) separator; the optional `limit` caps the number of
|
|
684
|
+
// pieces. JS ignores a third+ argument. Go uses `bf_split`
|
|
685
|
+
// (`strings.Split`, optional limit, normalised to `[]any`) and
|
|
686
|
+
// `bf_arr` for the no-separator whole-string case; Mojo uses
|
|
687
|
+
// `bf->split`. The regex-separator form stays refused (the parser
|
|
688
|
+
// never reaches here for it — a regex literal arg is `unsupported`
|
|
689
|
+
// and propagates). See #1448 Tier B.
|
|
690
|
+
if (callee.property === 'split') {
|
|
691
|
+
return { kind: 'array-method', method: 'split', object: callee.object, args }
|
|
692
|
+
}
|
|
693
|
+
// Arity guard for the forms whose EXTRA argument changes the
|
|
694
|
+
// result and is not yet lowered: the `fromIndex` of `.includes` /
|
|
695
|
+
// `.indexOf` / `.lastIndexOf` (the 2-arg form), and the additional
|
|
696
|
+
// arrays of a variadic `.concat(a, b, …)`. Silently dropping those
|
|
697
|
+
// would make the SSR output *differ* from the client (worse than a
|
|
698
|
+
// build error), so they refuse with BF101 until lowered. (The
|
|
699
|
+
// single-argument forms, zero-arg defaults, and JS-ignored
|
|
700
|
+
// trailing arguments of every method are accepted by the relaxed
|
|
701
|
+
// arms above.) See #1448.
|
|
702
|
+
if (LOWERED_ARRAY_METHODS.has(callee.property)) {
|
|
703
|
+
const argName = callee.property === 'concat' ? 'other' : 'x'
|
|
704
|
+
const detail =
|
|
705
|
+
callee.property === 'concat'
|
|
706
|
+
? 'the variadic `.concat(a, b, …)` form'
|
|
707
|
+
: `\`.${callee.property}(…)\` with ${args.length} argument(s)`
|
|
708
|
+
return {
|
|
709
|
+
kind: 'unsupported',
|
|
710
|
+
raw,
|
|
711
|
+
reason: `${detail} is not yet lowered to the Go/Mojo template adapters. Use the single-argument \`.${callee.property}(${argName})\` form, or pre-compute the value before the template.`,
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// `.startsWith(search, position?)` / `.endsWith(search, endPosition?)`
|
|
715
|
+
// — string → boolean, full JS arity. Go uses `bf_starts_with` /
|
|
716
|
+
// `bf_ends_with` (wrapping `strings.HasPrefix` / `strings.HasSuffix`,
|
|
717
|
+
// with an optional position that re-anchors the comparison); Mojo
|
|
718
|
+
// uses `bf->starts_with` / `bf->ends_with` (substr comparison). JS
|
|
719
|
+
// ignores a third+ argument. The zero-arg form (`.startsWith()`) is
|
|
720
|
+
// refused: JS coerces the missing search to the literal string
|
|
721
|
+
// "undefined", a degenerate result not worth lowering (mirrors the
|
|
722
|
+
// `.includes()` zero-arg refusal). See #1448 Tier B.
|
|
723
|
+
if (callee.property === 'startsWith' || callee.property === 'endsWith') {
|
|
724
|
+
if (args.length === 0) {
|
|
725
|
+
return {
|
|
726
|
+
kind: 'unsupported',
|
|
727
|
+
raw,
|
|
728
|
+
reason: `\`.${callee.property}()\` with no search string is not lowered — JS coerces the missing argument to the string "undefined", a degenerate result. Pass an explicit search string, or pre-compute the value before the template.`,
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return { kind: 'array-method', method: callee.property, object: callee.object, args }
|
|
732
|
+
}
|
|
733
|
+
// `.replace(pattern, replacement)` — string-pattern form,
|
|
734
|
+
// replacing the FIRST occurrence (JS semantics for a string
|
|
735
|
+
// pattern). Go uses `bf_replace` (`strings.Replace` with n=1);
|
|
736
|
+
// Mojo uses `bf->replace` (index/substr splice, no regex). A
|
|
737
|
+
// regex-literal pattern parses as `unsupported` (convertNode has
|
|
738
|
+
// no regex arm), so it's refused explicitly here rather than
|
|
739
|
+
// emitting a broken `.Replace` — the Perl `s///` vs Go
|
|
740
|
+
// `regexp.ReplaceAllString` flavour gap is the open design
|
|
741
|
+
// question in #1448. `replaceAll` stays refused entirely.
|
|
742
|
+
//
|
|
743
|
+
// Full JS arity: a third+ argument is ignored (the adapter reads
|
|
744
|
+
// only the pattern + replacement). The one- and zero-argument
|
|
745
|
+
// forms are refused: JS coerces the missing replacement (and
|
|
746
|
+
// pattern) to the literal string "undefined", a degenerate result
|
|
747
|
+
// (mirrors the `.includes()` / `.startsWith()` zero-arg refusal).
|
|
748
|
+
if (callee.property === 'replace') {
|
|
749
|
+
if (args.length < 2) {
|
|
750
|
+
return {
|
|
751
|
+
kind: 'unsupported',
|
|
752
|
+
raw,
|
|
753
|
+
reason: `\`.replace(${args.length === 0 ? '' : 'pattern'})\` needs both a pattern and a replacement — JS coerces the missing argument to the string "undefined", a degenerate result. Pass both arguments, or pre-compute the value before the template.`,
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// A regex-literal pattern is the deferred form (the Perl `s///`
|
|
757
|
+
// vs Go `regexp.ReplaceAllString` flavour gap, #1448) — detect it
|
|
758
|
+
// on the TS node so the message is accurate. Any OTHER unsupported
|
|
759
|
+
// pattern/replacement (an object literal, an unsupported call, …)
|
|
760
|
+
// surfaces ITS OWN reason rather than being mislabelled as the
|
|
761
|
+
// regex form.
|
|
762
|
+
const patternNode = node.arguments[0]
|
|
763
|
+
if (patternNode && ts.isRegularExpressionLiteral(patternNode)) {
|
|
764
|
+
return {
|
|
765
|
+
kind: 'unsupported',
|
|
766
|
+
raw,
|
|
767
|
+
reason:
|
|
768
|
+
'String.prototype.replace supports only a string pattern + string replacement (the regex form is deferred); use a string pattern or wrap the expression in /* @client */',
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
const badArg =
|
|
772
|
+
args[0].kind === 'unsupported'
|
|
773
|
+
? args[0]
|
|
774
|
+
: args[1].kind === 'unsupported'
|
|
775
|
+
? args[1]
|
|
776
|
+
: undefined
|
|
777
|
+
if (badArg && badArg.kind === 'unsupported') {
|
|
778
|
+
return { kind: 'unsupported', raw, reason: badArg.reason }
|
|
779
|
+
}
|
|
780
|
+
return { kind: 'array-method', method: 'replace', object: callee.object, args }
|
|
781
|
+
}
|
|
782
|
+
// `.repeat(n)` — string → string (the receiver concatenated `n`
|
|
783
|
+
// times). Go uses `bf_repeat` (`strings.Repeat`, clamping a
|
|
784
|
+
// negative count to "" instead of panicking); Mojo uses
|
|
785
|
+
// `bf->repeat` (Perl's `x` operator). JS throws RangeError for a
|
|
786
|
+
// negative count, but SSR templates degrade to the empty string
|
|
787
|
+
// rather than crashing the render. See #1448 Tier B.
|
|
788
|
+
// Full JS arity: `.repeat()` (no count) is `repeat(0)` → "" (JS
|
|
789
|
+
// coerces the missing count to 0, not a RangeError), and a
|
|
790
|
+
// second+ argument is ignored. The adapter supplies the `0` for
|
|
791
|
+
// the no-argument form. See #1448 Tier B.
|
|
792
|
+
if (callee.property === 'repeat') {
|
|
793
|
+
return { kind: 'array-method', method: 'repeat', object: callee.object, args }
|
|
794
|
+
}
|
|
795
|
+
// `.padStart(target, pad?)` / `.padEnd(target, pad?)` — string →
|
|
796
|
+
// string, padded to `target` length with `pad` (default a single
|
|
797
|
+
// space) repeated + truncated to fill. Go uses `bf_pad_start` /
|
|
798
|
+
// `bf_pad_end`; Mojo uses `bf->pad_start` / `bf->pad_end`. Both
|
|
799
|
+
// count length in code points (Go runes / Perl chars) so they
|
|
800
|
+
// stay byte-equal — this differs from JS's UTF-16-unit length
|
|
801
|
+
// only for astral-plane receivers, which are vanishingly rare in
|
|
802
|
+
// numeric / space padding. See #1448 Tier B.
|
|
803
|
+
// Full JS arity: `.padStart()` (no target) is `padStart(0)` → the
|
|
804
|
+
// receiver unchanged (JS coerces the missing target to 0), and a
|
|
805
|
+
// third+ argument is ignored. The adapter supplies the `0` for the
|
|
806
|
+
// no-argument form and reads only target + padString.
|
|
807
|
+
if (callee.property === 'padStart' || callee.property === 'padEnd') {
|
|
808
|
+
return { kind: 'array-method', method: callee.property, object: callee.object, args }
|
|
809
|
+
}
|
|
415
810
|
// `.sort(cmp)` / `.toSorted(cmp)` (#1448 Tier B). The comparator
|
|
416
811
|
// is extracted into a structured `SortComparator` at parse time;
|
|
417
812
|
// unrecognised shapes fall through to `unsupported` so adapters
|
|
@@ -450,6 +845,85 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
|
|
|
450
845
|
`Wrap the call in /* @client */ to evaluate at hydration.`,
|
|
451
846
|
}
|
|
452
847
|
}
|
|
848
|
+
|
|
849
|
+
// `.reduce(fn, init)` / `.reduceRight(fn, init)` (#1448 Tier C).
|
|
850
|
+
// The reducer + init are extracted into a structured `ReduceOp` at
|
|
851
|
+
// parse time; the two-param arrow never reaches the standard
|
|
852
|
+
// convertNode path (which refuses it), so we read the raw TS AST.
|
|
853
|
+
// Only the arithmetic-fold catalogue lowers — anything else, or a
|
|
854
|
+
// missing init, falls through to `unsupported` (BF101 + @client
|
|
855
|
+
// hint). `reduceRight` shares the catalogue; the method name is
|
|
856
|
+
// preserved so adapters fold right-to-left (only observable for
|
|
857
|
+
// string concatenation — numeric sum / product are commutative).
|
|
858
|
+
if (
|
|
859
|
+
(callee.property === 'reduce' || callee.property === 'reduceRight') &&
|
|
860
|
+
node.arguments.length === 2
|
|
861
|
+
) {
|
|
862
|
+
const reduceOp = extractReduceOpFromTS(node.arguments[0], node.arguments[1])
|
|
863
|
+
if (reduceOp) {
|
|
864
|
+
return {
|
|
865
|
+
kind: 'array-method',
|
|
866
|
+
method: callee.property,
|
|
867
|
+
object: callee.object,
|
|
868
|
+
args: [],
|
|
869
|
+
reduceOp,
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
const m = callee.property
|
|
873
|
+
return {
|
|
874
|
+
kind: 'unsupported',
|
|
875
|
+
raw,
|
|
876
|
+
reason:
|
|
877
|
+
`Reduce shape not supported. Accepted (arithmetic fold, explicit init):\n` +
|
|
878
|
+
` arr.${m}((acc, x) => acc + x, 0)\n` +
|
|
879
|
+
` arr.${m}((acc, x) => acc + x.field, 0)\n` +
|
|
880
|
+
` arr.${m}((acc, x) => acc * x.field, 1)\n` +
|
|
881
|
+
` arr.${m}((acc, x) => acc + x.field, '') (string concat)\n` +
|
|
882
|
+
`The accumulator must be the left operand and the initial ` +
|
|
883
|
+
`value a number / string literal. ` +
|
|
884
|
+
`Wrap the call in /* @client */ to evaluate at hydration.`,
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
// A `.reduce(fn)` without an initial value can't be lowered: JS
|
|
888
|
+
// throws on an empty array, which a template can't mirror. Fall
|
|
889
|
+
// through to the BF101 gate with the @client escape hatch.
|
|
890
|
+
|
|
891
|
+
// `.flatMap(fn)` value-returning projection (#1448 Tier C). The
|
|
892
|
+
// callback is extracted into a structured `FlatMapOp` (self / field
|
|
893
|
+
// scalar projection, or an array-literal tuple of self / field
|
|
894
|
+
// leaves) from the raw TS AST. The JSX-returning form is handled as
|
|
895
|
+
// an `IRLoop` upstream and never reaches here; richer callbacks
|
|
896
|
+
// refuse with BF101 + the @client hint. Go uses `bf_flat_map` /
|
|
897
|
+
// `bf_flat_map_tuple`; Mojo uses `bf->flat_map` / `bf->flat_map_tuple`.
|
|
898
|
+
// Intercept EVERY `.flatMap(...)` call (not just the 1-arg form) so
|
|
899
|
+
// the off-catalogue and wrong-arity shapes get this tailored reason
|
|
900
|
+
// rather than the generic "flatMap has no template lowering" gate
|
|
901
|
+
// message, which now misleads (the field-projection form does lower).
|
|
902
|
+
if (callee.property === 'flatMap') {
|
|
903
|
+
const flatMapOp =
|
|
904
|
+
node.arguments.length === 1 ? extractFlatMapOpFromTS(node.arguments[0]) : null
|
|
905
|
+
if (flatMapOp) {
|
|
906
|
+
return {
|
|
907
|
+
kind: 'array-method',
|
|
908
|
+
method: 'flatMap',
|
|
909
|
+
object: callee.object,
|
|
910
|
+
args: [],
|
|
911
|
+
flatMapOp,
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return {
|
|
915
|
+
kind: 'unsupported',
|
|
916
|
+
raw,
|
|
917
|
+
reason:
|
|
918
|
+
`flatMap shape not supported. Accepted (self / field leaves, no thisArg):\n` +
|
|
919
|
+
` arr.flatMap(i => i) (flatten one level)\n` +
|
|
920
|
+
` arr.flatMap(i => i.field) (flatten a per-item array field)\n` +
|
|
921
|
+
` arr.flatMap(i => [i.a, i.b]) (gather per-item fields)\n` +
|
|
922
|
+
`Richer callbacks (computed / nested access, arithmetic, calls, ` +
|
|
923
|
+
`literal elements) and the 2-arg \`flatMap(fn, thisArg)\` form ` +
|
|
924
|
+
`aren't lowered. Wrap the call in /* @client */ to evaluate at hydration.`,
|
|
925
|
+
}
|
|
926
|
+
}
|
|
453
927
|
}
|
|
454
928
|
|
|
455
929
|
return { kind: 'call', callee, args }
|
|
@@ -1036,6 +1510,218 @@ function classifySortOperand(
|
|
|
1036
1510
|
return null
|
|
1037
1511
|
}
|
|
1038
1512
|
|
|
1513
|
+
/**
|
|
1514
|
+
* Recover a `ReduceOp` from the `(reducer, init)` args of
|
|
1515
|
+
* `.reduce(...)` (#1448 Tier C). Operates on the raw TS AST because the
|
|
1516
|
+
* standard `convertNode` arrow-fn path rejects two-param arrows.
|
|
1517
|
+
*
|
|
1518
|
+
* The accepted catalogue is intentionally finite — only the
|
|
1519
|
+
* arithmetic-fold family lowers to a declarative template:
|
|
1520
|
+
*
|
|
1521
|
+
* (acc, x) => acc + x → self, numeric (init: number)
|
|
1522
|
+
* (acc, x) => acc + x.field → field, numeric (init: number)
|
|
1523
|
+
* (acc, x) => acc * x → self, numeric (init: number)
|
|
1524
|
+
* (acc, x) => acc * x.field → field, numeric (init: number)
|
|
1525
|
+
* (acc, x) => acc + x → self, string (init: string → concat)
|
|
1526
|
+
* (acc, x) => acc + x.field → field, string (init: string → concat)
|
|
1527
|
+
*
|
|
1528
|
+
* The accumulator must be the binary expression's *left* operand
|
|
1529
|
+
* (canonical reduce form; reversed operands change string-concat
|
|
1530
|
+
* order), the per-item value must be the item param itself or a
|
|
1531
|
+
* single non-computed field access on it, and the init must be a
|
|
1532
|
+
* number or string literal (negative numbers via prefix `-` allowed).
|
|
1533
|
+
* String concatenation requires `+`. Block bodies reduce to a single
|
|
1534
|
+
* `return`, mirroring the sort extractor. Anything else returns null
|
|
1535
|
+
* and the caller emits `unsupported` (BF101).
|
|
1536
|
+
*/
|
|
1537
|
+
export function extractReduceOpFromTS(
|
|
1538
|
+
reducerNode: ts.Node,
|
|
1539
|
+
initNode: ts.Node,
|
|
1540
|
+
): ReduceOp | null {
|
|
1541
|
+
const init = classifyReduceInit(initNode)
|
|
1542
|
+
if (!init) return null
|
|
1543
|
+
|
|
1544
|
+
if (!ts.isArrowFunction(reducerNode) && !ts.isFunctionExpression(reducerNode)) return null
|
|
1545
|
+
// Exactly `(acc, item)` — the index / array reducer params can't be
|
|
1546
|
+
// expressed in a template fold, so refuse the 3- / 4-param forms.
|
|
1547
|
+
if (reducerNode.parameters.length !== 2) return null
|
|
1548
|
+
const pAcc = reducerNode.parameters[0]
|
|
1549
|
+
const pItem = reducerNode.parameters[1]
|
|
1550
|
+
if (!ts.isIdentifier(pAcc.name) || !ts.isIdentifier(pItem.name)) return null
|
|
1551
|
+
const paramAcc = pAcc.name.text
|
|
1552
|
+
const paramItem = pItem.name.text
|
|
1553
|
+
|
|
1554
|
+
// Resolve the reducer body: expression-bodied arrow directly; block
|
|
1555
|
+
// bodies (arrow `=> { … }` and function expressions) must reduce to
|
|
1556
|
+
// exactly one `return <expr>;` — mirrors `extractSortComparatorFromTS`.
|
|
1557
|
+
let body: ts.Expression
|
|
1558
|
+
if (ts.isArrowFunction(reducerNode) && !ts.isBlock(reducerNode.body)) {
|
|
1559
|
+
body = reducerNode.body
|
|
1560
|
+
} else {
|
|
1561
|
+
const block = reducerNode.body as ts.Block
|
|
1562
|
+
const stmts = block.statements
|
|
1563
|
+
if (stmts.length !== 1 || !ts.isReturnStatement(stmts[0]) || !stmts[0].expression) return null
|
|
1564
|
+
body = stmts[0].expression
|
|
1565
|
+
}
|
|
1566
|
+
const raw = body.getText()
|
|
1567
|
+
|
|
1568
|
+
const expr = unwrapParens(body)
|
|
1569
|
+
if (!ts.isBinaryExpression(expr)) return null
|
|
1570
|
+
let op: '+' | '*'
|
|
1571
|
+
if (expr.operatorToken.kind === ts.SyntaxKind.PlusToken) op = '+'
|
|
1572
|
+
else if (expr.operatorToken.kind === ts.SyntaxKind.AsteriskToken) op = '*'
|
|
1573
|
+
else return null
|
|
1574
|
+
|
|
1575
|
+
// The accumulator must be the left operand (`acc + x`, not `x + acc`).
|
|
1576
|
+
const left = unwrapParens(expr.left)
|
|
1577
|
+
if (!ts.isIdentifier(left) || left.text !== paramAcc) return null
|
|
1578
|
+
|
|
1579
|
+
const key = classifyReduceKey(unwrapParens(expr.right), paramItem)
|
|
1580
|
+
if (!key) return null
|
|
1581
|
+
|
|
1582
|
+
// String concatenation only makes sense with `+`.
|
|
1583
|
+
const type: 'numeric' | 'string' = init.type
|
|
1584
|
+
if (type === 'string' && op !== '+') return null
|
|
1585
|
+
|
|
1586
|
+
return { op, key, type, init: init.value, raw, paramAcc, paramItem }
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* Recover a `FlatMapOp` from the single-argument callback of a
|
|
1591
|
+
* value-returning `.flatMap(fn)` (#1448 Tier C). Operates on the raw TS
|
|
1592
|
+
* AST, mirroring `extractReduceOpFromTS`.
|
|
1593
|
+
*
|
|
1594
|
+
* The accepted catalogue:
|
|
1595
|
+
*
|
|
1596
|
+
* i => i → self (flatMap(identity) === flat(1))
|
|
1597
|
+
* i => i.field → field (flatten a per-item array field)
|
|
1598
|
+
* i => [i.a, i.b] → tuple (gather per-item self / field leaves)
|
|
1599
|
+
*
|
|
1600
|
+
* The callback must take exactly one identifier param (the index / array
|
|
1601
|
+
* params can't be expressed in a template projection), and the body must
|
|
1602
|
+
* be the param itself, a single non-computed field access on it, or an
|
|
1603
|
+
* array literal whose every element is one of those leaves. Block bodies
|
|
1604
|
+
* reduce to a single `return`, like the reduce / sort extractors. Any
|
|
1605
|
+
* other body (deep access, computed members, calls, arithmetic, a
|
|
1606
|
+
* literal element) returns null and the caller emits `unsupported`
|
|
1607
|
+
* (BF101).
|
|
1608
|
+
*/
|
|
1609
|
+
export function extractFlatMapOpFromTS(cbNode: ts.Node): FlatMapOp | null {
|
|
1610
|
+
if (!ts.isArrowFunction(cbNode) && !ts.isFunctionExpression(cbNode)) return null
|
|
1611
|
+
// Exactly `(item)` — a `(item, index)` / `(item, index, array)` callback
|
|
1612
|
+
// can't be lowered to a declarative projection.
|
|
1613
|
+
if (cbNode.parameters.length !== 1) return null
|
|
1614
|
+
const p = cbNode.parameters[0]
|
|
1615
|
+
if (!ts.isIdentifier(p.name)) return null
|
|
1616
|
+
const param = p.name.text
|
|
1617
|
+
|
|
1618
|
+
let body: ts.Expression
|
|
1619
|
+
if (ts.isArrowFunction(cbNode) && !ts.isBlock(cbNode.body)) {
|
|
1620
|
+
body = cbNode.body
|
|
1621
|
+
} else {
|
|
1622
|
+
const block = cbNode.body as ts.Block
|
|
1623
|
+
const stmts = block.statements
|
|
1624
|
+
if (stmts.length !== 1 || !ts.isReturnStatement(stmts[0]) || !stmts[0].expression) return null
|
|
1625
|
+
body = stmts[0].expression
|
|
1626
|
+
}
|
|
1627
|
+
const raw = body.getText()
|
|
1628
|
+
const inner = unwrapParens(body)
|
|
1629
|
+
|
|
1630
|
+
// Array-literal body → tuple projection. Every element must be a
|
|
1631
|
+
// self / field leaf; a literal / computed / nested element refuses the
|
|
1632
|
+
// whole shape (the per-item evaluation of richer expressions isn't
|
|
1633
|
+
// lowered). flat(1) removes only the literal's wrapper, so each leaf is
|
|
1634
|
+
// appended verbatim — handled by the `bf_flat_map_tuple` runtime.
|
|
1635
|
+
if (ts.isArrayLiteralExpression(inner)) {
|
|
1636
|
+
// An empty tuple (`i => []`) is a degenerate no-op projection (always
|
|
1637
|
+
// yields nothing). Refuse it so the emitters never produce a
|
|
1638
|
+
// zero-arg `bf_flat_map_tuple` / `bf->flat_map_tuple(...,)` call.
|
|
1639
|
+
if (inner.elements.length === 0) return null
|
|
1640
|
+
const elements: FlatMapLeaf[] = []
|
|
1641
|
+
for (const el of inner.elements) {
|
|
1642
|
+
// Spread / holes (`[...xs]`, `[, x]`) aren't leaves.
|
|
1643
|
+
if (ts.isSpreadElement(el) || ts.isOmittedExpression(el)) return null
|
|
1644
|
+
const leaf = classifyReduceKey(unwrapParens(el), param)
|
|
1645
|
+
if (!leaf) return null
|
|
1646
|
+
elements.push(leaf)
|
|
1647
|
+
}
|
|
1648
|
+
return { projection: { kind: 'tuple', elements }, param, raw }
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// Scalar body. Reuse the reduce key classifier — `i` → self,
|
|
1652
|
+
// `i.field` → field, null for anything deeper (`i.a.b`, `i[k]`, a call).
|
|
1653
|
+
const leaf = classifyReduceKey(inner, param)
|
|
1654
|
+
if (!leaf) return null
|
|
1655
|
+
|
|
1656
|
+
return { projection: leaf, param, raw }
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* Classify a reduce per-item operand into a `ReduceOp` key. Accepts
|
|
1661
|
+
* the bare item param (`x` → self) and a single non-computed field
|
|
1662
|
+
* access (`x.field` → field); returns null for anything deeper
|
|
1663
|
+
* (`x.a.b`, `x[k]`, a literal, a call, …).
|
|
1664
|
+
*/
|
|
1665
|
+
function classifyReduceKey(
|
|
1666
|
+
expr: ts.Expression,
|
|
1667
|
+
paramItem: string,
|
|
1668
|
+
): { kind: 'self' } | { kind: 'field'; field: string } | null {
|
|
1669
|
+
if (ts.isIdentifier(expr)) {
|
|
1670
|
+
return expr.text === paramItem ? { kind: 'self' } : null
|
|
1671
|
+
}
|
|
1672
|
+
if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression)) {
|
|
1673
|
+
if (expr.expression.text === paramItem) return { kind: 'field', field: expr.name.text }
|
|
1674
|
+
}
|
|
1675
|
+
return null
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/**
|
|
1679
|
+
* Classify a reduce initial-value node into the *decoded* fold seed.
|
|
1680
|
+
* Accepts a numeric literal (optionally prefixed with `-`) and a string
|
|
1681
|
+
* literal; returns `{ type, value }` where `value` is the canonical
|
|
1682
|
+
* value — never the raw source text. Any other init (a variable, a
|
|
1683
|
+
* call, an object) returns null — the fold start value must be
|
|
1684
|
+
* statically known.
|
|
1685
|
+
*
|
|
1686
|
+
* Numeric: `node.text` is TypeScript's canonical decimal form, so
|
|
1687
|
+
* separators and non-decimal radices fold uniformly across adapters
|
|
1688
|
+
* (`1_000` → `1000`, `0x10` → `16`, `1e3` → `1000`). The Go runtime's
|
|
1689
|
+
* `strconv.ParseFloat` and Perl both accept that decimal string —
|
|
1690
|
+
* passing the raw source (`0x10`, `1_000`) would silently fold to 0 on
|
|
1691
|
+
* Go while Perl accepted it (#1728 review).
|
|
1692
|
+
*
|
|
1693
|
+
* String: `node.text` is the *unescaped* contents. To keep the three
|
|
1694
|
+
* adapters byte-equal without teaching each one to re-decode JS escapes
|
|
1695
|
+
* (`\n`, `\u{…}`, `\\`, an escaped quote), we refuse any string literal
|
|
1696
|
+
* whose contents differ from its raw inner source — i.e. any literal
|
|
1697
|
+
* carrying an escape sequence. Accepted seeds are therefore escape-free
|
|
1698
|
+
* single-line strings (`''`, `', '`, `'-'`), which embed safely in both
|
|
1699
|
+
* the Go-template `"…"` operand and the Perl single-quoted literal. The
|
|
1700
|
+
* realistic concat seed (`''`) is unaffected; richer seeds fall back to
|
|
1701
|
+
* the `@client` escape hatch.
|
|
1702
|
+
*/
|
|
1703
|
+
function classifyReduceInit(
|
|
1704
|
+
node: ts.Node,
|
|
1705
|
+
): { type: 'numeric' | 'string'; value: string } | null {
|
|
1706
|
+
// Unwrap redundant parens (`(0)` / `(-1)`) so they classify like the
|
|
1707
|
+
// bare literal — matches the extractor's `unwrapParens` use elsewhere.
|
|
1708
|
+
let n: ts.Node = unwrapParens(node as ts.Expression)
|
|
1709
|
+
// `-1` parses as a prefix-minus over a numeric literal.
|
|
1710
|
+
if (ts.isPrefixUnaryExpression(n) && n.operator === ts.SyntaxKind.MinusToken) {
|
|
1711
|
+
if (ts.isNumericLiteral(n.operand)) return { type: 'numeric', value: '-' + n.operand.text }
|
|
1712
|
+
return null
|
|
1713
|
+
}
|
|
1714
|
+
if (ts.isNumericLiteral(n)) return { type: 'numeric', value: n.text }
|
|
1715
|
+
if (ts.isStringLiteral(n)) {
|
|
1716
|
+
// Refuse literals carrying escapes so the decoded value equals its
|
|
1717
|
+
// raw inner source (and thus embeds safely + byte-equal everywhere).
|
|
1718
|
+
const raw = n.getText()
|
|
1719
|
+
if (raw.length < 2 || raw.slice(1, -1) !== n.text) return null
|
|
1720
|
+
return { type: 'string', value: n.text }
|
|
1721
|
+
}
|
|
1722
|
+
return null
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1039
1725
|
/**
|
|
1040
1726
|
* Per-binding entry stored in `fieldMap`: the dotted path from the
|
|
1041
1727
|
* synthetic param down to the field, plus an optional default
|
|
@@ -1584,6 +2270,22 @@ function substituteDestructuredFields(
|
|
|
1584
2270
|
// enclosing destructure). Preserve verbatim.
|
|
1585
2271
|
return { kind: 'array-method', method: e.method, object: walk(e.object), args: [], comparator: e.comparator }
|
|
1586
2272
|
}
|
|
2273
|
+
if (e.method === 'reduce' || e.method === 'reduceRight') {
|
|
2274
|
+
// `ReduceOp` is a structured value referencing its own
|
|
2275
|
+
// paramAcc / paramItem, never the enclosing destructure —
|
|
2276
|
+
// preserve verbatim, same as the sort comparator above.
|
|
2277
|
+
return { kind: 'array-method', method: e.method, object: walk(e.object), args: [], reduceOp: e.reduceOp }
|
|
2278
|
+
}
|
|
2279
|
+
if (e.method === 'flat') {
|
|
2280
|
+
// `flatDepth` is a normalised literal — no destructure refs to
|
|
2281
|
+
// substitute. Preserve verbatim, same as sort / reduce above.
|
|
2282
|
+
return { kind: 'array-method', method: 'flat', object: walk(e.object), args: [], flatDepth: e.flatDepth }
|
|
2283
|
+
}
|
|
2284
|
+
if (e.method === 'flatMap') {
|
|
2285
|
+
// `FlatMapOp` references its own callback param, never the
|
|
2286
|
+
// enclosing destructure — preserve verbatim, like reduce / sort.
|
|
2287
|
+
return { kind: 'array-method', method: 'flatMap', object: walk(e.object), args: [], flatMapOp: e.flatMapOp }
|
|
2288
|
+
}
|
|
1587
2289
|
return { kind: 'array-method', method: e.method, object: walk(e.object), args: e.args.map(walk) }
|
|
1588
2290
|
case 'literal':
|
|
1589
2291
|
case 'unsupported':
|
|
@@ -1735,11 +2437,17 @@ function checkSupport(expr: ParsedExpr): SupportResult {
|
|
|
1735
2437
|
// This handles the case where the pattern wasn't recognized as higher-order
|
|
1736
2438
|
if (expr.callee.kind === 'member') {
|
|
1737
2439
|
const methodName = expr.callee.property
|
|
2440
|
+
// No template lowering → BF101. `UNSUPPORTED_METHOD_REASONS` supplies
|
|
2441
|
+
// a tailored reason for some methods (e.g. `forEach`); the rest get the
|
|
2442
|
+
// generic hint. Both already carry next steps, hence `selfContained`.
|
|
1738
2443
|
if (UNSUPPORTED_METHODS.has(methodName)) {
|
|
1739
2444
|
return {
|
|
1740
2445
|
supported: false,
|
|
1741
2446
|
level: 'L5_UNSUPPORTED',
|
|
1742
|
-
|
|
2447
|
+
selfContained: true,
|
|
2448
|
+
reason:
|
|
2449
|
+
UNSUPPORTED_METHOD_REASONS[methodName] ??
|
|
2450
|
+
`'${methodName}()' can't render on the server. Pre-compute the value, or add /* @client */ for client-only (no SSR).`,
|
|
1743
2451
|
}
|
|
1744
2452
|
}
|
|
1745
2453
|
}
|
|
@@ -2050,6 +2758,24 @@ export function exprToString(expr: ParsedExpr): string {
|
|
|
2050
2758
|
const { paramA, paramB, raw } = expr.comparator
|
|
2051
2759
|
return `${exprToString(expr.object)}.${expr.method}((${paramA},${paramB}) => ${raw})`
|
|
2052
2760
|
}
|
|
2761
|
+
if (expr.method === 'reduce' || expr.method === 'reduceRight') {
|
|
2762
|
+
const { paramAcc, paramItem, raw, type, init } = expr.reduceOp
|
|
2763
|
+
// `init` is the decoded value: re-quote a string seed, re-emit a
|
|
2764
|
+
// numeric seed as-is (it's already a valid number literal).
|
|
2765
|
+
const initSrc = type === 'string' ? JSON.stringify(init) : init
|
|
2766
|
+
return `${exprToString(expr.object)}.${expr.method}((${paramAcc},${paramItem}) => ${raw}, ${initSrc})`
|
|
2767
|
+
}
|
|
2768
|
+
if (expr.method === 'flat') {
|
|
2769
|
+
// Preserve the normalised depth so diagnostics don't misleadingly
|
|
2770
|
+
// print `.flat()` for a `.flat(2)` / `.flat(Infinity)` source.
|
|
2771
|
+
const d = expr.flatDepth
|
|
2772
|
+
const depthSrc = d === 'infinity' ? 'Infinity' : String(d)
|
|
2773
|
+
return `${exprToString(expr.object)}.flat(${d === 1 ? '' : depthSrc})`
|
|
2774
|
+
}
|
|
2775
|
+
if (expr.method === 'flatMap') {
|
|
2776
|
+
const { param, raw } = expr.flatMapOp
|
|
2777
|
+
return `${exprToString(expr.object)}.flatMap(${param} => ${raw})`
|
|
2778
|
+
}
|
|
2053
2779
|
return `${exprToString(expr.object)}.${expr.method}(${expr.args.map(exprToString).join(', ')})`
|
|
2054
2780
|
case 'unsupported':
|
|
2055
2781
|
return `[UNSUPPORTED: ${expr.raw}]`
|
|
@@ -2112,6 +2838,29 @@ export function stringifyParsedExpr(expr: ParsedExpr): string {
|
|
|
2112
2838
|
const { paramA, paramB, raw } = expr.comparator
|
|
2113
2839
|
return `${stringifyParsedExpr(expr.object)}.${expr.method}((${paramA},${paramB}) => ${raw})`
|
|
2114
2840
|
}
|
|
2841
|
+
if (expr.method === 'reduce' || expr.method === 'reduceRight') {
|
|
2842
|
+
// Round-trip the user's param names + init so downstream
|
|
2843
|
+
// re-parsers (the CSR / Hono JS path, templatePrimitive
|
|
2844
|
+
// substitution) see valid JS — `raw` references the names
|
|
2845
|
+
// verbatim. `init` is the decoded value: re-quote a string
|
|
2846
|
+
// seed via JSON.stringify, re-emit a numeric seed as-is.
|
|
2847
|
+
const { paramAcc, paramItem, raw, type, init } = expr.reduceOp
|
|
2848
|
+
const initSrc = type === 'string' ? JSON.stringify(init) : init
|
|
2849
|
+
return `${stringifyParsedExpr(expr.object)}.${expr.method}((${paramAcc},${paramItem}) => ${raw}, ${initSrc})`
|
|
2850
|
+
}
|
|
2851
|
+
if (expr.method === 'flat') {
|
|
2852
|
+
// Round-trip the normalised depth back to JS for the CSR / Hono
|
|
2853
|
+
// path: `'infinity'` → `Infinity`, `1` is left implicit (`.flat()`).
|
|
2854
|
+
const d = expr.flatDepth
|
|
2855
|
+
const depthSrc = d === 'infinity' ? 'Infinity' : String(d)
|
|
2856
|
+
return `${stringifyParsedExpr(expr.object)}.flat(${d === 1 ? '' : depthSrc})`
|
|
2857
|
+
}
|
|
2858
|
+
if (expr.method === 'flatMap') {
|
|
2859
|
+
// Round-trip the user's callback param + body so the CSR / Hono
|
|
2860
|
+
// path re-parses valid JS (`raw` references the param verbatim).
|
|
2861
|
+
const { param, raw } = expr.flatMapOp
|
|
2862
|
+
return `${stringifyParsedExpr(expr.object)}.flatMap(${param} => ${raw})`
|
|
2863
|
+
}
|
|
2115
2864
|
return `${stringifyParsedExpr(expr.object)}.${expr.method}(${expr.args.map(stringifyParsedExpr).join(', ')})`
|
|
2116
2865
|
case 'unsupported':
|
|
2117
2866
|
return expr.raw
|